From 049ef443cc40bc9049f4eb07223e30973bc3d116 Mon Sep 17 00:00:00 2001 From: pjmalandrino Date: Wed, 18 Mar 2026 14:46:37 +0100 Subject: [PATCH] Add a stronger testing strategy --- docker-compose.dev.yml | 19 -- document-parser/conftest.py | 4 + document-parser/pytest.ini | 2 + document-parser/tests/__init__.py | 0 document-parser/tests/test_api_endpoints.py | 178 +++++++++++++++++++ document-parser/tests/test_models.py | 108 +++++++++++ document-parser/tests/test_repos.py | 157 ++++++++++++++++ document-parser/tests/test_schemas.py | 85 +++++++++ frontend/src/features/analysis/api.test.js | 55 ++++++ frontend/src/features/analysis/store.test.js | 155 ++++++++++++++++ frontend/src/features/document/api.test.js | 65 +++++++ frontend/src/features/document/store.test.js | 115 ++++++++++++ frontend/src/features/history/api.test.js | 32 ++++ frontend/src/features/history/store.test.js | 66 +++++++ frontend/src/shared/api/http.test.js | 95 ++++++++++ frontend/src/shared/i18n.test.js | 62 +++++++ 16 files changed, 1179 insertions(+), 19 deletions(-) delete mode 100644 docker-compose.dev.yml create mode 100644 document-parser/conftest.py create mode 100644 document-parser/pytest.ini create mode 100644 document-parser/tests/__init__.py create mode 100644 document-parser/tests/test_api_endpoints.py create mode 100644 document-parser/tests/test_models.py create mode 100644 document-parser/tests/test_repos.py create mode 100644 document-parser/tests/test_schemas.py create mode 100644 frontend/src/features/analysis/api.test.js create mode 100644 frontend/src/features/analysis/store.test.js create mode 100644 frontend/src/features/document/api.test.js create mode 100644 frontend/src/features/document/store.test.js create mode 100644 frontend/src/features/history/api.test.js create mode 100644 frontend/src/features/history/store.test.js create mode 100644 frontend/src/shared/api/http.test.js create mode 100644 frontend/src/shared/i18n.test.js 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') + }) +})