|
@@ -0,0 +1,245 @@
|
|
|
|
|
+<?php
|
|
|
|
|
+
|
|
|
|
|
+namespace Tests\Integration;
|
|
|
|
|
+
|
|
|
|
|
+use App\Http\Controllers\api\Rebate;
|
|
|
|
|
+use App\Http\Middleware\CheckToken;
|
|
|
|
|
+use Carbon\Carbon;
|
|
|
|
|
+use Illuminate\Config\Repository;
|
|
|
|
|
+use Illuminate\Database\Capsule\Manager;
|
|
|
|
|
+use Illuminate\Database\Schema\Blueprint;
|
|
|
|
|
+use Illuminate\Events\Dispatcher;
|
|
|
|
|
+use Illuminate\Foundation\Application;
|
|
|
|
|
+use Illuminate\Http\JsonResponse;
|
|
|
|
|
+use Illuminate\Http\Request;
|
|
|
|
|
+use Illuminate\Routing\Router;
|
|
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
|
|
+use Illuminate\Support\Facades\Facade;
|
|
|
|
|
+use Illuminate\Translation\ArrayLoader;
|
|
|
|
|
+use Illuminate\Translation\Translator;
|
|
|
|
|
+use Illuminate\Validation\Factory;
|
|
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
|
|
+
|
|
|
|
|
+/** Real API routes, member-token middleware and queries, using SQLite without loading .env. */
|
|
|
|
|
+class MemberRebateApiTest extends TestCase
|
|
|
|
|
+{
|
|
|
|
|
+ private Application $app;
|
|
|
|
|
+ private Router $router;
|
|
|
|
|
+
|
|
|
|
|
+ protected function setUp(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ parent::setUp();
|
|
|
|
|
+ Carbon::setTestNow(Carbon::parse('2026-10-25 00:30:00', 'Asia/Shanghai'));
|
|
|
|
|
+ Facade::clearResolvedInstances();
|
|
|
|
|
+ $this->app = new Application(dirname(__DIR__, 2));
|
|
|
|
|
+ $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
|
|
|
|
|
+ Facade::setFacadeApplication($this->app);
|
|
|
|
|
+ $db = new Manager($this->app);
|
|
|
|
|
+ $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
|
|
|
|
|
+ $db->setAsGlobal();
|
|
|
|
|
+ $db->bootEloquent();
|
|
|
|
|
+ $this->app->instance('db', $db->getDatabaseManager());
|
|
|
|
|
+ $this->app->bind('db.schema', fn () => $db->schema());
|
|
|
|
|
+ $translator = new Translator(new ArrayLoader(), 'zh');
|
|
|
|
|
+ $this->app->instance('translator', $translator);
|
|
|
|
|
+ $this->app->instance('validator', new Factory($translator, $this->app));
|
|
|
|
|
+ $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
|
|
|
|
|
+ public function json($data, $status = 200, $headers = [], $options = 0) {
|
|
|
|
|
+ return new JsonResponse($data, $status, $headers, $options);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ Request::macro('validate', function (array $rules) {
|
|
|
|
|
+ return app('validator')->make($this->all(), $rules)->validate();
|
|
|
|
|
+ });
|
|
|
|
|
+ if (!function_exists('lang')) require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
|
|
|
|
|
+ $db->schema()->create('users', function (Blueprint $t) {
|
|
|
|
|
+ $t->id(); $t->string('user_id'); $t->string('member_id')->nullable();
|
|
|
|
|
+ });
|
|
|
|
|
+ $db->schema()->create('user_session', function (Blueprint $t) {
|
|
|
|
|
+ $t->id(); $t->string('user_id'); $t->string('token');
|
|
|
|
|
+ $t->bigInteger('expire_time'); $t->bigInteger('create_time'); $t->bigInteger('update_time');
|
|
|
|
|
+ });
|
|
|
|
|
+ foreach ([
|
|
|
|
|
+ '2025_10_31_145445_create_rebates.php',
|
|
|
|
|
+ '2025_10_31_155143_update_rebates.php',
|
|
|
|
|
+ '2025_11_03_144118_update_rebates.php',
|
|
|
|
|
+ '2025_11_20_145643_update_rebates.php',
|
|
|
|
|
+ '2025_11_20_151650_update_rebates.php',
|
|
|
|
|
+ '2026_01_13_153629_update_rebates.php',
|
|
|
|
|
+ '2026_01_28_093600_update_rebate.php',
|
|
|
|
|
+ ] as $migration) {
|
|
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
|
|
|
|
|
+ }
|
|
|
|
|
+ foreach (['one' => '100001', 'two' => '200002', 'unlinked' => null] as $suffix => $memberId) {
|
|
|
|
|
+ DB::table('users')->insert(['user_id' => 'login-' . $suffix, 'member_id' => $memberId]);
|
|
|
|
|
+ DB::table('user_session')->insert([
|
|
|
|
|
+ 'user_id' => 'login-' . $suffix, 'token' => 'token-' . $suffix,
|
|
|
|
|
+ 'expire_time' => now()->timestamp + 86400,
|
|
|
|
|
+ 'create_time' => now()->timestamp, 'update_time' => now()->timestamp,
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+ $this->router = new Router(new Dispatcher($this->app), $this->app);
|
|
|
|
|
+ $this->app->instance('router', $this->router);
|
|
|
|
|
+ $this->router->aliasMiddleware('check.token', CheckToken::class);
|
|
|
|
|
+ $this->router->group(['prefix' => 'api'], function () {
|
|
|
|
|
+ require dirname(__DIR__, 2) . '/routes/api.php';
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ protected function tearDown(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ Carbon::setTestNow();
|
|
|
|
|
+ DB::disconnect();
|
|
|
|
|
+ Facade::clearResolvedInstances();
|
|
|
|
|
+ parent::tearDown();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function request(array $params = [], ?string $token = 'token-one'): JsonResponse
|
|
|
|
|
+ {
|
|
|
|
|
+ $request = Request::create('/api/rebate', 'GET', $params);
|
|
|
|
|
+ if ($token !== null) $request->headers->set('Authorization', $token);
|
|
|
|
|
+ $this->app->instance('request', $request);
|
|
|
|
|
+ return $this->router->dispatch($request);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function rebate(array $overrides = []): int
|
|
|
|
|
+ {
|
|
|
|
|
+ return DB::table('rebates')->insertGetId($overrides + [
|
|
|
|
|
+ 'member_id' => '100001', 'date' => '2026-10-23',
|
|
|
|
|
+ 'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00',
|
|
|
|
|
+ 'profit' => '-100.00', 'rebate_ratio' => '0.0050', 'amount' => '0.00', 'status' => 0,
|
|
|
|
|
+ 'audited_by' => 'internal-admin', 'username' => 'historical-account', 'first_name' => '历史昵称',
|
|
|
|
|
+ ]);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private function listing(array $params = [], string $token = 'token-one'): array
|
|
|
|
|
+ {
|
|
|
|
|
+ $response = $this->request($params, $token);
|
|
|
|
|
+ $this->assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ $body = $response->getData(true);
|
|
|
|
|
+ $this->assertSame(0, $body['code'], $body['msg']);
|
|
|
|
|
+ return $body['data'];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_route_requires_member_login_and_rejects_missing_invalid_expired_and_unlinked_sessions(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->rebate();
|
|
|
|
|
+ $route = $this->router->getRoutes()->match(Request::create('/api/rebate', 'GET'));
|
|
|
|
|
+ $this->assertSame(Rebate::class . '@index', $route->getAction('uses'));
|
|
|
|
|
+ $this->assertContains('check.token', $route->middleware());
|
|
|
|
|
+ $this->assertSame(['GET', 'HEAD'], $route->methods());
|
|
|
|
|
+ foreach ([null, 'invalid', 'Bearer token-one', 'token-unlinked'] as $token) {
|
|
|
|
|
+ $response = $this->request([], $token);
|
|
|
|
|
+ $this->assertSame(401, $response->getStatusCode());
|
|
|
|
|
+ $this->assertSame(401, $response->getData(true)['code']);
|
|
|
|
|
+ }
|
|
|
|
|
+ DB::table('user_session')->where('token', 'token-one')->update(['expire_time' => now()->timestamp]);
|
|
|
|
|
+ $this->assertSame(401, $this->request()->getStatusCode());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_user_cannot_select_another_members_rows_or_total_by_overriding_identity_parameters(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $own = $this->rebate();
|
|
|
|
|
+ $other = $this->rebate(['member_id' => '200002', 'betting_amount' => 9000]);
|
|
|
|
|
+ $before = DB::table('rebates')->orderBy('id')->get()->toJson();
|
|
|
|
|
+ $list = $this->listing([
|
|
|
|
|
+ 'member_id' => '200002', 'user_id' => 'login-two', 'id' => $other,
|
|
|
|
|
+ 'username' => 'another-account', 'first_name' => '其他会员',
|
|
|
|
|
+ 'user' => ['member_id' => '200002'],
|
|
|
|
|
+ ]);
|
|
|
|
|
+ $this->assertSame(1, $list['total']);
|
|
|
|
|
+ $this->assertSame([$own], array_column($list['data'], 'id'));
|
|
|
|
|
+ $this->assertSame(['100001'], array_column($list['data'], 'member_id'));
|
|
|
|
|
+ $otherList = $this->listing([], 'token-two');
|
|
|
|
|
+ $this->assertSame(1, $otherList['total']);
|
|
|
|
|
+ $this->assertSame([$other], array_column($otherList['data'], 'id'));
|
|
|
|
|
+ $this->assertSame($before, DB::table('rebates')->orderBy('id')->get()->toJson());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_date_cutoff_uses_new_york_midnight_and_never_includes_today_or_future_rows(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $yesterday = $this->rebate();
|
|
|
|
|
+ $today = $this->rebate(['date' => '2026-10-24']);
|
|
|
|
|
+ $this->rebate(['date' => '2026-10-25']);
|
|
|
|
|
+ $list = $this->listing();
|
|
|
|
|
+ $this->assertSame(1, $list['total']);
|
|
|
|
|
+ $this->assertSame([$yesterday], array_column($list['data'], 'id'));
|
|
|
|
|
+ $this->assertSame(['total' => 0, 'data' => []], $this->listing(['date' => '2026-10-24']));
|
|
|
|
|
+ Carbon::setTestNow(Carbon::parse('2026-10-25 00:00:00', 'America/New_York'));
|
|
|
|
|
+ $list = $this->listing();
|
|
|
|
|
+ $this->assertSame(2, $list['total']);
|
|
|
|
|
+ $this->assertSame([$today, $yesterday], array_column($list['data'], 'id'));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_filters_sorting_pagination_and_empty_results_use_the_same_member_scope(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $paid = $this->rebate(['status' => 1, 'betting_amount' => 9000]);
|
|
|
|
|
+ $small = $this->rebate(['betting_amount' => 100]);
|
|
|
|
|
+ $large = $this->rebate(['betting_amount' => 200]);
|
|
|
|
|
+ $tied = $this->rebate(['betting_amount' => 200]);
|
|
|
|
|
+ $older = $this->rebate(['date' => '2026-10-22', 'betting_amount' => 10000]);
|
|
|
|
|
+ $this->rebate(['member_id' => '200002', 'status' => 1]);
|
|
|
|
|
+ $all = $this->listing();
|
|
|
|
|
+ $this->assertSame(5, $all['total']);
|
|
|
|
|
+ $this->assertSame([$tied, $large, $small, $paid, $older], array_column($all['data'], 'id'));
|
|
|
|
|
+ $unpaid = $this->listing(['status' => '0', 'date' => '2026-10-23']);
|
|
|
|
|
+ $this->assertSame(3, $unpaid['total']);
|
|
|
|
|
+ $this->assertSame([$tied, $large, $small], array_column($unpaid['data'], 'id'));
|
|
|
|
|
+ $page = $this->listing(['status' => 0, 'limit' => 2, 'page' => 2]);
|
|
|
|
|
+ $this->assertSame(4, $page['total']);
|
|
|
|
|
+ $this->assertSame([$small, $older], array_column($page['data'], 'id'));
|
|
|
|
|
+ $paidList = $this->listing(['status' => '1']);
|
|
|
|
|
+ $this->assertSame(1, $paidList['total']);
|
|
|
|
|
+ $this->assertSame([$paid], array_column($paidList['data'], 'id'));
|
|
|
|
|
+ $this->assertSame(['total' => 5, 'data' => []], $this->listing(['page' => 99]));
|
|
|
|
|
+ $this->assertSame(['total' => 0, 'data' => []], $this->listing(['date' => '2020-01-01']));
|
|
|
|
|
+ $this->assertSame(5, $this->listing(['date' => null, 'status' => null, 'limit' => null, 'page' => null])['total']);
|
|
|
|
|
+ $this->assertSame(5, $this->listing(['date' => '', 'status' => ''])['total']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public function test_list_returns_display_fields_and_saved_money_without_serializing_admin_or_dynamic_backflow_fields(): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $this->rebate(['rebate_ratio' => null]);
|
|
|
|
|
+ $grantTime = Carbon::parse('2026-10-24 10:00:00', 'Asia/Shanghai')->timestamp;
|
|
|
|
|
+ $paid = $this->rebate(['status' => 1, 'amount' => '4.00', 'grant_time' => $grantTime]);
|
|
|
|
|
+ $before = DB::table('rebates')->get()->toJson();
|
|
|
|
|
+ $rows = collect($this->listing()['data'])->keyBy('id');
|
|
|
|
|
+ $row = $rows[$paid];
|
|
|
|
|
+ $this->assertSame([
|
|
|
|
|
+ 'id', 'date', 'member_id', 'betting_amount', 'effective_betting_amount',
|
|
|
|
|
+ 'profit', 'rebate_ratio', 'amount', 'status', 'grant_time',
|
|
|
|
|
+ ], array_keys($row));
|
|
|
|
|
+ $this->assertEquals(800, $row['effective_betting_amount']);
|
|
|
|
|
+ $this->assertEquals(-100, $row['profit']);
|
|
|
|
|
+ $this->assertEquals(0.005, $row['rebate_ratio']);
|
|
|
|
|
+ $this->assertEquals(4, $row['amount']);
|
|
|
|
|
+ $this->assertSame(date('Y-m-d H:i', $grantTime), $row['grant_time']);
|
|
|
|
|
+ $this->assertSame('', $rows->first()['grant_time']);
|
|
|
|
|
+ $this->assertNull($rows->first()['rebate_ratio']);
|
|
|
|
|
+ $this->assertSame($before, DB::table('rebates')->get()->toJson());
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** @dataProvider invalidFilters */
|
|
|
|
|
+ public function test_invalid_filters_return_a_frontend_validation_error(array $params): void
|
|
|
|
|
+ {
|
|
|
|
|
+ $response = $this->request($params);
|
|
|
|
|
+ $this->assertSame(200, $response->getStatusCode());
|
|
|
|
|
+ $body = $response->getData(true);
|
|
|
|
|
+ $this->assertSame(-1, $body['code']);
|
|
|
|
|
+ $this->assertSame([], $body['data']);
|
|
|
|
|
+ $this->assertNotEmpty($body['msg']);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ public static function invalidFilters(): array
|
|
|
|
|
+ {
|
|
|
|
|
+ return [
|
|
|
|
|
+ 'page zero' => [['page' => 0]],
|
|
|
|
|
+ 'invalid page' => [['page' => 'abc']],
|
|
|
|
|
+ 'limit zero' => [['limit' => 0]],
|
|
|
|
|
+ 'oversized limit' => [['limit' => 101]],
|
|
|
|
|
+ 'invalid date' => [['date' => '2026-02-30']],
|
|
|
|
|
+ 'invalid status' => [['status' => 2]],
|
|
|
|
|
+ 'array status' => [['status' => [0, 1]]],
|
|
|
|
|
+ ];
|
|
|
|
|
+ }
|
|
|
|
|
+}
|