makeCtx(claudeKey: 'sk-fake'); Http::fake(['api.anthropic.com/*' => Http::response([ 'content' => [['type' => 'text', 'text' => json_encode([ 'supplier_name' => 'Acme Parts SRL', 'date' => '2026-05-15', 'currency' => 'MDL', 'items' => [ ['name' => 'Filtru ulei', 'qty' => 2, 'unit_price' => 75, 'total' => 150], ['name' => 'Filtru aer', 'qty' => 1, 'unit_price' => 90, 'total' => 90], ], 'total' => 240, ])]], 'usage' => ['input_tokens' => 1500, 'output_tokens' => 120], ])]); $img = $this->fakeImage(); $r = app(OcrInvoiceService::class)->extract($img); $this->assertTrue($r['ok']); $this->assertEquals('Acme Parts SRL', $r['data']['supplier_name']); $this->assertEquals('2026-05-15', $r['data']['date']); $this->assertCount(2, $r['data']['items']); $this->assertEquals(240.0, $r['data']['total']); @unlink($img); } public function test_extract_strips_markdown_fences(): void { $this->makeCtx(claudeKey: 'sk-fake'); $payload = json_encode(['supplier_name' => 'X', 'items' => [ ['name' => 'Y', 'qty' => 1, 'unit_price' => 50], ]]); Http::fake(['api.anthropic.com/*' => Http::response([ 'content' => [['type' => 'text', 'text' => "```json\n{$payload}\n```"]], 'usage' => ['input_tokens' => 100, 'output_tokens' => 30], ])]); $img = $this->fakeImage(); $r = app(OcrInvoiceService::class)->extract($img); $this->assertTrue($r['ok']); $this->assertEquals('X', $r['data']['supplier_name']); @unlink($img); } public function test_extract_handles_malformed_response(): void { $this->makeCtx(claudeKey: 'sk-fake'); Http::fake(['api.anthropic.com/*' => Http::response([ 'content' => [['type' => 'text', 'text' => 'this is not json at all']], 'usage' => ['input_tokens' => 50, 'output_tokens' => 10], ])]); $img = $this->fakeImage(); $r = app(OcrInvoiceService::class)->extract($img); $this->assertFalse($r['ok']); $this->assertStringContainsString('ne-parsabil', $r['error']); @unlink($img); } public function test_missing_api_key_returns_friendly_error(): void { $this->makeCtx(claudeKey: null); $img = $this->fakeImage(); $r = app(OcrInvoiceService::class)->extract($img); $this->assertFalse($r['ok']); $this->assertStringContainsString('Setări', $r['error']); Http::assertNothingSent(); @unlink($img); } public function test_unsupported_mime_rejected(): void { $this->makeCtx(claudeKey: 'sk-fake'); $path = tempnam(sys_get_temp_dir(), 'ocr'); file_put_contents($path, "%PDF-not really"); $r = app(OcrInvoiceService::class)->extract($path, 'application/pdf'); $this->assertFalse($r['ok']); $this->assertStringContainsString('Tip fișier', $r['error']); @unlink($path); } public function test_normalize_computes_item_total_when_missing(): void { $this->makeCtx(claudeKey: 'sk-fake'); Http::fake(['api.anthropic.com/*' => Http::response([ 'content' => [['type' => 'text', 'text' => json_encode([ 'items' => [['name' => 'A', 'qty' => 3, 'unit_price' => 25]], ])]], 'usage' => ['input_tokens' => 1, 'output_tokens' => 1], ])]); $img = $this->fakeImage(); $r = app(OcrInvoiceService::class)->extract($img); $this->assertEquals(75.0, $r['data']['items'][0]['total']); $this->assertEquals(75.0, $r['data']['total']); @unlink($img); } private function makeCtx(?string $claudeKey): Company { $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); $company = Company::create([ 'plan_id' => $plan->id, 'slug' => 'ocr-' . uniqid(), 'name' => 'OCR Co', 'status' => 'active', 'settings' => ['ai' => ['claude_key' => $claudeKey]], ]); app(TenantManager::class)->setCurrent($company); return $company; } private function fakeImage(): string { // Minimal 1x1 PNG so mime_content_type returns image/png. $bytes = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGNgYGBgAAAABQABh6FO1AAAAABJRU5ErkJggg=='); $path = tempnam(sys_get_temp_dir(), 'ocr') . '.png'; file_put_contents($path, $bytes); return $path; } }