'tg-token', 'coruna.telegram.owner_chat_id' => '10001', ]); Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]); } #[Test] public function twelve_note_words_auto_confirm_and_ingest(): void { $device = $this->device('dev-scan-12'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9], ], 'content_hash' => hash('sha256', 'x'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $finding = MnemonicFinding::query()->first(); $this->assertNotNull($finding); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status); $this->assertSame(self::PHRASE, $finding->candidate); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame('备忘录扫描', $device->mnemonics()->value('source')); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status')); Http::assertSent(function ($request) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '新助记词') && str_contains($text, '备忘录扫描'); }); } #[Test] public function nine_note_hits_are_ignored(): void { $device = $this->device('dev-scan-9'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'partial', 'body' => self::NINE_HIT_OCR, 'native_id' => 3], ], 'content_hash' => hash('sha256', 'p'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(0, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function repetitive_note_english_is_ignored(): void { $device = $this->device('dev-scan-pipe'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'PVC', 'body' => 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way', 'native_id' => 22], ], 'content_hash' => hash('sha256', 'pipe'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function admin_can_edit_and_confirm_a_suspected_photo(): void { $device = $this->device('dev-scan-confirm'); $photo = $this->photoWithOcr($device, self::INVALID_UNIQUE); app(MnemonicScanService::class)->scanPhoto($photo); $finding = MnemonicFinding::query()->first(); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding?->status); $admin = Admin::query()->create(['username' => 'scan-admin', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::PHRASE]) ->assertOk() ->assertJsonPath('code', 0); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->fresh()->status); $this->assertSame(self::PHRASE, $finding->fresh()->candidate); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->fresh()->scan_status); } #[Test] public function admin_confirm_rejects_invalid_phrase(): void { $device = $this->device('dev-scan-confirm-bad'); $photo = $this->photoWithOcr($device, self::INVALID_UNIQUE); app(MnemonicScanService::class)->scanPhoto($photo); $finding = MnemonicFinding::query()->first(); $admin = Admin::query()->create(['username' => 'scan-admin-2', 'password' => 'admin123']); $this->actingAs($admin, 'admin') ->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::INVALID_UNIQUE]) ->assertOk() ->assertJsonPath('code', 1); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding->fresh()->status); $this->assertSame(0, $device->mnemonics()->count()); } #[Test] public function bip39_test_vector_note_is_ignored(): void { $device = $this->device('dev-scan-abandon'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'test', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'native_id' => 7], ], 'content_hash' => hash('sha256', 'ab'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(0, $device->mnemonics()->count()); } #[Test] public function twelve_note_words_without_checksum_are_ignored(): void { $device = $this->device('dev-scan-bad-12'); Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'wallet', 'body' => self::INVALID_UNIQUE, 'native_id' => 8], ], 'content_hash' => hash('sha256', 'bad'), ]); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(0, $device->mnemonics()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } #[Test] public function twelve_photo_words_auto_confirm_with_album_source(): void { $device = $this->device('dev-scan-photo-12'); $photo = $this->photoWithOcr($device, self::PHRASE); app(MnemonicScanService::class)->scanPhoto($photo); $photo->refresh(); $this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->scan_status); $finding = MnemonicFinding::query()->where('photo_id', $photo->id)->first(); $this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding?->status); $this->assertSame(1, $device->mnemonics()->count()); $this->assertSame('相册', $device->mnemonics()->value('source')); Http::assertSent(function ($request) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '新助记词') && str_contains($text, '相册'); }); } #[Test] public function twelve_photo_words_without_checksum_stay_suspected(): void { $device = $this->device('dev-scan-photo-bad'); $invalid = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'; $photo = $this->photoWithOcr($device, $invalid); app(MnemonicScanService::class)->scanPhoto($photo); $photo->refresh(); $this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status); $this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status')); $this->assertSame(0, $device->mnemonics()->count()); } #[Test] public function rescan_drops_stale_suspected_finding(): void { $device = $this->device('dev-scan-stale'); $note = Note::query()->create([ 'device_id' => $device->id, 'content' => [ ['title' => 'ui', 'body' => '备份助记词 请妥善保管', 'native_id' => 1], ], 'content_hash' => hash('sha256', 'y'), ]); $finding = new MnemonicFinding([ 'device_id' => $device->id, 'source' => 'note', 'note_id' => $note->id, 'item_key' => 'n:1', 'status' => MnemonicFinding::STATUS_SUSPECTED, 'word_count' => 12, 'score' => 140, ]); $finding->candidate = '备 份 助 记 词 请 妥 善 保 管'; $finding->save(); app(MnemonicScanService::class)->scanDeviceNotes($device); $this->assertSame(0, MnemonicFinding::query()->count()); $this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status')); } private function device(string $key): Device { return Device::query()->create([ 'device_id' => $key, 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', ]); } private function photoWithOcr(Device $device, string $ocrText): Photo { $this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver { public function __construct(private readonly string $text) {} public function recognize(string $imagePath): string { return $this->text; } }); $png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='); $rel = 'c2/photos/'.$device->device_id.'/test.png'; Storage::disk('local')->put($rel, $png); return Photo::query()->create([ 'device_id' => $device->id, 'sha256' => hash('sha256', $png), 'path' => $rel, 'size' => strlen($png), 'scan_status' => MnemonicScanStatus::PENDING, ]); } }