|
|
@@ -0,0 +1,150 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace Tests\Integration;
|
|
|
+
|
|
|
+use App\Http\Controllers\admin\JisuGameOrder;
|
|
|
+use App\Http\Controllers\admin\LhcOrder;
|
|
|
+use App\Http\Controllers\admin\Order;
|
|
|
+use App\Http\Controllers\admin\SportGameOrder;
|
|
|
+use Illuminate\Config\Repository;
|
|
|
+use Illuminate\Database\Capsule\Manager;
|
|
|
+use Illuminate\Database\Eloquent\Model;
|
|
|
+use Illuminate\Database\Schema\Blueprint;
|
|
|
+use Illuminate\Foundation\Application;
|
|
|
+use Illuminate\Http\Request;
|
|
|
+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\Attributes\DataProvider;
|
|
|
+use PHPUnit\Framework\TestCase;
|
|
|
+
|
|
|
+class AdminOrderRefundTest extends TestCase
|
|
|
+{
|
|
|
+ private Application $app;
|
|
|
+ private const CONTROLLERS = ['sport' => Order::class, 'game' => SportGameOrder::class, 'lottery' => LhcOrder::class, 'jisu' => JisuGameOrder::class];
|
|
|
+ private const TABLES = ['sport' => 'sport_order', 'game' => 'sport_game_order', 'lottery' => 'lhc_order', 'jisu' => 'jisu_game_order'];
|
|
|
+
|
|
|
+ protected function setUp(): void
|
|
|
+ {
|
|
|
+ Facade::clearResolvedInstances(); Model::clearBootedModels(); Model::unsetEventDispatcher();
|
|
|
+ $this->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 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 \Tests\Support\JsonResponseFactory());
|
|
|
+ Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
|
|
|
+ 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());
|
|
|
+ $db->schema()->create('wallets', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 2); $t->timestamps(); });
|
|
|
+ $db->schema()->create('balance_logs', function (Blueprint $t) {
|
|
|
+ $t->id(); $t->string('member_id'); $t->decimal('amount', 24, 2); $t->decimal('before_balance', 24, 2); $t->decimal('after_balance', 24, 2);
|
|
|
+ $t->string('change_type'); $t->string('remark'); $t->integer('related_id')->nullable(); $t->timestamps();
|
|
|
+ });
|
|
|
+ foreach (self::TABLES as $kind => $table) $db->schema()->create($table, function (Blueprint $t) use ($kind) {
|
|
|
+ $t->id(); $t->string($kind === 'sport' ? 'user_id' : 'member_id'); $t->decimal('amount', 24, 2);
|
|
|
+ if ($kind === 'lottery') { $t->string('ordernum'); $t->decimal('total_amount', 24, 2)->nullable(); $t->integer('lottery_status')->default(0); }
|
|
|
+ else $t->integer('status')->default($kind === 'sport' ? 1 : 0);
|
|
|
+ $t->integer('type')->default(1);
|
|
|
+ if ($kind === 'sport') { $t->integer('pay_status')->default(1); $t->integer('return_status')->default(0); $t->integer('settlement_status')->default(0); $t->integer('return_operation_time')->nullable(); $t->integer('create_time')->nullable(); $t->integer('update_time')->nullable(); }
|
|
|
+ else $t->timestamps();
|
|
|
+ });
|
|
|
+ $db->schema()->create('lhc_order_item', function (Blueprint $t) { $t->id(); $t->string('ordernum'); $t->timestamps(); });
|
|
|
+ (require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_120000_add_promotion_coupons.php')->up();
|
|
|
+ DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '50']);
|
|
|
+ }
|
|
|
+
|
|
|
+ protected function tearDown(): void
|
|
|
+ {
|
|
|
+ DB::disconnect(); Model::clearBootedModels(); Facade::clearResolvedInstances(); parent::tearDown();
|
|
|
+ }
|
|
|
+
|
|
|
+ public static function kinds(): array { return array_map(fn ($kind) => [$kind], array_keys(self::TABLES)); }
|
|
|
+
|
|
|
+ private function seed(string $kind, int $id = 1, bool $coupon = true, bool $missingTotal = false): string
|
|
|
+ {
|
|
|
+ $coupon = $coupon && $kind !== 'jisu';
|
|
|
+ $row = ['id' => $id, $kind === 'sport' ? 'user_id' : 'member_id' => '1001', 'amount' => '100'];
|
|
|
+ if ($kind === 'lottery') {
|
|
|
+ $row += ['ordernum' => 'slip-' . $id, 'total_amount' => $missingTotal ? null : '300'];
|
|
|
+ foreach ([1, 2, 3] as $number) DB::table('lhc_order_item')->insert(['ordernum' => $row['ordernum']]);
|
|
|
+ }
|
|
|
+ if ($coupon) $row += ['coupon_id' => $id, 'coupon_discount' => '50'];
|
|
|
+ DB::table(self::TABLES[$kind])->insert($row);
|
|
|
+ if ($coupon) {
|
|
|
+ DB::table('promotion_coupons')->insert(['id' => $id, 'activity_id' => 1, 'reward_id' => $id, 'user_id' => 1, 'member_id' => '1001',
|
|
|
+ 'role' => 'inviter', 'inviter_user_id' => 1, 'seq' => 1, 'min_amount' => '100', 'discount' => '50', 'categories' => '["self:sport","self:lottery"]',
|
|
|
+ 'valid_from' => '2000-01-01 00:00:00', 'valid_until' => '2099-12-31 23:59:59', 'status' => 'used']);
|
|
|
+ DB::table('promotion_coupon_uses')->insert(['coupon_id' => $id, 'source' => self::TABLES[$kind], 'order_id' => $id, 'discount' => '50']);
|
|
|
+ }
|
|
|
+ return $kind === 'lottery' ? ($coupon ? '250.00' : '300.00') : ($coupon ? '50.00' : '100.00');
|
|
|
+ }
|
|
|
+
|
|
|
+ private function refund(string $kind, array $ids = [1]): array
|
|
|
+ {
|
|
|
+ $this->app->instance('request', Request::create('/admin/refund', 'POST', ['id' => $ids]));
|
|
|
+ return (new (self::CONTROLLERS[$kind])())->refund()->getData(true);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function snapshot(): array
|
|
|
+ {
|
|
|
+ $result = [];
|
|
|
+ foreach (['wallets', 'balance_logs', 'promotion_coupons', 'promotion_coupon_uses', ...array_values(self::TABLES)] as $table) $result[$table] = DB::table($table)->orderBy('id')->get()->toJson();
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ #[DataProvider('kinds')]
|
|
|
+ public function test_refund_credits_paid_amount_returns_coupon_and_retries_without_duplicates(string $kind): void
|
|
|
+ {
|
|
|
+ $refund = $this->seed($kind);
|
|
|
+ $this->assertSame(0, $this->refund($kind)['code']);
|
|
|
+ $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
|
|
|
+ $log = DB::table('balance_logs')->first();
|
|
|
+ $this->assertSame($refund, bcadd((string)$log->amount, '0', 2));
|
|
|
+ $this->assertSame(1, (int)$log->related_id);
|
|
|
+ $this->assertSame('50.00', bcadd((string)$log->before_balance, '0', 2));
|
|
|
+ $this->assertSame(bcadd('50', $refund, 2), bcadd((string)$log->after_balance, '0', 2));
|
|
|
+ if ($kind !== 'jisu') $this->assertSame('unused', DB::table('promotion_coupons')->value('status'));
|
|
|
+ $before = $this->snapshot();
|
|
|
+ $this->assertSame(0, $this->refund($kind)['code']);
|
|
|
+ $this->assertSame($before, $this->snapshot());
|
|
|
+ }
|
|
|
+
|
|
|
+ #[DataProvider('kinds')]
|
|
|
+ public function test_refund_without_coupon_credits_the_complete_order(string $kind): void
|
|
|
+ {
|
|
|
+ $refund = $this->seed($kind, coupon: false);
|
|
|
+ $this->assertSame(0, $this->refund($kind)['code']);
|
|
|
+ $this->assertSame(bcadd('50', $refund, 2), bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
|
|
|
+ }
|
|
|
+
|
|
|
+ #[DataProvider('kinds')]
|
|
|
+ public function test_missing_wallet_rolls_back_terminal_state_and_coupon(string $kind): void
|
|
|
+ {
|
|
|
+ $this->seed($kind); DB::table('wallets')->delete(); $before = $this->snapshot();
|
|
|
+ $this->assertSame(-3, $this->refund($kind)['code']);
|
|
|
+ $this->assertSame($before, $this->snapshot());
|
|
|
+ }
|
|
|
+
|
|
|
+ #[DataProvider('kinds')]
|
|
|
+ public function test_later_ledger_failure_rolls_back_the_entire_refund_batch(string $kind): void
|
|
|
+ {
|
|
|
+ $this->seed($kind); $this->seed($kind, 2); $before = $this->snapshot();
|
|
|
+ DB::statement("CREATE TRIGGER reject_admin_refund BEFORE INSERT ON bot_balance_logs WHEN NEW.related_id = 2 BEGIN SELECT RAISE(ABORT, 'fixture ledger failure'); END");
|
|
|
+ $this->assertSame(-3, $this->refund($kind, [1, 2])['code']);
|
|
|
+ $this->assertSame($before, $this->snapshot());
|
|
|
+ }
|
|
|
+
|
|
|
+ public function test_legacy_lottery_missing_total_is_derived_from_its_numbers(): void
|
|
|
+ {
|
|
|
+ $this->seed('lottery', coupon: false, missingTotal: true);
|
|
|
+ $this->assertSame(0, $this->refund('lottery')['code']);
|
|
|
+ $this->assertSame('350.00', bcadd((string)DB::table('wallets')->value('available_balance'), '0', 2));
|
|
|
+ }
|
|
|
+}
|