MemberRebateApiTest.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\api\Rebate;
  4. use App\Http\Middleware\CheckToken;
  5. use Carbon\Carbon;
  6. use Illuminate\Config\Repository;
  7. use Illuminate\Database\Capsule\Manager;
  8. use Illuminate\Database\Schema\Blueprint;
  9. use Illuminate\Events\Dispatcher;
  10. use Illuminate\Foundation\Application;
  11. use Illuminate\Http\JsonResponse;
  12. use Illuminate\Http\Request;
  13. use Illuminate\Routing\Router;
  14. use Illuminate\Support\Facades\DB;
  15. use Illuminate\Support\Facades\Facade;
  16. use Illuminate\Translation\ArrayLoader;
  17. use Illuminate\Translation\Translator;
  18. use Illuminate\Validation\Factory;
  19. use PHPUnit\Framework\TestCase;
  20. /** Real API routes, member-token middleware and queries, using SQLite without loading .env. */
  21. class MemberRebateApiTest extends TestCase
  22. {
  23. private Application $app;
  24. private Router $router;
  25. protected function setUp(): void
  26. {
  27. parent::setUp();
  28. Carbon::setTestNow(Carbon::parse('2026-10-25 00:30:00', 'Asia/Shanghai'));
  29. Facade::clearResolvedInstances();
  30. $this->app = new Application(dirname(__DIR__, 2));
  31. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  32. Facade::setFacadeApplication($this->app);
  33. $db = new Manager($this->app);
  34. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  35. $db->setAsGlobal();
  36. $db->bootEloquent();
  37. $this->app->instance('db', $db->getDatabaseManager());
  38. $this->app->bind('db.schema', fn () => $db->schema());
  39. $translator = new Translator(new ArrayLoader(), 'zh');
  40. $this->app->instance('translator', $translator);
  41. $this->app->instance('validator', new Factory($translator, $this->app));
  42. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
  43. public function json($data, $status = 200, $headers = [], $options = 0) {
  44. return new JsonResponse($data, $status, $headers, $options);
  45. }
  46. });
  47. Request::macro('validate', function (array $rules) {
  48. return app('validator')->make($this->all(), $rules)->validate();
  49. });
  50. if (!function_exists('lang')) require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
  51. $db->schema()->create('users', function (Blueprint $t) {
  52. $t->id(); $t->string('user_id'); $t->string('member_id')->nullable();
  53. });
  54. $db->schema()->create('user_session', function (Blueprint $t) {
  55. $t->id(); $t->string('user_id'); $t->string('token');
  56. $t->bigInteger('expire_time'); $t->bigInteger('create_time'); $t->bigInteger('update_time');
  57. });
  58. foreach ([
  59. '2025_10_31_145445_create_rebates.php',
  60. '2025_10_31_155143_update_rebates.php',
  61. '2025_11_03_144118_update_rebates.php',
  62. '2025_11_20_145643_update_rebates.php',
  63. '2025_11_20_151650_update_rebates.php',
  64. '2026_01_13_153629_update_rebates.php',
  65. '2026_01_28_093600_update_rebate.php',
  66. ] as $migration) {
  67. (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
  68. }
  69. foreach (['one' => '100001', 'two' => '200002', 'unlinked' => null] as $suffix => $memberId) {
  70. DB::table('users')->insert(['user_id' => 'login-' . $suffix, 'member_id' => $memberId]);
  71. DB::table('user_session')->insert([
  72. 'user_id' => 'login-' . $suffix, 'token' => 'token-' . $suffix,
  73. 'expire_time' => now()->timestamp + 86400,
  74. 'create_time' => now()->timestamp, 'update_time' => now()->timestamp,
  75. ]);
  76. }
  77. $this->router = new Router(new Dispatcher($this->app), $this->app);
  78. $this->app->instance('router', $this->router);
  79. $this->router->aliasMiddleware('check.token', CheckToken::class);
  80. $this->router->group(['prefix' => 'api'], function () {
  81. require dirname(__DIR__, 2) . '/routes/api.php';
  82. });
  83. }
  84. protected function tearDown(): void
  85. {
  86. Carbon::setTestNow();
  87. DB::disconnect();
  88. Facade::clearResolvedInstances();
  89. parent::tearDown();
  90. }
  91. private function request(array $params = [], ?string $token = 'token-one'): JsonResponse
  92. {
  93. $request = Request::create('/api/rebate', 'GET', $params);
  94. if ($token !== null) $request->headers->set('Authorization', $token);
  95. $this->app->instance('request', $request);
  96. return $this->router->dispatch($request);
  97. }
  98. private function rebate(array $overrides = []): int
  99. {
  100. return DB::table('rebates')->insertGetId($overrides + [
  101. 'member_id' => '100001', 'date' => '2026-10-23',
  102. 'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00',
  103. 'profit' => '-100.00', 'rebate_ratio' => '0.0050', 'amount' => '0.00', 'status' => 0,
  104. 'audited_by' => 'internal-admin', 'username' => 'historical-account', 'first_name' => '历史昵称',
  105. ]);
  106. }
  107. private function listing(array $params = [], string $token = 'token-one'): array
  108. {
  109. $response = $this->request($params, $token);
  110. $this->assertSame(200, $response->getStatusCode());
  111. $body = $response->getData(true);
  112. $this->assertSame(0, $body['code'], $body['msg']);
  113. return $body['data'];
  114. }
  115. public function test_route_requires_member_login_and_rejects_missing_invalid_expired_and_unlinked_sessions(): void
  116. {
  117. $this->rebate();
  118. $route = $this->router->getRoutes()->match(Request::create('/api/rebate', 'GET'));
  119. $this->assertSame(Rebate::class . '@index', $route->getAction('uses'));
  120. $this->assertContains('check.token', $route->middleware());
  121. $this->assertSame(['GET', 'HEAD'], $route->methods());
  122. foreach ([null, 'invalid', 'Bearer token-one', 'token-unlinked'] as $token) {
  123. $response = $this->request([], $token);
  124. $this->assertSame(401, $response->getStatusCode());
  125. $this->assertSame(401, $response->getData(true)['code']);
  126. }
  127. DB::table('user_session')->where('token', 'token-one')->update(['expire_time' => now()->timestamp]);
  128. $this->assertSame(401, $this->request()->getStatusCode());
  129. }
  130. public function test_user_cannot_select_another_members_rows_or_total_by_overriding_identity_parameters(): void
  131. {
  132. $own = $this->rebate();
  133. $other = $this->rebate(['member_id' => '200002', 'betting_amount' => 9000]);
  134. $before = DB::table('rebates')->orderBy('id')->get()->toJson();
  135. $list = $this->listing([
  136. 'member_id' => '200002', 'user_id' => 'login-two', 'id' => $other,
  137. 'username' => 'another-account', 'first_name' => '其他会员',
  138. 'user' => ['member_id' => '200002'],
  139. ]);
  140. $this->assertSame(1, $list['total']);
  141. $this->assertSame([$own], array_column($list['data'], 'id'));
  142. $this->assertSame(['100001'], array_column($list['data'], 'member_id'));
  143. $otherList = $this->listing([], 'token-two');
  144. $this->assertSame(1, $otherList['total']);
  145. $this->assertSame([$other], array_column($otherList['data'], 'id'));
  146. $this->assertSame($before, DB::table('rebates')->orderBy('id')->get()->toJson());
  147. }
  148. public function test_date_cutoff_uses_new_york_midnight_and_never_includes_today_or_future_rows(): void
  149. {
  150. $yesterday = $this->rebate();
  151. $today = $this->rebate(['date' => '2026-10-24']);
  152. $this->rebate(['date' => '2026-10-25']);
  153. $list = $this->listing();
  154. $this->assertSame(1, $list['total']);
  155. $this->assertSame([$yesterday], array_column($list['data'], 'id'));
  156. $this->assertSame(['total' => 0, 'data' => []], $this->listing(['date' => '2026-10-24']));
  157. Carbon::setTestNow(Carbon::parse('2026-10-25 00:00:00', 'America/New_York'));
  158. $list = $this->listing();
  159. $this->assertSame(2, $list['total']);
  160. $this->assertSame([$today, $yesterday], array_column($list['data'], 'id'));
  161. }
  162. public function test_filters_sorting_pagination_and_empty_results_use_the_same_member_scope(): void
  163. {
  164. $paid = $this->rebate(['status' => 1, 'betting_amount' => 9000]);
  165. $small = $this->rebate(['betting_amount' => 100]);
  166. $large = $this->rebate(['betting_amount' => 200]);
  167. $tied = $this->rebate(['betting_amount' => 200]);
  168. $older = $this->rebate(['date' => '2026-10-22', 'betting_amount' => 10000]);
  169. $this->rebate(['member_id' => '200002', 'status' => 1]);
  170. $all = $this->listing();
  171. $this->assertSame(5, $all['total']);
  172. $this->assertSame([$tied, $large, $small, $paid, $older], array_column($all['data'], 'id'));
  173. $unpaid = $this->listing(['status' => '0', 'date' => '2026-10-23']);
  174. $this->assertSame(3, $unpaid['total']);
  175. $this->assertSame([$tied, $large, $small], array_column($unpaid['data'], 'id'));
  176. $page = $this->listing(['status' => 0, 'limit' => 2, 'page' => 2]);
  177. $this->assertSame(4, $page['total']);
  178. $this->assertSame([$small, $older], array_column($page['data'], 'id'));
  179. $paidList = $this->listing(['status' => '1']);
  180. $this->assertSame(1, $paidList['total']);
  181. $this->assertSame([$paid], array_column($paidList['data'], 'id'));
  182. $this->assertSame(['total' => 5, 'data' => []], $this->listing(['page' => 99]));
  183. $this->assertSame(['total' => 0, 'data' => []], $this->listing(['date' => '2020-01-01']));
  184. $this->assertSame(5, $this->listing(['date' => null, 'status' => null, 'limit' => null, 'page' => null])['total']);
  185. $this->assertSame(5, $this->listing(['date' => '', 'status' => ''])['total']);
  186. }
  187. public function test_list_returns_display_fields_and_saved_money_without_serializing_admin_or_dynamic_backflow_fields(): void
  188. {
  189. $this->rebate(['rebate_ratio' => null]);
  190. $grantTime = Carbon::parse('2026-10-24 10:00:00', 'Asia/Shanghai')->timestamp;
  191. $paid = $this->rebate(['status' => 1, 'amount' => '4.00', 'grant_time' => $grantTime]);
  192. $before = DB::table('rebates')->get()->toJson();
  193. $rows = collect($this->listing()['data'])->keyBy('id');
  194. $row = $rows[$paid];
  195. $this->assertSame([
  196. 'id', 'date', 'member_id', 'betting_amount', 'effective_betting_amount',
  197. 'profit', 'rebate_ratio', 'amount', 'status', 'grant_time',
  198. ], array_keys($row));
  199. $this->assertEquals(800, $row['effective_betting_amount']);
  200. $this->assertEquals(-100, $row['profit']);
  201. $this->assertEquals(0.005, $row['rebate_ratio']);
  202. $this->assertEquals(4, $row['amount']);
  203. $this->assertSame(date('Y-m-d H:i', $grantTime), $row['grant_time']);
  204. $this->assertSame('', $rows->first()['grant_time']);
  205. $this->assertNull($rows->first()['rebate_ratio']);
  206. $this->assertSame($before, DB::table('rebates')->get()->toJson());
  207. }
  208. /** @dataProvider invalidFilters */
  209. public function test_invalid_filters_return_a_frontend_validation_error(array $params): void
  210. {
  211. $response = $this->request($params);
  212. $this->assertSame(200, $response->getStatusCode());
  213. $body = $response->getData(true);
  214. $this->assertSame(-1, $body['code']);
  215. $this->assertSame([], $body['data']);
  216. $this->assertNotEmpty($body['msg']);
  217. }
  218. public static function invalidFilters(): array
  219. {
  220. return [
  221. 'page zero' => [['page' => 0]],
  222. 'invalid page' => [['page' => 'abc']],
  223. 'limit zero' => [['limit' => 0]],
  224. 'oversized limit' => [['limit' => 101]],
  225. 'invalid date' => [['date' => '2026-02-30']],
  226. 'invalid status' => [['status' => 2]],
  227. 'array status' => [['status' => [0, 1]]],
  228. ];
  229. }
  230. }