diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
deleted file mode 100644
index 4294634..0000000
--- a/docker-compose.dev.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-services:
- postgres:
- image: postgres:16-alpine
- environment:
- POSTGRES_USER: ${POSTGRES_USER:-app}
- POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-app}
- POSTGRES_DB: ${POSTGRES_DB:-docling_studio}
- ports:
- - "5432:5432"
- volumes:
- - postgres_data:/var/lib/postgresql/data
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-app} -d ${POSTGRES_DB:-docling_studio}"]
- interval: 5s
- timeout: 5s
- retries: 10
-
-volumes:
- postgres_data:
diff --git a/document-parser/conftest.py b/document-parser/conftest.py
new file mode 100644
index 0000000..1241b04
--- /dev/null
+++ b/document-parser/conftest.py
@@ -0,0 +1,4 @@
+import pytest
+
+
+pytest_plugins = ["pytest_asyncio"]
diff --git a/document-parser/pytest.ini b/document-parser/pytest.ini
new file mode 100644
index 0000000..2f4c80e
--- /dev/null
+++ b/document-parser/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+asyncio_mode = auto
diff --git a/document-parser/tests/__init__.py b/document-parser/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/document-parser/tests/test_api_endpoints.py b/document-parser/tests/test_api_endpoints.py
new file mode 100644
index 0000000..1d5467d
--- /dev/null
+++ b/document-parser/tests/test_api_endpoints.py
@@ -0,0 +1,178 @@
+"""Tests for FastAPI API endpoints using TestClient."""
+
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from fastapi.testclient import TestClient
+
+from domain.models import AnalysisJob, AnalysisStatus, Document
+from main import app
+
+
+@pytest.fixture
+def client():
+ return TestClient(app, raise_server_exceptions=False)
+
+
+class TestHealthEndpoint:
+ def test_health(self, client):
+ resp = client.get("/health")
+ assert resp.status_code == 200
+ assert resp.json() == {"status": "ok"}
+
+
+class TestDocumentEndpoints:
+ @patch("services.document_service.find_all", new_callable=AsyncMock)
+ def test_list_documents(self, mock_find_all, client):
+ mock_find_all.return_value = [
+ Document(id="d1", filename="a.pdf", storage_path="/tmp/a"),
+ Document(id="d2", filename="b.pdf", storage_path="/tmp/b"),
+ ]
+
+ resp = client.get("/api/documents")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 2
+ assert data[0]["id"] == "d1"
+ assert data[0]["filename"] == "a.pdf"
+ # Verify camelCase
+ assert "createdAt" in data[0]
+
+ @patch("services.document_service.find_by_id", new_callable=AsyncMock)
+ def test_get_document(self, mock_find, client):
+ mock_find.return_value = Document(
+ id="d1", filename="test.pdf", content_type="application/pdf",
+ file_size=2048, page_count=3, storage_path="/tmp/test",
+ )
+
+ resp = client.get("/api/documents/d1")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["id"] == "d1"
+ assert data["fileSize"] == 2048
+ assert data["pageCount"] == 3
+
+ @patch("services.document_service.find_by_id", new_callable=AsyncMock)
+ def test_get_document_not_found(self, mock_find, client):
+ mock_find.return_value = None
+
+ resp = client.get("/api/documents/missing")
+ assert resp.status_code == 404
+
+ @patch("services.document_service.upload", new_callable=AsyncMock)
+ def test_upload_document(self, mock_upload, client):
+ mock_upload.return_value = Document(
+ id="new-1", filename="uploaded.pdf",
+ content_type="application/pdf", file_size=512,
+ storage_path="/tmp/uploaded",
+ )
+
+ resp = client.post(
+ "/api/documents/upload",
+ files={"file": ("uploaded.pdf", b"fake-pdf-content", "application/pdf")},
+ )
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["id"] == "new-1"
+ assert data["filename"] == "uploaded.pdf"
+
+ @patch("services.document_service.upload", new_callable=AsyncMock)
+ def test_upload_too_large(self, mock_upload, client):
+ mock_upload.side_effect = ValueError("File too large (max 50 MB)")
+
+ resp = client.post(
+ "/api/documents/upload",
+ files={"file": ("big.pdf", b"x", "application/pdf")},
+ )
+ assert resp.status_code == 413
+
+ @patch("services.document_service.delete", new_callable=AsyncMock)
+ def test_delete_document(self, mock_delete, client):
+ mock_delete.return_value = True
+
+ resp = client.delete("/api/documents/d1")
+ assert resp.status_code == 204
+
+ @patch("services.document_service.delete", new_callable=AsyncMock)
+ def test_delete_document_not_found(self, mock_delete, client):
+ mock_delete.return_value = False
+
+ resp = client.delete("/api/documents/missing")
+ assert resp.status_code == 404
+
+
+class TestAnalysisEndpoints:
+ @patch("services.analysis_service.find_all", new_callable=AsyncMock)
+ def test_list_analyses(self, mock_find_all, client):
+ mock_find_all.return_value = [
+ AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf"),
+ ]
+
+ resp = client.get("/api/analyses")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert len(data) == 1
+ assert data[0]["id"] == "j1"
+ assert data[0]["documentId"] == "d1"
+ assert data[0]["documentFilename"] == "test.pdf"
+ assert data[0]["status"] == "PENDING"
+
+ @patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
+ def test_get_analysis(self, mock_find, client):
+ job = AnalysisJob(id="j1", document_id="d1", document_filename="test.pdf")
+ job.mark_running()
+ mock_find.return_value = job
+
+ resp = client.get("/api/analyses/j1")
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["status"] == "RUNNING"
+ assert data["startedAt"] is not None
+
+ @patch("services.analysis_service.find_by_id", new_callable=AsyncMock)
+ def test_get_analysis_not_found(self, mock_find, client):
+ mock_find.return_value = None
+
+ resp = client.get("/api/analyses/missing")
+ assert resp.status_code == 404
+
+ @patch("services.analysis_service.create", new_callable=AsyncMock)
+ def test_create_analysis(self, mock_create, client):
+ mock_create.return_value = AnalysisJob(
+ id="j1", document_id="d1", document_filename="test.pdf",
+ )
+
+ resp = client.post("/api/analyses", json={"documentId": "d1"})
+ assert resp.status_code == 200
+ data = resp.json()
+ assert data["id"] == "j1"
+ assert data["documentId"] == "d1"
+
+ @patch("services.analysis_service.create", new_callable=AsyncMock)
+ def test_create_analysis_document_not_found(self, mock_create, client):
+ mock_create.side_effect = ValueError("Document not found: d99")
+
+ resp = client.post("/api/analyses", json={"documentId": "d99"})
+ assert resp.status_code == 404
+
+ def test_create_analysis_empty_document_id(self, client):
+ resp = client.post("/api/analyses", json={"documentId": ""})
+ assert resp.status_code == 400
+
+ def test_create_analysis_whitespace_document_id(self, client):
+ resp = client.post("/api/analyses", json={"documentId": " "})
+ assert resp.status_code == 400
+
+ @patch("services.analysis_service.delete", new_callable=AsyncMock)
+ def test_delete_analysis(self, mock_delete, client):
+ mock_delete.return_value = True
+
+ resp = client.delete("/api/analyses/j1")
+ assert resp.status_code == 204
+
+ @patch("services.analysis_service.delete", new_callable=AsyncMock)
+ def test_delete_analysis_not_found(self, mock_delete, client):
+ mock_delete.return_value = False
+
+ resp = client.delete("/api/analyses/missing")
+ assert resp.status_code == 404
diff --git a/document-parser/tests/test_models.py b/document-parser/tests/test_models.py
new file mode 100644
index 0000000..a738dce
--- /dev/null
+++ b/document-parser/tests/test_models.py
@@ -0,0 +1,108 @@
+"""Tests for domain models."""
+
+from datetime import datetime, timezone
+
+from domain.models import AnalysisJob, AnalysisStatus, Document
+
+
+class TestDocument:
+ def test_default_values(self):
+ doc = Document()
+ assert doc.id # auto-generated UUID
+ assert doc.filename == ""
+ assert doc.content_type is None
+ assert doc.file_size is None
+ assert doc.page_count is None
+ assert doc.storage_path == ""
+ assert isinstance(doc.created_at, datetime)
+
+ def test_custom_values(self):
+ doc = Document(
+ id="doc-1",
+ filename="test.pdf",
+ content_type="application/pdf",
+ file_size=1024,
+ page_count=5,
+ storage_path="/tmp/test.pdf",
+ )
+ assert doc.id == "doc-1"
+ assert doc.filename == "test.pdf"
+ assert doc.file_size == 1024
+ assert doc.page_count == 5
+
+ def test_unique_ids(self):
+ d1 = Document()
+ d2 = Document()
+ assert d1.id != d2.id
+
+
+class TestAnalysisJob:
+ def test_default_values(self):
+ job = AnalysisJob()
+ assert job.id
+ assert job.document_id == ""
+ assert job.status == AnalysisStatus.PENDING
+ assert job.content_markdown is None
+ assert job.content_html is None
+ assert job.pages_json is None
+ assert job.error_message is None
+ assert job.started_at is None
+ assert job.completed_at is None
+
+ def test_mark_running(self):
+ job = AnalysisJob()
+ assert job.started_at is None
+
+ job.mark_running()
+
+ assert job.status == AnalysisStatus.RUNNING
+ assert job.started_at is not None
+
+ def test_mark_completed(self):
+ job = AnalysisJob()
+ job.mark_running()
+
+ job.mark_completed(
+ markdown="# Title",
+ html="
Title
",
+ pages_json='[{"page": 1}]',
+ )
+
+ assert job.status == AnalysisStatus.COMPLETED
+ assert job.content_markdown == "# Title"
+ assert job.content_html == "Title
"
+ assert job.pages_json == '[{"page": 1}]'
+ assert job.completed_at is not None
+
+ def test_mark_failed(self):
+ job = AnalysisJob()
+ job.mark_running()
+
+ job.mark_failed("Something went wrong")
+
+ assert job.status == AnalysisStatus.FAILED
+ assert job.error_message == "Something went wrong"
+ assert job.completed_at is not None
+
+ def test_status_transitions(self):
+ """Test full lifecycle: PENDING -> RUNNING -> COMPLETED."""
+ job = AnalysisJob()
+ assert job.status == AnalysisStatus.PENDING
+
+ job.mark_running()
+ assert job.status == AnalysisStatus.RUNNING
+
+ job.mark_completed(markdown="md", html="html", pages_json="[]")
+ assert job.status == AnalysisStatus.COMPLETED
+
+
+class TestAnalysisStatus:
+ def test_values(self):
+ assert AnalysisStatus.PENDING == "PENDING"
+ assert AnalysisStatus.RUNNING == "RUNNING"
+ assert AnalysisStatus.COMPLETED == "COMPLETED"
+ assert AnalysisStatus.FAILED == "FAILED"
+
+ def test_from_string(self):
+ assert AnalysisStatus("PENDING") == AnalysisStatus.PENDING
+ assert AnalysisStatus("COMPLETED") == AnalysisStatus.COMPLETED
diff --git a/document-parser/tests/test_repos.py b/document-parser/tests/test_repos.py
new file mode 100644
index 0000000..84931fc
--- /dev/null
+++ b/document-parser/tests/test_repos.py
@@ -0,0 +1,157 @@
+"""Tests for persistence repositories using a temporary SQLite database."""
+
+import os
+
+import pytest
+
+from domain.models import AnalysisJob, AnalysisStatus, Document
+from persistence import document_repo, analysis_repo
+from persistence.database import init_db
+
+
+@pytest.fixture(autouse=True)
+async def setup_db(monkeypatch, tmp_path):
+ """Use a temp file SQLite database for all repo tests."""
+ db_path = str(tmp_path / "test.db")
+ monkeypatch.setattr("persistence.database.DB_PATH", db_path)
+ await init_db()
+ yield
+
+
+class TestDocumentRepo:
+ async def test_insert_and_find_by_id(self):
+ doc = Document(
+ id="doc-1",
+ filename="test.pdf",
+ content_type="application/pdf",
+ file_size=1024,
+ storage_path="/tmp/test.pdf",
+ )
+ await document_repo.insert(doc)
+
+ found = await document_repo.find_by_id("doc-1")
+ assert found is not None
+ assert found.id == "doc-1"
+ assert found.filename == "test.pdf"
+ assert found.file_size == 1024
+
+ async def test_find_by_id_not_found(self):
+ found = await document_repo.find_by_id("nonexistent")
+ assert found is None
+
+ async def test_find_all(self):
+ for i in range(3):
+ doc = Document(id=f"doc-{i}", filename=f"file{i}.pdf", storage_path=f"/tmp/{i}")
+ await document_repo.insert(doc)
+
+ all_docs = await document_repo.find_all()
+ assert len(all_docs) == 3
+
+ async def test_update_page_count(self):
+ doc = Document(id="doc-1", filename="test.pdf", storage_path="/tmp/test.pdf")
+ await document_repo.insert(doc)
+
+ await document_repo.update_page_count("doc-1", 10)
+
+ updated = await document_repo.find_by_id("doc-1")
+ assert updated.page_count == 10
+
+ async def test_delete(self):
+ doc = Document(id="doc-1", filename="test.pdf", storage_path="/tmp/test.pdf")
+ await document_repo.insert(doc)
+
+ deleted = await document_repo.delete("doc-1")
+ assert deleted is True
+
+ found = await document_repo.find_by_id("doc-1")
+ assert found is None
+
+ async def test_delete_nonexistent(self):
+ deleted = await document_repo.delete("nonexistent")
+ assert deleted is False
+
+
+class TestAnalysisRepo:
+ async def _insert_doc(self):
+ doc = Document(id="doc-1", filename="test.pdf", storage_path="/tmp/test.pdf")
+ await document_repo.insert(doc)
+ return doc
+
+ async def test_insert_and_find_by_id(self):
+ await self._insert_doc()
+ job = AnalysisJob(id="job-1", document_id="doc-1")
+ await analysis_repo.insert(job)
+
+ found = await analysis_repo.find_by_id("job-1")
+ assert found is not None
+ assert found.id == "job-1"
+ assert found.document_id == "doc-1"
+ assert found.status == AnalysisStatus.PENDING
+ assert found.document_filename == "test.pdf"
+
+ async def test_find_by_id_not_found(self):
+ found = await analysis_repo.find_by_id("nonexistent")
+ assert found is None
+
+ async def test_find_all(self):
+ await self._insert_doc()
+ for i in range(3):
+ job = AnalysisJob(id=f"job-{i}", document_id="doc-1")
+ await analysis_repo.insert(job)
+
+ all_jobs = await analysis_repo.find_all()
+ assert len(all_jobs) == 3
+
+ async def test_update_status(self):
+ await self._insert_doc()
+ job = AnalysisJob(id="job-1", document_id="doc-1")
+ await analysis_repo.insert(job)
+
+ job.mark_running()
+ await analysis_repo.update_status(job)
+
+ found = await analysis_repo.find_by_id("job-1")
+ assert found.status == AnalysisStatus.RUNNING
+ assert found.started_at is not None
+
+ async def test_update_status_completed(self):
+ await self._insert_doc()
+ job = AnalysisJob(id="job-1", document_id="doc-1")
+ await analysis_repo.insert(job)
+
+ job.mark_running()
+ job.mark_completed(markdown="# Test", html="Test
", pages_json="[]")
+ await analysis_repo.update_status(job)
+
+ found = await analysis_repo.find_by_id("job-1")
+ assert found.status == AnalysisStatus.COMPLETED
+ assert found.content_markdown == "# Test"
+ assert found.content_html == "Test
"
+ assert found.pages_json == "[]"
+
+ async def test_delete(self):
+ await self._insert_doc()
+ job = AnalysisJob(id="job-1", document_id="doc-1")
+ await analysis_repo.insert(job)
+
+ deleted = await analysis_repo.delete("job-1")
+ assert deleted is True
+
+ found = await analysis_repo.find_by_id("job-1")
+ assert found is None
+
+ async def test_delete_nonexistent(self):
+ deleted = await analysis_repo.delete("nonexistent")
+ assert deleted is False
+
+ async def test_delete_by_document(self):
+ await self._insert_doc()
+ for i in range(3):
+ job = AnalysisJob(id=f"job-{i}", document_id="doc-1")
+ await analysis_repo.insert(job)
+
+ count = await analysis_repo.delete_by_document("doc-1")
+ assert count == 3
+
+ all_jobs = await analysis_repo.find_all()
+ assert len(all_jobs) == 0
diff --git a/document-parser/tests/test_schemas.py b/document-parser/tests/test_schemas.py
new file mode 100644
index 0000000..dc6c8a8
--- /dev/null
+++ b/document-parser/tests/test_schemas.py
@@ -0,0 +1,85 @@
+"""Tests for API schemas — camelCase serialization."""
+
+from datetime import datetime
+
+from api.schemas import (
+ AnalysisResponse,
+ CreateAnalysisRequest,
+ DocumentResponse,
+ _to_camel,
+)
+
+
+class TestToCamel:
+ def test_single_word(self):
+ assert _to_camel("status") == "status"
+
+ def test_two_words(self):
+ assert _to_camel("document_id") == "documentId"
+
+ def test_three_words(self):
+ assert _to_camel("content_html_raw") == "contentHtmlRaw"
+
+ def test_already_camel(self):
+ assert _to_camel("documentId") == "documentId"
+
+
+class TestDocumentResponse:
+ def test_serializes_to_camel_case(self):
+ doc = DocumentResponse(
+ id="1",
+ filename="test.pdf",
+ content_type="application/pdf",
+ file_size=1024,
+ page_count=5,
+ created_at="2024-01-01",
+ )
+ data = doc.model_dump(by_alias=True)
+ assert "contentType" in data
+ assert "fileSize" in data
+ assert "pageCount" in data
+ assert "createdAt" in data
+ # Original snake_case should not appear
+ assert "content_type" not in data
+ assert "file_size" not in data
+
+ def test_optional_fields_default_to_none(self):
+ doc = DocumentResponse(id="1", filename="test.pdf", created_at="2024-01-01")
+ assert doc.content_type is None
+ assert doc.file_size is None
+ assert doc.page_count is None
+
+
+class TestAnalysisResponse:
+ def test_serializes_to_camel_case(self):
+ resp = AnalysisResponse(
+ id="1",
+ document_id="d1",
+ status="COMPLETED",
+ content_markdown="# Hello",
+ pages_json="[]",
+ created_at="2024-01-01",
+ )
+ data = resp.model_dump(by_alias=True)
+ assert "documentId" in data
+ assert "contentMarkdown" in data
+ assert "pagesJson" in data
+ assert "errorMessage" in data
+ assert "startedAt" in data
+ assert "completedAt" in data
+
+ def test_populate_by_name(self):
+ """Can create using snake_case field names."""
+ resp = AnalysisResponse(
+ id="1",
+ document_id="d1",
+ status="PENDING",
+ created_at="2024-01-01",
+ )
+ assert resp.document_id == "d1"
+
+
+class TestCreateAnalysisRequest:
+ def test_parses_document_id(self):
+ req = CreateAnalysisRequest(documentId="doc-42")
+ assert req.documentId == "doc-42"
diff --git a/frontend/src/features/analysis/api.test.js b/frontend/src/features/analysis/api.test.js
new file mode 100644
index 0000000..684b8d6
--- /dev/null
+++ b/frontend/src/features/analysis/api.test.js
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { createAnalysis, fetchAnalyses, fetchAnalysis, deleteAnalysis } from './api.js'
+
+vi.mock('../../shared/api/http.js', () => ({
+ apiFetch: vi.fn(),
+}))
+
+import { apiFetch } from '../../shared/api/http.js'
+
+describe('analysis API', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('createAnalysis sends POST with documentId', async () => {
+ const job = { id: '1', documentId: 'doc-1', status: 'PENDING' }
+ apiFetch.mockResolvedValue(job)
+
+ const result = await createAnalysis('doc-1')
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/analyses', {
+ method: 'POST',
+ body: JSON.stringify({ documentId: 'doc-1' }),
+ })
+ expect(result).toEqual(job)
+ })
+
+ it('fetchAnalyses calls GET /api/analyses', async () => {
+ const jobs = [{ id: '1', status: 'COMPLETED' }]
+ apiFetch.mockResolvedValue(jobs)
+
+ const result = await fetchAnalyses()
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/analyses')
+ expect(result).toEqual(jobs)
+ })
+
+ it('fetchAnalysis calls GET /api/analyses/:id', async () => {
+ const job = { id: '42', status: 'RUNNING' }
+ apiFetch.mockResolvedValue(job)
+
+ const result = await fetchAnalysis('42')
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/analyses/42')
+ expect(result).toEqual(job)
+ })
+
+ it('deleteAnalysis calls DELETE /api/analyses/:id', async () => {
+ apiFetch.mockResolvedValue(null)
+
+ await deleteAnalysis('42')
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/analyses/42', { method: 'DELETE' })
+ })
+})
diff --git a/frontend/src/features/analysis/store.test.js b/frontend/src/features/analysis/store.test.js
new file mode 100644
index 0000000..30324fd
--- /dev/null
+++ b/frontend/src/features/analysis/store.test.js
@@ -0,0 +1,155 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useAnalysisStore } from './store.js'
+
+vi.mock('./api.js', () => ({
+ fetchAnalyses: vi.fn(),
+ fetchAnalysis: vi.fn(),
+ createAnalysis: vi.fn(),
+ deleteAnalysis: vi.fn(),
+}))
+
+import * as api from './api.js'
+
+describe('useAnalysisStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ vi.useFakeTimers()
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('starts with empty state', () => {
+ const store = useAnalysisStore()
+ expect(store.analyses).toEqual([])
+ expect(store.currentAnalysis).toBeNull()
+ expect(store.running).toBe(false)
+ })
+
+ it('currentPages parses pagesJson from current analysis', () => {
+ const store = useAnalysisStore()
+ store.currentAnalysis = {
+ pagesJson: JSON.stringify([{ pageNumber: 1, width: 612, height: 792 }]),
+ }
+ expect(store.currentPages).toEqual([{ pageNumber: 1, width: 612, height: 792 }])
+ })
+
+ it('currentPages returns [] when no current analysis', () => {
+ const store = useAnalysisStore()
+ expect(store.currentPages).toEqual([])
+ })
+
+ it('currentPages returns [] on invalid JSON', () => {
+ const store = useAnalysisStore()
+ store.currentAnalysis = { pagesJson: 'not json' }
+ expect(store.currentPages).toEqual([])
+ })
+
+ it('load() fetches analyses', async () => {
+ const data = [{ id: '1', status: 'COMPLETED' }]
+ api.fetchAnalyses.mockResolvedValue(data)
+
+ const store = useAnalysisStore()
+ await store.load()
+
+ expect(store.analyses).toEqual(data)
+ })
+
+ it('run() creates analysis, sets current, and starts polling', async () => {
+ const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
+ api.createAnalysis.mockResolvedValue(job)
+ api.fetchAnalysis.mockResolvedValue({ ...job, status: 'COMPLETED' })
+
+ const store = useAnalysisStore()
+ const result = await store.run('d1')
+
+ expect(result).toEqual(job)
+ expect(store.currentAnalysis).toEqual(job)
+ expect(store.analyses[0]).toEqual(job)
+ expect(store.running).toBe(true)
+
+ // Advance timer to trigger polling
+ await vi.advanceTimersByTimeAsync(2000)
+
+ expect(api.fetchAnalysis).toHaveBeenCalledWith('j1')
+ expect(store.running).toBe(false) // COMPLETED stops polling
+
+ store.stopPolling()
+ })
+
+ it('run() resets running on error', async () => {
+ api.createAnalysis.mockRejectedValue(new Error('fail'))
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const store = useAnalysisStore()
+ await expect(store.run('d1')).rejects.toThrow('fail')
+
+ expect(store.running).toBe(false)
+ })
+
+ it('select() fetches and sets current analysis', async () => {
+ const job = { id: '42', status: 'COMPLETED' }
+ api.fetchAnalysis.mockResolvedValue(job)
+
+ const store = useAnalysisStore()
+ await store.select('42')
+
+ expect(store.currentAnalysis).toEqual(job)
+ })
+
+ it('remove() deletes and removes from list', async () => {
+ api.deleteAnalysis.mockResolvedValue(null)
+
+ const store = useAnalysisStore()
+ store.analyses = [{ id: '1' }, { id: '2' }]
+ store.currentAnalysis = { id: '1' }
+
+ await store.remove('1')
+
+ expect(store.analyses).toEqual([{ id: '2' }])
+ expect(store.currentAnalysis).toBeNull()
+ })
+
+ it('remove() keeps currentAnalysis if different id', async () => {
+ api.deleteAnalysis.mockResolvedValue(null)
+
+ const store = useAnalysisStore()
+ store.analyses = [{ id: '1' }, { id: '2' }]
+ store.currentAnalysis = { id: '2' }
+
+ await store.remove('1')
+
+ expect(store.currentAnalysis).toEqual({ id: '2' })
+ })
+
+ it('polling stops on FAILED status', async () => {
+ const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
+ api.createAnalysis.mockResolvedValue(job)
+ api.fetchAnalysis.mockResolvedValue({ ...job, status: 'FAILED', errorMessage: 'oops' })
+
+ const store = useAnalysisStore()
+ await store.run('d1')
+
+ await vi.advanceTimersByTimeAsync(2000)
+
+ expect(store.running).toBe(false)
+ expect(store.currentAnalysis.status).toBe('FAILED')
+ })
+
+ it('polling stops on fetch error', async () => {
+ const job = { id: 'j1', status: 'PENDING', documentId: 'd1' }
+ api.createAnalysis.mockResolvedValue(job)
+ api.fetchAnalysis.mockRejectedValue(new Error('network'))
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const store = useAnalysisStore()
+ await store.run('d1')
+
+ await vi.advanceTimersByTimeAsync(2000)
+
+ expect(store.running).toBe(false)
+ })
+})
diff --git a/frontend/src/features/document/api.test.js b/frontend/src/features/document/api.test.js
new file mode 100644
index 0000000..51ed4d3
--- /dev/null
+++ b/frontend/src/features/document/api.test.js
@@ -0,0 +1,65 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { fetchDocuments, fetchDocument, uploadDocument, deleteDocument, getPreviewUrl } from './api.js'
+
+vi.mock('../../shared/api/http.js', () => ({
+ apiFetch: vi.fn(),
+}))
+
+import { apiFetch } from '../../shared/api/http.js'
+
+describe('document API', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('fetchDocuments calls GET /api/documents', async () => {
+ const docs = [{ id: '1', filename: 'a.pdf' }]
+ apiFetch.mockResolvedValue(docs)
+
+ const result = await fetchDocuments()
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/documents')
+ expect(result).toEqual(docs)
+ })
+
+ it('fetchDocument calls GET /api/documents/:id', async () => {
+ const doc = { id: '42', filename: 'test.pdf' }
+ apiFetch.mockResolvedValue(doc)
+
+ const result = await fetchDocument('42')
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/documents/42')
+ expect(result).toEqual(doc)
+ })
+
+ it('uploadDocument sends file via FormData with skipContentType', async () => {
+ const file = new File(['content'], 'test.pdf', { type: 'application/pdf' })
+ const response = { id: '1', filename: 'test.pdf' }
+ apiFetch.mockResolvedValue(response)
+
+ const result = await uploadDocument(file)
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/documents/upload', {
+ method: 'POST',
+ body: expect.any(FormData),
+ skipContentType: true,
+ })
+ expect(result).toEqual(response)
+ })
+
+ it('deleteDocument calls DELETE /api/documents/:id', async () => {
+ apiFetch.mockResolvedValue(null)
+
+ await deleteDocument('42')
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/documents/42', { method: 'DELETE' })
+ })
+
+ it('getPreviewUrl builds correct URL with defaults', () => {
+ expect(getPreviewUrl('abc')).toBe('/api/documents/abc/preview?page=1&dpi=150')
+ })
+
+ it('getPreviewUrl accepts custom page and dpi', () => {
+ expect(getPreviewUrl('abc', 3, 300)).toBe('/api/documents/abc/preview?page=3&dpi=300')
+ })
+})
diff --git a/frontend/src/features/document/store.test.js b/frontend/src/features/document/store.test.js
new file mode 100644
index 0000000..a5967a4
--- /dev/null
+++ b/frontend/src/features/document/store.test.js
@@ -0,0 +1,115 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useDocumentStore } from './store.js'
+
+vi.mock('./api.js', () => ({
+ fetchDocuments: vi.fn(),
+ uploadDocument: vi.fn(),
+ deleteDocument: vi.fn(),
+}))
+
+import * as api from './api.js'
+
+describe('useDocumentStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ })
+
+ it('starts with empty state', () => {
+ const store = useDocumentStore()
+ expect(store.documents).toEqual([])
+ expect(store.selectedId).toBeNull()
+ expect(store.uploading).toBe(false)
+ })
+
+ it('load() fetches and sets documents', async () => {
+ const docs = [{ id: '1', filename: 'a.pdf' }, { id: '2', filename: 'b.pdf' }]
+ api.fetchDocuments.mockResolvedValue(docs)
+
+ const store = useDocumentStore()
+ await store.load()
+
+ expect(store.documents).toEqual(docs)
+ })
+
+ it('load() handles errors gracefully', async () => {
+ api.fetchDocuments.mockRejectedValue(new Error('network'))
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const store = useDocumentStore()
+ await store.load()
+
+ expect(store.documents).toEqual([])
+ spy.mockRestore()
+ })
+
+ it('upload() adds document to front of list and selects it', async () => {
+ const newDoc = { id: 'new', filename: 'new.pdf' }
+ api.uploadDocument.mockResolvedValue(newDoc)
+
+ const store = useDocumentStore()
+ store.documents = [{ id: 'old', filename: 'old.pdf' }]
+
+ const result = await store.upload(new File([], 'new.pdf'))
+
+ expect(result).toEqual(newDoc)
+ expect(store.documents[0]).toEqual(newDoc)
+ expect(store.selectedId).toBe('new')
+ expect(store.uploading).toBe(false)
+ })
+
+ it('upload() sets uploading to true during upload', async () => {
+ let resolveUpload
+ api.uploadDocument.mockImplementation(() => new Promise(r => { resolveUpload = r }))
+
+ const store = useDocumentStore()
+ const promise = store.upload(new File([], 'test.pdf'))
+
+ expect(store.uploading).toBe(true)
+ resolveUpload({ id: '1', filename: 'test.pdf' })
+ await promise
+ expect(store.uploading).toBe(false)
+ })
+
+ it('upload() resets uploading on error', async () => {
+ api.uploadDocument.mockRejectedValue(new Error('fail'))
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const store = useDocumentStore()
+
+ await expect(store.upload(new File([], 'test.pdf'))).rejects.toThrow('fail')
+ expect(store.uploading).toBe(false)
+ })
+
+ it('remove() deletes document and clears selection if needed', async () => {
+ api.deleteDocument.mockResolvedValue(null)
+
+ const store = useDocumentStore()
+ store.documents = [{ id: '1' }, { id: '2' }]
+ store.selectedId = '1'
+
+ await store.remove('1')
+
+ expect(store.documents).toEqual([{ id: '2' }])
+ expect(store.selectedId).toBeNull()
+ })
+
+ it('remove() does not clear selection for other documents', async () => {
+ api.deleteDocument.mockResolvedValue(null)
+
+ const store = useDocumentStore()
+ store.documents = [{ id: '1' }, { id: '2' }]
+ store.selectedId = '2'
+
+ await store.remove('1')
+
+ expect(store.selectedId).toBe('2')
+ })
+
+ it('select() sets selectedId', () => {
+ const store = useDocumentStore()
+ store.select('42')
+ expect(store.selectedId).toBe('42')
+ })
+})
diff --git a/frontend/src/features/history/api.test.js b/frontend/src/features/history/api.test.js
new file mode 100644
index 0000000..cca61d0
--- /dev/null
+++ b/frontend/src/features/history/api.test.js
@@ -0,0 +1,32 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { fetchAnalyses, deleteAnalysis } from './api.js'
+
+vi.mock('../../shared/api/http.js', () => ({
+ apiFetch: vi.fn(),
+}))
+
+import { apiFetch } from '../../shared/api/http.js'
+
+describe('history API', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ it('fetchAnalyses calls GET /api/analyses', async () => {
+ const data = [{ id: '1' }, { id: '2' }]
+ apiFetch.mockResolvedValue(data)
+
+ const result = await fetchAnalyses()
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/analyses')
+ expect(result).toEqual(data)
+ })
+
+ it('deleteAnalysis calls DELETE /api/analyses/:id', async () => {
+ apiFetch.mockResolvedValue(null)
+
+ await deleteAnalysis('5')
+
+ expect(apiFetch).toHaveBeenCalledWith('/api/analyses/5', { method: 'DELETE' })
+ })
+})
diff --git a/frontend/src/features/history/store.test.js b/frontend/src/features/history/store.test.js
new file mode 100644
index 0000000..7b9e852
--- /dev/null
+++ b/frontend/src/features/history/store.test.js
@@ -0,0 +1,66 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+import { useHistoryStore } from './store.js'
+
+vi.mock('./api.js', () => ({
+ fetchAnalyses: vi.fn(),
+ deleteAnalysis: vi.fn(),
+}))
+
+import * as api from './api.js'
+
+describe('useHistoryStore', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ })
+
+ it('starts with empty analyses', () => {
+ const store = useHistoryStore()
+ expect(store.analyses).toEqual([])
+ })
+
+ it('load() fetches analyses', async () => {
+ const data = [{ id: '1' }, { id: '2' }]
+ api.fetchAnalyses.mockResolvedValue(data)
+
+ const store = useHistoryStore()
+ await store.load()
+
+ expect(store.analyses).toEqual(data)
+ })
+
+ it('load() handles errors gracefully', async () => {
+ api.fetchAnalyses.mockRejectedValue(new Error('fail'))
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const store = useHistoryStore()
+ await store.load()
+
+ expect(store.analyses).toEqual([])
+ })
+
+ it('remove() deletes and removes from list', async () => {
+ api.deleteAnalysis.mockResolvedValue(null)
+
+ const store = useHistoryStore()
+ store.analyses = [{ id: '1' }, { id: '2' }, { id: '3' }]
+
+ await store.remove('2')
+
+ expect(store.analyses).toEqual([{ id: '1' }, { id: '3' }])
+ })
+
+ it('remove() handles errors gracefully', async () => {
+ api.deleteAnalysis.mockRejectedValue(new Error('fail'))
+ vi.spyOn(console, 'error').mockImplementation(() => {})
+
+ const store = useHistoryStore()
+ store.analyses = [{ id: '1' }]
+
+ await store.remove('1')
+
+ // Should not remove on error
+ expect(store.analyses).toEqual([{ id: '1' }])
+ })
+})
diff --git a/frontend/src/shared/api/http.test.js b/frontend/src/shared/api/http.test.js
new file mode 100644
index 0000000..5935544
--- /dev/null
+++ b/frontend/src/shared/api/http.test.js
@@ -0,0 +1,95 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { apiFetch } from './http.js'
+
+describe('apiFetch', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('sets Content-Type to application/json by default', async () => {
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ data: 1 }),
+ })
+
+ await apiFetch('/api/test')
+
+ expect(spy).toHaveBeenCalledWith('/api/test', expect.objectContaining({
+ headers: expect.objectContaining({ 'Content-Type': 'application/json' }),
+ }))
+ })
+
+ it('skips Content-Type when skipContentType is true', async () => {
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({}),
+ })
+
+ await apiFetch('/api/upload', { skipContentType: true })
+
+ const headers = spy.mock.calls[0][1].headers
+ expect(headers['Content-Type']).toBeUndefined()
+ })
+
+ it('returns parsed JSON on success', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ id: '123', name: 'doc.pdf' }),
+ })
+
+ const result = await apiFetch('/api/docs')
+ expect(result).toEqual({ id: '123', name: 'doc.pdf' })
+ })
+
+ it('returns null on 204 No Content', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 204,
+ })
+
+ const result = await apiFetch('/api/docs/1', { method: 'DELETE' })
+ expect(result).toBeNull()
+ })
+
+ it('throws on non-ok response', async () => {
+ vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: false,
+ status: 404,
+ })
+
+ await expect(apiFetch('/api/docs/missing')).rejects.toThrow('API error: 404')
+ })
+
+ it('forwards method and body options', async () => {
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({}),
+ })
+
+ const body = JSON.stringify({ documentId: '42' })
+ await apiFetch('/api/analyses', { method: 'POST', body })
+
+ expect(spy).toHaveBeenCalledWith('/api/analyses', expect.objectContaining({
+ method: 'POST',
+ body,
+ }))
+ })
+
+ it('merges custom headers with Content-Type', async () => {
+ const spy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({}),
+ })
+
+ await apiFetch('/api/test', { headers: { 'X-Custom': 'value' } })
+
+ const headers = spy.mock.calls[0][1].headers
+ expect(headers['Content-Type']).toBe('application/json')
+ expect(headers['X-Custom']).toBe('value')
+ })
+})
diff --git a/frontend/src/shared/i18n.test.js b/frontend/src/shared/i18n.test.js
new file mode 100644
index 0000000..97c1426
--- /dev/null
+++ b/frontend/src/shared/i18n.test.js
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { setActivePinia, createPinia } from 'pinia'
+
+// Mock settings store before importing i18n
+vi.mock('../features/settings/store.js', () => ({
+ useSettingsStore: vi.fn(),
+}))
+
+import { useSettingsStore } from '../features/settings/store.js'
+import { useI18n } from './i18n.js'
+
+describe('useI18n', () => {
+ beforeEach(() => {
+ setActivePinia(createPinia())
+ vi.clearAllMocks()
+ })
+
+ it('returns French translation by default', () => {
+ useSettingsStore.mockReturnValue({ locale: 'fr' })
+
+ const { t } = useI18n()
+ expect(t('nav.studio')).toBe('Studio')
+ expect(t('nav.history')).toBe('Historique')
+ expect(t('nav.settings')).toBe('Paramètres')
+ })
+
+ it('returns English translation when locale is en', () => {
+ useSettingsStore.mockReturnValue({ locale: 'en' })
+
+ const { t } = useI18n()
+ expect(t('nav.history')).toBe('History')
+ expect(t('nav.settings')).toBe('Settings')
+ })
+
+ it('falls back to French when key missing in current locale', () => {
+ useSettingsStore.mockReturnValue({ locale: 'de' })
+
+ const { t } = useI18n()
+ expect(t('nav.studio')).toBe('Studio')
+ })
+
+ it('returns key when not found in any locale', () => {
+ useSettingsStore.mockReturnValue({ locale: 'fr' })
+
+ const { t } = useI18n()
+ expect(t('unknown.key')).toBe('unknown.key')
+ })
+
+ it('interpolates parameters', () => {
+ useSettingsStore.mockReturnValue({ locale: 'en' })
+
+ const { t } = useI18n()
+ expect(t('results.pageOf', { current: 3, total: 10 })).toBe('Page 3 of 10')
+ })
+
+ it('interpolates parameters in French', () => {
+ useSettingsStore.mockReturnValue({ locale: 'fr' })
+
+ const { t } = useI18n()
+ expect(t('results.pageOf', { current: 1, total: 5 })).toBe('Page 1 sur 5')
+ })
+})