Add a stronger testing strategy

This commit is contained in:
pjmalandrino 2026-03-18 14:46:37 +01:00
parent ac1b68ac18
commit 049ef443cc
16 changed files with 1179 additions and 19 deletions

View file

@ -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:

View file

@ -0,0 +1,4 @@
import pytest
pytest_plugins = ["pytest_asyncio"]

View file

@ -0,0 +1,2 @@
[pytest]
asyncio_mode = auto

View file

View file

@ -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

View file

@ -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="<h1>Title</h1>",
pages_json='[{"page": 1}]',
)
assert job.status == AnalysisStatus.COMPLETED
assert job.content_markdown == "# Title"
assert job.content_html == "<h1>Title</h1>"
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

View file

@ -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="<h1>Test</h1>", 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 == "<h1>Test</h1>"
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

View file

@ -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"

View file

@ -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' })
})
})

View file

@ -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)
})
})

View file

@ -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')
})
})

View file

@ -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')
})
})

View file

@ -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' })
})
})

View file

@ -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' }])
})
})

View file

@ -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')
})
})

View file

@ -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')
})
})