refactor: introduce domain model classes and mapper layer

Replace ad-hoc inline row casting (snake_case → camelCase) spread across
services, routes, and the indexing pipeline with explicit model classes
(Repository, IndexingJob, RepositoryVersion, Snippet, SearchResult) and
dedicated mapper classes that own the DB → domain conversion.

- Add src/lib/server/models/ with typed model classes for all domain entities
- Add src/lib/server/mappers/ with mapper classes per entity
- Remove duplicated RawRow interfaces and inline map functions from
  job-queue, repository.service, indexing.pipeline, and all API routes
- Add dtoJsonResponse helper to standardise JSON responses via SvelteKit json()
- Add api-contract.integration.test.ts as a regression baseline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Giancarmine Salucci
2026-03-25 14:29:49 +01:00
parent 7994254e23
commit 215cadf070
39 changed files with 1339 additions and 562 deletions

View File

@@ -0,0 +1,163 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import Database from 'better-sqlite3';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { RepositoryService } from '$lib/server/services/repository.service';
import { VersionService } from '$lib/server/services/version.service';
let db: Database.Database;
let queue: null = null;
vi.mock('$lib/server/db/client', () => ({
getClient: () => db
}));
vi.mock('$lib/server/db/client.js', () => ({
getClient: () => db
}));
vi.mock('$lib/server/pipeline/startup', () => ({
getQueue: () => queue
}));
vi.mock('$lib/server/pipeline/startup.js', () => ({
getQueue: () => queue
}));
import { POST as postLibraries } from './libs/+server.js';
import { GET as getLibrary } from './libs/[id]/+server.js';
import { GET as getJobs } from './jobs/+server.js';
import { GET as getJob } from './jobs/[id]/+server.js';
import { GET as getVersions, POST as postVersions } from './libs/[id]/versions/+server.js';
function createTestDb(): Database.Database {
const client = new Database(':memory:');
client.pragma('foreign_keys = ON');
const migrationsFolder = join(import.meta.dirname, '../../../lib/server/db/migrations');
const migrationSql = readFileSync(join(migrationsFolder, '0000_large_master_chief.sql'), 'utf-8');
const statements = migrationSql
.split('--> statement-breakpoint')
.map((statement) => statement.trim())
.filter(Boolean);
for (const statement of statements) {
client.exec(statement);
}
return client;
}
describe('API contract integration', () => {
beforeEach(() => {
db = createTestDb();
queue = null;
});
it('POST /api/v1/libs returns repository and job DTOs in camelCase', async () => {
const response = await postLibraries({
request: new Request('http://test/api/v1/libs', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
source: 'github',
sourceUrl: 'https://github.com/facebook/react'
})
}),
url: new URL('http://test/api/v1/libs')
} as never);
expect(response.status).toBe(201);
const body = await response.json();
expect(body.library.sourceUrl).toBe('https://github.com/facebook/react');
expect(body.library.totalSnippets).toBe(0);
expect(body.library.lastIndexedAt).toBeNull();
expect(body.library).not.toHaveProperty('source_url');
expect(body.library).not.toHaveProperty('total_snippets');
expect(body.job.repositoryId).toBe('/facebook/react');
expect(body.job.processedFiles).toBe(0);
expect(body.job).not.toHaveProperty('repository_id');
expect(body.job).not.toHaveProperty('processed_files');
});
it('GET /api/v1/libs/:id returns repository DTO plus version tags', async () => {
const repoService = new RepositoryService(db);
const versionService = new VersionService(db);
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
versionService.add('/facebook/react', 'v18.3.0', 'React v18.3.0');
const response = await getLibrary({
params: { id: encodeURIComponent('/facebook/react') }
} as never);
expect(response.status).toBe(200);
const body = await response.json();
expect(body.sourceUrl).toBe('https://github.com/facebook/react');
expect(body.totalSnippets).toBe(0);
expect(body.versions).toEqual(['v18.3.0']);
expect(body).not.toHaveProperty('source_url');
expect(body).not.toHaveProperty('total_snippets');
});
it('GET /api/v1/jobs and /api/v1/jobs/:id return job DTOs in camelCase', async () => {
const repoService = new RepositoryService(db);
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
const createdJob = repoService.createIndexingJob('/facebook/react');
const listResponse = await getJobs({
url: new URL('http://test/api/v1/jobs?repositoryId=%2Ffacebook%2Freact')
} as never);
const listBody = await listResponse.json();
expect(listBody.jobs).toHaveLength(1);
expect(listBody.jobs[0].repositoryId).toBe('/facebook/react');
expect(listBody.jobs[0].totalFiles).toBe(0);
expect(listBody.jobs[0]).not.toHaveProperty('repository_id');
const itemResponse = await getJob({
params: { id: createdJob.id }
} as never);
const itemBody = await itemResponse.json();
expect(itemBody.job.repositoryId).toBe('/facebook/react');
expect(itemBody.job.processedFiles).toBe(0);
expect(itemBody.job).not.toHaveProperty('processed_files');
});
it('GET and POST /api/v1/libs/:id/versions return version and job DTOs in camelCase', async () => {
const repoService = new RepositoryService(db);
repoService.add({ source: 'github', sourceUrl: 'https://github.com/facebook/react' });
const postResponse = await postVersions({
params: { id: encodeURIComponent('/facebook/react') },
request: new Request('http://test/api/v1/libs/%2Ffacebook%2Freact/versions', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ tag: 'v18.3.0', title: 'React v18.3.0', autoIndex: true })
})
} as never);
const postBody = await postResponse.json();
expect(postResponse.status).toBe(201);
expect(postBody.version.repositoryId).toBe('/facebook/react');
expect(postBody.version.totalSnippets).toBe(0);
expect(postBody.version).not.toHaveProperty('repository_id');
expect(postBody.job.repositoryId).toBe('/facebook/react');
expect(postBody.job).not.toHaveProperty('repository_id');
const getResponse = await getVersions({
params: { id: encodeURIComponent('/facebook/react') }
} as never);
const getBody = await getResponse.json();
expect(getBody.versions).toHaveLength(1);
expect(getBody.versions[0].repositoryId).toBe('/facebook/react');
expect(getBody.versions[0].totalSnippets).toBe(0);
expect(getBody.versions[0]).not.toHaveProperty('repository_id');
expect(getBody.versions[0]).not.toHaveProperty('total_snippets');
});
});