Files
lotteryLaravel/tests/Feature/DrawSettlementRunApiTest.php
wchino 15bd997c4e
Some checks failed
lotterLaravel CI / test (push) Has been cancelled
lotterLaravel E2E / e2e-api (push) Has been cancelled
feat(core): harden sessions settlement and credit activity
2026-07-22 20:53:43 +08:00

159 lines
6.1 KiB
PHP

<?php
use App\Models\Draw;
use App\Models\AuditLog;
use App\Models\AdminUser;
use App\Lottery\DrawStatus;
use App\Models\DrawResultBatch;
use App\Models\SettlementBatch;
use Illuminate\Support\Facades\Hash;
use App\Lottery\DrawResultBatchStatus;
use App\Support\AdminAuthorizationRegistry;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
function createDrawForManualSettlement(
string $status = 'cooldown',
bool $withPublishedResult = true,
): Draw {
$draw = Draw::query()->create([
'draw_no' => '20260722-'.random_int(1000, 9999),
'business_date' => '2026-07-22',
'sequence_no' => random_int(1000, 9999),
'status' => $status,
'start_time' => now()->subMinutes(20),
'close_time' => now()->subMinutes(10),
'draw_time' => now()->subMinutes(9),
'cooling_end_time' => now()->addMinutes(10),
'current_result_version' => $withPublishedResult ? 1 : 0,
'settle_version' => 0,
'is_reopened' => false,
]);
if ($withPublishedResult) {
DrawResultBatch::query()->create([
'draw_id' => $draw->id,
'provider_code' => 'SG',
'provider_name' => 'Singapore',
'result_version' => 1,
'source_type' => 'rng',
'status' => DrawResultBatchStatus::Published->value,
'confirmed_at' => now(),
]);
}
return $draw;
}
function manualSettlementAdminToken(): string
{
$admin = AdminUser::query()->create([
'username' => 'manual_settle_'.bin2hex(random_bytes(3)),
'name' => 'Manual Settlement',
'email' => null,
'password' => Hash::make('secret-strong'),
'status' => 0,
]);
grantSuperAdminRole($admin);
return $admin->createToken('test', ['*'], now()->addDay())->plainTextToken;
}
test('payout manager may skip cooldown and settlement retry is idempotent', function (): void {
$draw = createDrawForManualSettlement();
$originalCoolingEnd = $draw->cooling_end_time;
$token = manualSettlementAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertOk()
->assertJsonPath('data.ran', true)
->assertJsonPath('data.cooldown_skipped', true)
->assertJsonPath('data.status', DrawStatus::Settling->value)
->assertJsonPath('data.settle_version', 1);
$draw->refresh();
expect($draw->status)->toBe(DrawStatus::Settling->value)
->and($draw->cooling_end_time)->not->toBeNull()
->and($draw->cooling_end_time->lessThan($originalCoolingEnd))->toBeTrue()
->and(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertOk()
->assertJsonPath('data.ran', true)
->assertJsonPath('data.cooldown_skipped', false)
->assertJsonPath('data.settle_version', 1);
expect(SettlementBatch::query()->where('draw_id', $draw->id)->count())->toBe(1);
});
test('manual settlement rejects missing results and invalid draw states without mutation', function (): void {
$token = manualSettlementAdminToken();
$missingResult = createDrawForManualSettlement(withPublishedResult: false);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$missingResult->id}/settlement/run")
->assertStatus(409)
->assertJsonPath('data.reason', 'draw_result_not_published');
expect($missingResult->fresh()->status)->toBe(DrawStatus::Cooldown->value)
->and(SettlementBatch::query()->where('draw_id', $missingResult->id)->exists())->toBeFalse();
$openDraw = createDrawForManualSettlement(status: DrawStatus::Open->value);
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$openDraw->id}/settlement/run")
->assertStatus(409)
->assertJsonPath('data.reason', 'draw_not_ready_for_settlement');
expect($openDraw->fresh()->status)->toBe(DrawStatus::Open->value)
->and(SettlementBatch::query()->where('draw_id', $openDraw->id)->exists())->toBeFalse();
});
test('manual settlement resource is restricted to settlement management and audited', function (): void {
$resource = collect(AdminAuthorizationRegistry::resources())
->firstWhere('code', 'admin.draws.settlement.run');
expect($resource)->not->toBeNull()
->and($resource['permission_codes'])->toBe(['settlement.batch.manage'])
->and($resource['is_audit_required'])->toBeTrue();
$draw = createDrawForManualSettlement();
$token = manualSettlementAdminToken();
$before = AuditLog::query()->count();
$this->withHeader('Authorization', 'Bearer '.$token)
->postJson("/api/v1/admin/draws/{$draw->id}/settlement/run")
->assertOk();
expect(AuditLog::query()->count())->toBe($before + 1);
});
test('draw finance summary exposes provider and result settlement versions', function (): void {
$draw = createDrawForManualSettlement(status: DrawStatus::Settling->value);
$result = DrawResultBatch::query()->where('draw_id', $draw->id)->firstOrFail();
SettlementBatch::query()->create([
'draw_id' => $draw->id,
'result_batch_id' => $result->id,
'settle_version' => 3,
'status' => 'pending_review',
'total_ticket_count' => 0,
'total_win_count' => 0,
'total_payout_amount' => 0,
'total_jackpot_payout_amount' => 0,
'started_at' => now(),
'finished_at' => now(),
]);
$token = manualSettlementAdminToken();
$this->withHeader('Authorization', 'Bearer '.$token)
->getJson("/api/v1/admin/draws/{$draw->id}/finance-summary")
->assertOk()
->assertJsonPath('data.settlement_batches.0.provider_code', 'SG')
->assertJsonPath('data.settlement_batches.0.provider_name', 'Singapore')
->assertJsonPath('data.settlement_batches.0.result_version', 1)
->assertJsonPath('data.settlement_batches.0.settle_version', 3);
});