app = new Application(dirname(__DIR__, 2)); $this->app->instance('config', new Repository(['app' => ['locale' => 'zh']])); $this->app->instance('log', new \Psr\Log\NullLogger()); $translator = new \Illuminate\Translation\Translator(new \Illuminate\Translation\ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new \Illuminate\Validation\Factory($translator, $this->app)); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory()); 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()); foreach ([ 'users' => 'id INTEGER PRIMARY KEY,account TEXT', 'promotion_activities' => 'id INTEGER PRIMARY KEY,type INTEGER', 'promotion_accounts' => 'id INTEGER PRIMARY KEY,activity_id INTEGER,user_id INTEGER,round_no INTEGER,spins INTEGER,applied_at TEXT,application_snapshot TEXT,created_at TEXT,updated_at TEXT', 'promotion_rewards' => 'id INTEGER PRIMARY KEY,activity_id INTEGER,user_id INTEGER,account_id INTEGER,round_no INTEGER,amount TEXT,required_flow TEXT,status TEXT,credited_log_id INTEGER,rule_snapshot TEXT', 'promotion_coupons' => 'id INTEGER PRIMARY KEY,reward_id INTEGER,activity_id INTEGER,user_id INTEGER,member_id TEXT,role TEXT,inviter_user_id INTEGER,min_amount TEXT,discount TEXT,categories TEXT,status TEXT,valid_from TEXT,valid_until TEXT', ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')'); foreach ([164, 165, 168, 169, 170, 171] as $id) DB::table('users')->insert(['id' => $id, 'account' => 'member' . $id]); DB::table('promotion_activities')->insert(['id' => 1, 'type' => 7]); DB::table('promotion_accounts')->insert(['id' => 11865, 'activity_id' => 1, 'user_id' => 164, 'round_no' => 1, 'spins' => 0, 'applied_at' => '2026-10-29 11:52:42', 'application_snapshot' => json_encode(['rules' => ['reward_amount' => '500']])]); DB::table('promotion_rewards')->insert(['id' => 3, 'activity_id' => 1, 'user_id' => 164, 'account_id' => 11865, 'round_no' => 1, 'amount' => '500.00', 'required_flow' => '0.00', 'status' => 'paid', 'credited_log_id' => 20800, 'rule_snapshot' => json_encode(['invite_count' => 3, 'reward_amount' => '500', 'qualified_invitees' => [168, 169, 170]])]); } protected function tearDown(): void { DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); } private function records(string $kind, array $filters = []): array { $this->app->instance('request', Request::create('/admin/promotion/records', 'GET', $filters + ['activity_id' => 1, 'kind' => $kind])); $body = (new Promotion())->records(new PromotionAdminService())->getData(true); $this->assertSame(0, $body['code'], $body['msg']); return $body['data']; } private function coupon(int $id, int $user, string $role, string $min = '100', string $discount = '50', int $reward = 3, string $status = 'unused'): void { DB::table('promotion_coupons')->insert(['id' => $id, 'reward_id' => $reward, 'activity_id' => 1, 'user_id' => $user, 'member_id' => 'wallet-' . $user, 'role' => $role, 'inviter_user_id' => 164, 'min_amount' => $min, 'discount' => $discount, 'categories' => '["self:sport"]', 'status' => $status, 'valid_from' => '2000-01-01 00:00:00', 'valid_until' => '2001-01-01 00:00:00']); } public function test_legacy_cash_reward_exposes_the_three_qualified_members_without_inventing_coupon_awards(): void { DB::table('users')->where('id', 168)->update(['account' => '000168']); $before = DB::table('promotion_rewards')->first(); $result = $this->records('rewards', ['user_id' => 164]); $row = $result['data'][0]; $this->assertSame(1, $result['total']); $this->assertSame(3, $row['id']); $this->assertSame('cash', $row['reward_mode']); $this->assertSame(['id' => 164, 'account' => 'member164', 'coupons' => []], $row['inviter']); $this->assertSame([168, 169, 170], array_column($row['invitees'], 'id')); $this->assertSame(['000168', 'member169', 'member170'], array_column($row['invitees'], 'account')); $this->assertSame([[], [], []], array_column($row['invitees'], 'coupons')); $this->assertSame('500', $row['amount']); $this->assertSame((array)$before, (array)DB::table('promotion_rewards')->first()); $this->assertSame(0, DB::table('promotion_coupons')->count()); } public function test_coupon_reward_groups_actual_inviter_and_invitee_awards_including_used_and_expired_coupons(): void { DB::table('promotion_rewards')->where('id', 3)->update(['credited_log_id' => null, 'rule_snapshot' => json_encode([ 'reward_mode' => 'coupon', 'qualified_invitees' => [168, 169, 170, 171], 'coupon_invitees' => [168, 169, 170], ])]); $this->coupon(1, 164, 'inviter'); $this->coupon(2, 164, 'inviter', status: 'used'); $this->coupon(3, 164, 'inviter', '200', '100'); foreach ([168, 169, 170] as $id) $this->coupon($id, $id, 'invitee', '100', '100'); $this->coupon(999, 168, 'invitee', '999', '999', reward: 999); // Another reward must not bleed into this row. DB::table('users')->where('id', 169)->delete(); DB::table('users')->where('id', 170)->update(['account' => '']); $before = DB::table('promotion_coupons')->orderBy('id')->get()->toJson(); foreach (['rewards', 'accounts'] as $kind) { $row = $this->records($kind)['data'][0]; $this->assertSame('coupon', $row['reward_mode']); $this->assertSame([['min_amount' => '100', 'discount' => '50', 'quantity' => 2], ['min_amount' => '200', 'discount' => '100', 'quantity' => 1]], $row['inviter']['coupons']); $this->assertSame([168, 169, 170], array_column($row['invitees'], 'id')); $this->assertSame(['member168', null, null], array_column($row['invitees'], 'account')); foreach ($row['invitees'] as $person) $this->assertSame([['min_amount' => '100', 'discount' => '100', 'quantity' => 1]], $person['coupons']); if ($kind === 'accounts') { $this->assertSame(3, $row['reward_id']); $this->assertSame('paid', $row['reward_status']); } } $this->assertSame($before, DB::table('promotion_coupons')->orderBy('id')->get()->toJson()); } public function test_application_without_reward_has_no_award_list_and_uses_its_application_mode(): void { DB::table('promotion_accounts')->insert(['id' => 11866, 'activity_id' => 1, 'user_id' => 165, 'application_snapshot' => '{"rules":{"reward_mode":"coupon"}}']); $row = $this->records('accounts', ['user_id' => 165])['data'][0]; $this->assertNull($row['reward_id']); $this->assertNull($row['reward_status']); $this->assertSame('coupon', $row['reward_mode']); $this->assertSame(['id' => 165, 'account' => 'member165', 'coupons' => []], $row['inviter']); $this->assertSame([], $row['invitees']); } public function test_pagination_still_counts_rewards_not_nested_people_and_does_not_mix_reward_snapshots(): void { DB::table('promotion_rewards')->insert(['id' => 4, 'activity_id' => 1, 'user_id' => 164, 'account_id' => 11865, 'round_no' => 2, 'amount' => '5', 'status' => 'paid', 'rule_snapshot' => '{"qualified_invitees":[171]}' ]); $first = $this->records('rewards', ['limit' => 1, 'page' => 1]); $second = $this->records('rewards', ['limit' => 1, 'page' => 2]); $this->assertSame(2, $first['total']); $this->assertSame(2, $second['total']); $this->assertSame([171], array_column($first['data'][0]['invitees'], 'id')); $this->assertSame([168, 169, 170], array_column($second['data'][0]['invitees'], 'id')); } public function test_cash_history_does_not_require_coupon_tables_and_bad_snapshot_does_not_break_the_list(): void { DB::statement('DROP TABLE bot_promotion_coupons'); $this->assertCount(3, $this->records('rewards')['data'][0]['invitees']); DB::table('promotion_rewards')->update(['rule_snapshot' => 'invalid historical snapshot']); $row = $this->records('rewards')['data'][0]; $this->assertSame('cash', $row['reward_mode']); $this->assertSame([], $row['invitees']); } public function test_coupon_record_list_also_contains_holder_and_inviter_accounts(): void { $this->coupon(1, 168, 'invitee'); $row = $this->records('coupons', ['user_id' => 164])['data'][0]; $this->assertSame(168, $row['user_id']); $this->assertSame('member168', $row['account']); $this->assertSame(164, $row['inviter_user_id']); $this->assertSame('member164', $row['inviter_account']); } public function test_unrelated_activity_types_keep_the_existing_record_shape(): void { DB::table('promotion_activities')->update(['type' => 9]); $row = $this->records('rewards')['data'][0]; $this->assertArrayNotHasKey('inviter', $row); $this->assertArrayNotHasKey('invitees', $row); } }