water_mini_api_test.php 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536
  1. <?php
  2. /** Real Eloquent transactions, every fixture including uploaded photo bytes is rolled back. */
  3. require dirname(__DIR__) . '/vendor/autoload.php';
  4. require __DIR__ . '/support/MiniApiContract.php';
  5. \Dotenv\Dotenv::createUnsafeMutable(dirname(__DIR__))->load();
  6. \support\App::loadAllConfig(['route']);
  7. date_default_timezone_set('Asia/Shanghai');
  8. use app\miniapi\auth\Principal;
  9. use app\miniapi\auth\SessionVerifier;
  10. use app\miniapi\model\MiniUser;
  11. use app\miniapi\model\MiniSession;
  12. use app\miniapi\model\MiniGrant;
  13. use app\miniapi\model\MiniCheckout;
  14. use app\miniapi\model\MiniEnterpriseBalance;
  15. use app\miniapi\model\MiniCommand;
  16. use app\miniapi\model\MiniAudit;
  17. use app\miniapi\model\MiniTaskRecord;
  18. use app\miniapi\model\MiniPhoto;
  19. use app\miniapi\service\Access;
  20. use app\miniapi\service\AuthService;
  21. use app\miniapi\service\Catalog;
  22. use app\miniapi\service\EnterpriseQuery;
  23. use app\miniapi\service\EnterpriseCommands;
  24. use app\miniapi\service\CheckoutService;
  25. use app\miniapi\service\DriverService;
  26. use app\miniapi\service\AgentQuery;
  27. use app\miniapi\service\IdentityManagement;
  28. use app\miniapi\exception\ApiException;
  29. use app\miniapi\controller\CheckoutController;
  30. use app\miniapi\controller\PhotoController;
  31. use plugin\saiadmin\app\model\water\WaterEnterprise;
  32. use plugin\saiadmin\app\model\water\WaterSku;
  33. use plugin\saiadmin\app\model\water\WaterAccount;
  34. use plugin\saiadmin\app\model\water\WaterOrder;
  35. use plugin\saiadmin\app\model\water\WaterDelivery;
  36. use plugin\saiadmin\app\model\water\WaterContent;
  37. use plugin\saiadmin\app\service\WaterBusiness;
  38. use plugin\saiadmin\app\service\WaterDispatch;
  39. use support\Request;
  40. use app\miniapi\support\PhoneNumber;
  41. use app\miniapi\http\Fields;
  42. $checks = 0;
  43. $contract = new MiniApiContract();
  44. function check(bool $ok, string $label): void
  45. {
  46. global $checks;
  47. $checks++;
  48. if (!$ok) {
  49. throw new RuntimeException($label);
  50. }
  51. }
  52. function reject(callable $fn, int $code, string $label): void
  53. {
  54. try {
  55. $fn();
  56. } catch (ApiException $e) {
  57. check($e->getCode() === $code, $label . ' actual ' . $e->getCode());
  58. return;
  59. }throw new RuntimeException('Missing rejection: ' . $label);
  60. }
  61. function inputRequest(string $path, array $data, Principal $actor): Request
  62. {
  63. $body = json_encode($data);
  64. $r = new Request("POST $path HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: " . strlen($body) . "\r\n\r\n" . $body);
  65. $r->miniPrincipal = $actor;
  66. return $r;
  67. }
  68. foreach ([
  69. '138-1234-5678' => ['13812345678', '138****5678'],
  70. '+86 138 1234 5678' => ['+8613812345678', '+861******5678'],
  71. '(010) 1234-5678' => ['01012345678', '010****5678'],
  72. '1234567' => ['1234567', '12***67'],
  73. ] as $input => [$normalized, $masked]) {
  74. check(PhoneNumber::normalize((string) $input) === $normalized, 'phone normalization');
  75. check(PhoneNumber::mask((string) $input) === $masked, 'formatted phone masked');
  76. }
  77. foreach (['------', '++++++', '()()()', '(01012345678', '138****5678', '123456', '+0123456789', '0000000', '1234567890123456'] as $phone) {
  78. reject(fn () => Fields::phone(['phone' => $phone]), 42201, 'invalid phone rejected');
  79. check(PhoneNumber::mask($phone) === '****', 'invalid legacy phone never exposed');
  80. }
  81. check(PhoneNumber::mask('') === '', 'missing phone stays empty');
  82. $models = [
  83. MiniUser::class,
  84. MiniSession::class,
  85. MiniGrant::class,
  86. MiniCheckout::class,
  87. MiniEnterpriseBalance::class,
  88. MiniCommand::class,
  89. MiniAudit::class,
  90. MiniTaskRecord::class,
  91. MiniPhoto::class,
  92. WaterEnterprise::class,
  93. WaterSku::class,
  94. WaterAccount::class,
  95. WaterOrder::class,
  96. WaterDelivery::class,
  97. ];
  98. $before = [];
  99. foreach ($models as $m) {
  100. $before[$m] = $m::query()->count();
  101. }
  102. $db = (new MiniUser())->getConnection();
  103. $db->beginTransaction();
  104. $tag = 'MINI-TEST-' . bin2hex(random_bytes(6));
  105. try {
  106. $business = new WaterBusiness();
  107. $auth = new AuthService();
  108. $manager = new IdentityManagement();
  109. $session = $auth->issue([
  110. 'appid' => 'test-' . $tag,
  111. 'openid' => 'u1',
  112. ]);
  113. $actor = (new SessionVerifier())->verify($session['access_token']);
  114. $other = $auth->issue([
  115. 'appid' => 'test-' . $tag,
  116. 'openid' => 'u2',
  117. ]);
  118. $otherActor = (new SessionVerifier())->verify($other['access_token']);
  119. check($session['identities'] === [], 'new login has no inherited authority');
  120. $provider = new class ('test-' . $tag) implements \app\miniapi\auth\IdentityProvider {
  121. public function __construct(private readonly string $appid)
  122. {
  123. }
  124. public function exchange(string $code): array
  125. {
  126. if ($code !== 'verified-test-code') {
  127. throw new ApiException(40102, 'invalid code', 401);
  128. }
  129. return ['appid' => $this->appid, 'openid' => 'u1'];
  130. }
  131. };
  132. $loginService = new AuthService($provider);
  133. check($loginService->login(['code' => 'verified-test-code'])['user_id'] === $session['user_id'], 'login service depends on identity adapter contract');
  134. reject(fn () => $loginService->login(['code' => 'verified-test-code', 'user_id' => 'forged']), 42201, 'client cannot supply provider identity');
  135. check(MiniSession::query()->where('user_id', $actor->userId)->value('token_hash') !== $session['access_token'], 'token hash only');
  136. reject(fn () => (new SessionVerifier())->verify('admin.jwt.token'), 40101, 'admin JWT not accepted');
  137. $enterprise = $business->save('enterprise', [
  138. 'name' => $tag,
  139. 'contact' => '?????????',
  140. 'phone' => '13812345678',
  141. 'address' => '????????????',
  142. 'region' => '?????????',
  143. 'employee_count' => 30,
  144. ], 1)['id'];
  145. $enterprise2 = $business->save('enterprise', [
  146. 'name' => $tag . '-other',
  147. 'contact' => '???',
  148. 'phone' => '13912345678',
  149. 'address' => '????????????',
  150. ], 1)['id'];
  151. $sku = $business->save('sku', [
  152. 'name' => $tag,
  153. 'liters' => 18.9,
  154. ], 1)['id'];
  155. $package = $business->save('package', [
  156. 'name' => $tag,
  157. 'sku_id' => $sku,
  158. 'buckets' => 30,
  159. 'price_cents' => 69900,
  160. ], 1)['id'];
  161. $business->purchase([
  162. 'enterprise_id' => $enterprise,
  163. 'package_id' => $package,
  164. 'reference' => $tag . '-receipt',
  165. ], 1);
  166. $business->stock([
  167. 'sku_id' => $sku,
  168. 'mode' => 'inbound',
  169. 'quantity' => 60,
  170. 'reference' => $tag . '-stock',
  171. 'note' => 'test',
  172. ], 1);
  173. $manager->grant(1, [
  174. 'user_id' => (int) $actor->userId,
  175. 'role' => 'enterprise_admin',
  176. 'resource_id' => $enterprise,
  177. ]);
  178. $manager->grant(1, [
  179. 'user_id' => (int) $otherActor->userId,
  180. 'role' => 'enterprise_member',
  181. 'resource_id' => $enterprise,
  182. ]);
  183. reject(fn () => $manager->grant(2, [
  184. 'user_id' => (int) $actor->userId,
  185. 'role' => 'enterprise_admin',
  186. 'resource_id' => $enterprise2,
  187. ]), 40301, 'only admin can bind identity');
  188. $access = new Access($actor);
  189. $query = new EnterpriseQuery($access);
  190. $commands = new EnterpriseCommands($access);
  191. $checkout = new CheckoutService($access);
  192. check(count($access->identities()) === 1, 'identity returned after explicit grant');
  193. reject(fn () => $access->enterprise($enterprise2), 40301, 'cross enterprise read forbidden');
  194. reject(fn () => (new Access($otherActor))->enterprise($enterprise, true), 40301, 'member cannot write');
  195. check($query->addresses()['items'][0]['phone'] === '138****5678', 'phone masking');
  196. check(count($query->addresses()['items']) === 1, 'addresses scoped');
  197. $rights = $query->rights($enterprise);
  198. check($rights['summary']['available'] === 30, 'real purchased rights');
  199. check($rights['items'][0]['expires_at'] === null && !$rights['capabilities']['individual_package_cards'], 'no fictitious expiry or separate cards');
  200. $catalog = (new Catalog())->detail($package);
  201. check($catalog['price_cents'] === 69900, 'catalog integer cents');
  202. WaterSku::query()->where('id', $sku)->update(['status' => 2]);
  203. reject(fn () => (new Catalog())->detail($package), 40401, 'disabled SKU hides package');
  204. WaterSku::query()->where('id', $sku)->update(['status' => 1]);
  205. $item = ['items' => [[
  206. 'package_id' => $package,
  207. 'quantity' => 2,
  208. ]]];
  209. $quote = $checkout->quote($enterprise, $item);
  210. check($quote['amount_cents'] === 139800 && $quote['discount_cents'] === 0, 'server quote does not copy mock discount');
  211. reject(fn () => $checkout->quote($enterprise, ['items' => [[
  212. 'package_id' => $package,
  213. 'quantity' => 1,
  214. ], [
  215. 'package_id' => $package,
  216. 'quantity' => 2,
  217. ]]]), 42201, 'duplicate package rejected');
  218. reject(fn () => $checkout->quote($enterprise, ['items' => [[
  219. 'package_id' => $package,
  220. 'quantity' => 1,
  221. 'price_cents' => 1,
  222. ]]]), 42201, 'client price rejected');
  223. $create = $item + [
  224. 'request_key' => $tag . '-checkout',
  225. 'expected_amount_cents' => 139800,
  226. 'agreement_accepted' => true,
  227. ];
  228. reject(fn () => $checkout->create($enterprise, array_replace($create, ['expected_amount_cents' => 1])), 40904, 'changed quote rejected');
  229. $order = $checkout->create($enterprise, $create);
  230. check($order['status'] === 'pending', 'checkout stored pending');
  231. check($checkout->create($enterprise, $create)['id'] === $order['id'], 'checkout retry reuses order');
  232. reject(fn () => $checkout->create($enterprise, array_replace($create, ['expected_amount_cents' => 1])), 40901, 'different replay rejected');
  233. check($query->rights($enterprise)['summary']['available'] === 30, 'pending order never credits rights');
  234. check(WaterOrder::query()->where('enterprise_id', $enterprise)->count() === 1, 'pending order never appears in receipts');
  235. $controller = new CheckoutController();
  236. $quoteResponse = $controller->quote(inputRequest('/api/mini/v1/test', $item, $actor), (string) $enterprise);
  237. check(json_decode($quoteResponse->rawBody(), true)['data']['amount_cents'] === 139800, 'nested JSON items work through HTTP controller');
  238. MiniEnterpriseBalance::query()->create([
  239. 'enterprise_id' => $enterprise,
  240. 'balance_cents' => 200000,
  241. 'created_at' => date('Y-m-d H:i:s'),
  242. 'updated_at' => date('Y-m-d H:i:s'),
  243. ]);
  244. $payKey = $tag . '-pay';
  245. $payResponse = $controller->pay(inputRequest('/api/mini/v1/test', [
  246. 'request_key' => $payKey,
  247. 'payment_method' => 'balance',
  248. ], $actor), (string) $enterprise, (string) $order['id']);
  249. $payBody = json_decode($payResponse->rawBody(), true);
  250. check($payBody['data']['status'] === 'paid', 'balance payment marks checkout paid');
  251. check((int) MiniEnterpriseBalance::query()->where('enterprise_id', $enterprise)->value('balance_cents') === 60200, 'balance payment deducts cents');
  252. check($query->rights($enterprise)['summary']['available'] === 90, 'balance payment grants purchased rights');
  253. check(WaterOrder::query()->where('enterprise_id', $enterprise)->count() === 3, 'balance payment creates receipts');
  254. check($checkout->pay($enterprise, $order['id'], [
  255. 'request_key' => $payKey,
  256. 'payment_method' => 'balance',
  257. ])['replayed'] === true, 'balance payment retry replays safely');
  258. reject(fn () => $controller->pay(inputRequest('/api/mini/v1/test', [
  259. 'request_key' => $tag . '-wechat',
  260. 'payment_method' => 'wechat',
  261. ], $actor), (string) $enterprise, (string) $order['id']), 50303, 'wechat payment remains reserved');
  262. check($checkout->detail($enterprise, $order['id'])['address']['phone'] === '138****5678', 'checkout address masked');
  263. check($query->orders($enterprise, ['kind' => 'checkouts'])['total'] === 1, 'checkout list real');
  264. $cancelOrder = $checkout->create($enterprise, array_replace($create, ['request_key' => $tag . '-cancel-create']));
  265. check($checkout->cancel($enterprise, $cancelOrder['id'], ['request_key' => $tag . '-cancel-order'])['status'] === 'cancelled', 'checkout cancellation');
  266. $address = $query->addresses()['items'][0];
  267. check($query->addressDetail($enterprise)['phone'] === '13812345678', 'enterprise admin can edit actual contact');
  268. reject(fn () => (new EnterpriseQuery(new Access($otherActor)))->addressDetail($enterprise), 40301, 'member cannot read editable contact');
  269. $changed = $commands->address($enterprise, [
  270. 'request_key' => $tag . '-address',
  271. 'revision' => $address['revision'],
  272. 'address' => '???????????????',
  273. 'contact' => '???',
  274. 'phone' => '138-1234-5678',
  275. ]);
  276. check(WaterEnterprise::query()->find($enterprise)->phone === '13812345678', 'address stores normalized phone');
  277. check(WaterEnterprise::query()->find($enterprise)->address === '???????????????', 'address update works');
  278. reject(fn () => $commands->address($enterprise, [
  279. 'request_key' => $tag . '-stale-address',
  280. 'revision' => $address['revision'],
  281. 'address' => '???????????????',
  282. 'contact' => '???',
  283. 'phone' => '13812345678',
  284. ]), 40901, 'stale address revision rejected even within same second');
  285. $booking = [
  286. 'sku_id' => $sku,
  287. 'quantity' => 8,
  288. 'request_key' => $tag . '-book',
  289. ];
  290. $task = $commands->book($enterprise, $booking)['id'];
  291. check($commands->book($enterprise, $booking)['id'] === $task, 'booking retry no duplicate');
  292. check($query->rights($enterprise)['summary']['frozen'] === 8, 'booking freezes existing rights');
  293. reject(fn () => $commands->book($enterprise, array_replace($booking, ['request_key' => $tag . '-book-other'])), 40902, 'second active booking rejected');
  294. reject(fn () => $commands->address($enterprise, [
  295. 'request_key' => $tag . '-address-active',
  296. 'revision' => $changed['revision'],
  297. 'address' => '??????',
  298. 'contact' => '???',
  299. 'phone' => '13812345678',
  300. ]), 40902, 'active task prevents moving address');
  301. check($commands->cancel($enterprise, $task, ['request_key' => $tag . '-cancel-task'])['status'] === 'cancelled', 'cancel pending task');
  302. check($query->rights($enterprise)['summary']['frozen'] === 0, 'cancel releases rights');
  303. $task = $commands->book($enterprise, array_replace($booking, ['request_key' => $tag . '-book-2']))['id'];
  304. $dispatch = new WaterDispatch();
  305. $driver = $dispatch->saveDriver([
  306. 'name' => $tag,
  307. 'phone' => $tag,
  308. 'region' => '?????????',
  309. 'vehicle' => 'test',
  310. 'capacity' => 100,
  311. ], 1)['id'];
  312. $dispatch->shift([
  313. 'driver_id' => $driver,
  314. 'date' => date('Y-m-d'),
  315. 'state' => 'on',
  316. ], 1);
  317. $dispatch->commit([
  318. 'date' => date('Y-m-d'),
  319. 'request_key' => $tag . '-route',
  320. 'routes' => [[
  321. 'driver_id' => $driver,
  322. 'task_ids' => [$task],
  323. ]],
  324. ], 1);
  325. $manager->grant(1, [
  326. 'user_id' => (int) $actor->userId,
  327. 'role' => 'driver',
  328. 'resource_id' => $driver,
  329. ]);
  330. $driverService = new DriverService($access);
  331. check($driverService->tasks($driver, [])['total'] === 1, 'driver sees only assigned task');
  332. check($driverService->detail($driver, $task)['contact_phone'] === '13812345678', 'assigned driver can call customer');
  333. reject(fn () => (new DriverService(new Access($otherActor)))->detail($driver, $task), 40301, 'unbound driver forbidden');
  334. reject(fn () => $commands->cancel($enterprise, $task, ['request_key' => $tag . '-cancel-assigned']), 40902, 'customer cannot cancel assigned task');
  335. check($driverService->command($driver, $task, 'start', ['request_key' => $tag . '-depart'])['status'] === 'transit', 'driver departure uses stock transaction');
  336. check($driverService->command($driver, $task, 'start', ['request_key' => $tag . '-depart'])['replayed'], 'departure replay');
  337. $driverService->command($driver, $task, 'checkin', [
  338. 'request_key' => $tag . '-checkin',
  339. 'latitude' => 22.52,
  340. 'longitude' => 113.92,
  341. ]);
  342. reject(fn () => $driverService->command($driver, $task, 'checkin', [
  343. 'request_key' => $tag . '-bad-location',
  344. 'latitude' => 100,
  345. 'longitude' => 113.92,
  346. ]), 42201, 'latitude range checked');
  347. $driverService->command($driver, $task, 'exception', [
  348. 'request_key' => $tag . '-exception',
  349. 'reason' => '??????????????????',
  350. ]);
  351. check(WaterDelivery::query()->find($task)->status === 'transit', 'exception report does not invent completion');
  352. $finish = [
  353. 'request_key' => $tag . '-complete',
  354. 'accepted_qty' => 8,
  355. 'empty_returned' => 0,
  356. 'receiver' => '???',
  357. 'confirmed' => true,
  358. 'photo_ids' => [],
  359. ];
  360. reject(fn () => $driverService->command($driver, $task, 'complete', $finish), 42201, 'completion requires photo');
  361. $img = imagecreatetruecolor(640, 100);
  362. ob_start();
  363. imagepng($img);
  364. $base64 = base64_encode(ob_get_clean());
  365. imagedestroy($img);
  366. $photoInput = [
  367. 'request_key' => $tag . '-photo',
  368. 'image_base64' => $base64,
  369. 'latitude' => 22.52,
  370. 'longitude' => 113.92,
  371. ];
  372. $photo = $driverService->photo($driver, $task, $photoInput);
  373. check($driverService->photo($driver, $task, $photoInput)['id'] === $photo['id'], 'photo upload idempotent');
  374. check(MiniPhoto::query()->find($photo['id'])->mime === 'image/jpeg', 'photos reencoded and watermarked');
  375. reject(fn () => $driverService->photo($driver, $task, array_replace($photoInput, [
  376. 'request_key' => $tag . '-bad-photo',
  377. 'image_base64' => base64_encode('<?php ?>'),
  378. ])), 42201, 'non image rejected');
  379. $draft = array_replace($finish, [
  380. 'request_key' => $tag . '-draft',
  381. 'photo_ids' => [$photo['id']],
  382. ]);
  383. check($driverService->command($driver, $task, 'draft', $draft)['saved'], 'draft saved without settlement');
  384. check($query->rights($enterprise)['summary']['remaining'] === 90, 'draft never consumes rights');
  385. $finish['photo_ids'] = [$photo['id']];
  386. check($driverService->command($driver, $task, 'complete', $finish)['status'] === 'completed', 'driver completes assigned task');
  387. check($driverService->command($driver, $task, 'complete', $finish)['replayed'], 'completion retry does not settle twice');
  388. check($driverService->detail($driver, $task)['contact_phone'] === null, 'completed task no longer exposes call number');
  389. $rights = $query->rights($enterprise);
  390. check($rights['summary']['remaining'] === 82 && $rights['summary']['frozen'] === 0, 'exactly eight rights consumed');
  391. check($rights['summary']['full_qty'] === 8, 'site inventory received');
  392. check($query->tracking($enterprise, $task)['photos'][0]['id'] == $photo['id'], 'customer sees own fulfillment evidence');
  393. reject(fn () => $query->tracking($enterprise2, $task), 40301, 'tracking cannot cross enterprise');
  394. $pr = new Request('GET /api/mini/v1/photos/' . $photo['id'] . '?scope=enterprise&resource_id=' . $enterprise . " HTTP/1.1\r\nHost: localhost\r\n\r\n");
  395. $pr->miniPrincipal = $actor;
  396. check((new PhotoController())->photo($pr, (string) $photo['id'])->getHeader('Content-Type') === 'image/jpeg', 'private photo authorized');
  397. $pr2 = new Request('GET /api/mini/v1/photos/' . $photo['id'] . '?scope=enterprise&resource_id=' . $enterprise2 . " HTTP/1.1\r\nHost: localhost\r\n\r\n");
  398. $pr2->miniPrincipal = $otherActor;
  399. reject(fn () => (new PhotoController())->photo($pr2, (string) $photo['id']), 40301, 'photo ownership enforced');
  400. $device = $business->save('device', [
  401. 'name' => $tag,
  402. 'sn' => $tag,
  403. 'enterprise_id' => $enterprise,
  404. 'sku_id' => $sku,
  405. ], 1)['id'];
  406. check($query->deviceDetail($enterprise, $device)['today_liters'] === null, 'unknown telemetry stays null');
  407. check(count($query->deviceDetail($enterprise, $device)['trend']) === 7, 'seven day trend');
  408. check(!$driverService->deviceCheck($driver, $task, $tag)['online'], 'device registration does not mean online');
  409. $now = date('Y-m-d H:i:s');
  410. WaterContent::query()->insert([
  411. 'title' => $tag,
  412. 'image_url' => 'https://example.test/a.png',
  413. 'target_url' => 'https://example.test/',
  414. 'audience' => 'purchased',
  415. 'region' => '?????????',
  416. 'sort' => 999999,
  417. 'status' => 1,
  418. 'created_at' => $now,
  419. 'updated_at' => $now,
  420. ]);
  421. $home = $query->home($enterprise);
  422. check(in_array($tag, array_column($home['banners'], 'title'), true), 'home derives actual paid audience');
  423. check($home['unread_messages'] === null, 'no fake unread message count');
  424. check($driverService->dashboard($driver)['summary']['completed_today'] === 1, 'driver dashboard completion');
  425. check(count($driverService->routes($driver)) === 1, 'assigned routes returned');
  426. $agent = $business->save('agent', [
  427. 'name' => $tag,
  428. 'contact' => '??????',
  429. 'phone' => $tag,
  430. 'region' => '?????????',
  431. 'address' => '??????',
  432. ], 1)['id'];
  433. WaterEnterprise::query()->where('id', $enterprise)->update(['agent_id' => $agent]);
  434. $manager->grant(1, [
  435. 'user_id' => (int) $actor->userId,
  436. 'role' => 'agent',
  437. 'resource_id' => $agent,
  438. ]);
  439. $aq = new AgentQuery($access);
  440. check($aq->customers($agent, [])['total'] === 1, 'agent customers scoped');
  441. check($aq->orders($agent, [])['total'] === 3, 'paid checkout receipts enter agent turnover');
  442. check($aq->dashboard($agent)['summary']['month_amount_cents'] === 209700, 'agent real receipt turnover');
  443. check($aq->commissions($agent)['withdrawable_cents'] === 0 && $aq->commissions($agent)['pending_cents'] === 0, 'unsettled commission account starts at zero');
  444. $customer = $aq->customers($agent, [])['items'][0];
  445. check($customer['amount_cents'] === 209700 && $customer['has_purchased'] && $customer['device_count'] === 1, 'customer aggregates preserve paid and device totals');
  446. check($customer['last_purchase_at'] !== null, 'customer last receipt timestamp');
  447. WaterEnterprise::query()->where('id', $enterprise2)->update(['agent_id' => $agent, 'phone' => '+86 138 1234 5678']);
  448. $futureReceipt = WaterOrder::query()->where('enterprise_id', $enterprise)->first()->replicate();
  449. $futureReceipt->fill([
  450. 'enterprise_id' => $enterprise2,
  451. 'reference' => $tag . '-future',
  452. 'created_at' => date('Y-m-d H:i:s', time() + 86400),
  453. ])->save();
  454. $db->enableQueryLog();
  455. try {
  456. $db->flushQueryLog();
  457. $aq->customers($agent, ['page_size' => 1]);
  458. $singleCount = count($db->getQueryLog());
  459. $db->flushQueryLog();
  460. $customers = $aq->customers($agent, ['page_size' => 100]);
  461. check(count($db->getQueryLog()) === $singleCount, 'customer query count does not grow per row');
  462. check(count($customers['items']) === 2, 'query-count regression covers multiple customers');
  463. } finally {
  464. $db->disableQueryLog();
  465. $db->flushQueryLog();
  466. }
  467. $unpaid = $customers['items'][0];
  468. check($unpaid['id'] === $enterprise2 && $unpaid['amount_cents'] === 0 && !$unpaid['has_purchased'], 'future receipt excluded from customer purchase totals');
  469. check($unpaid['last_purchase_at'] === null && $unpaid['device_count'] === 0, 'empty aggregates return null timestamp and zero count');
  470. check($unpaid['phone'] === '+861******5678', 'agent list masks legacy international phone');
  471. check($aq->customers($agent, ['filter' => 'unpaid'])['total'] === 1, 'unpaid filter agrees with aggregates');
  472. foreach ([
  473. 'Login' => $session,
  474. 'Config' => (new \app\miniapi\service\SystemService())->configuration(),
  475. 'Health' => (new \app\miniapi\service\SystemService())->status(),
  476. 'Profile' => $query->profile($enterprise),
  477. 'Addresses' => $query->addresses(),
  478. 'EditableAddress' => $query->addressDetail($enterprise),
  479. 'Home' => $home,
  480. 'Package' => $catalog,
  481. 'PackagePage' => (new Catalog())->listing(['keyword' => $tag]),
  482. 'Rights' => $rights,
  483. 'LedgerPage' => $query->ledger($enterprise, []),
  484. 'EnterpriseOrderPage' => $query->orders($enterprise, []),
  485. 'Tracking' => $query->tracking($enterprise, $task),
  486. 'DevicePage' => $query->devices($enterprise, []),
  487. 'DeviceDetail' => $query->deviceDetail($enterprise, $device),
  488. 'Rewards' => $query->rewards($enterprise),
  489. 'Quote' => $quote,
  490. 'Checkout' => $order,
  491. 'CheckoutDetail' => $checkout->detail($enterprise, $order['id']),
  492. 'AddressResult' => $changed,
  493. 'DriverDashboard' => $driverService->dashboard($driver),
  494. 'TaskPage' => $driverService->tasks($driver, []),
  495. 'TaskDetail' => $driverService->detail($driver, $task),
  496. 'Route' => $driverService->routes($driver)[0],
  497. 'DeviceCheck' => $driverService->deviceCheck($driver, $task, $tag),
  498. 'PhotoResult' => $photo,
  499. 'AgentDashboard' => $aq->dashboard($agent),
  500. 'CustomerPage' => $customers,
  501. 'AgentOrderPage' => $aq->orders($agent, []),
  502. 'Commissions' => $aq->commissions($agent),
  503. ] as $schema => $result) {
  504. check($contract->data($schema, $result), 'actual response matches OpenAPI: ' . $schema);
  505. }
  506. foreach (['purchases', 'checkouts'] as $kind) {
  507. check($contract->data('EnterpriseOrderPage', $query->orders($enterprise, ['kind' => $kind])), 'order variant matches OpenAPI: ' . $kind);
  508. }
  509. reject(fn () => (new AgentQuery(new Access($otherActor)))->dashboard($agent), 40301, 'agent identity enforced');
  510. $manager->grant(1, [
  511. 'user_id' => (int) $actor->userId,
  512. 'role' => 'enterprise_admin',
  513. 'resource_id' => $enterprise,
  514. 'status' => 0,
  515. ]);
  516. reject(fn () => $query->rights($enterprise), 40301, 'revocation effective immediately');
  517. reject(fn () => $checkout->create($enterprise, $create), 40301, 'revoked user cannot replay old checkout result');
  518. $auth->logout($session['access_token']);
  519. reject(fn () => (new SessionVerifier())->verify($session['access_token']), 40101, 'logout invalidates token');
  520. MiniSession::query()->where('user_id', $otherActor->userId)->update(['expires_at' => date('Y-m-d H:i:s', time() - 1)]);
  521. reject(fn () => (new SessionVerifier())->verify($other['access_token']), 40101, 'expired token rejected');
  522. check(MiniAudit::query()->where('actor_type', 'mini')->where('actor_id', $actor->userId)->count() > 0, 'mini actor audit separate from admin');
  523. check(WaterDelivery::query()->find($task)->created_by === 0, 'mini IDs not impersonating admin IDs');
  524. } finally {
  525. $db->rollBack();
  526. }
  527. foreach ($models as $m) {
  528. check($m::query()->count() === $before[$m], $m . ' fixtures rolled back');
  529. }
  530. echo "PASS: $checks mini water API checks; no persistent business changes.\n";