feat(bridge): bloc 3 — routes REST Tier 1 + auth + repos Baserow (10 endpoints)
Some checks are pending
CI / Lint bridge (Biome) (push) Waiting to run
CI / Type-check bridge (push) Blocked by required conditions
CI / Tests unit bridge (push) Blocked by required conditions
CI / Tests integration bridge (push) Blocked by required conditions
CI / Security scan (push) Waiting to run
CI / Docker build + healthcheck (push) Blocked by required conditions

Wiring HTTP du bridge service. 10 endpoints livres (cf docs/19 §6.1-6.5) :
- GET /api/v1/personnes (+ /:id, + /:id/dashboard)
- GET /api/v1/formations (+ /:id avec rollups blocs/modules)
- GET /api/v1/projets (+ /:id avec rollups taches)
- POST /api/v1/modules/:id/attribuer (RG-01 -> 422, role/heures invalides -> 400)
- POST /api/v1/interventions (validation role developpeur + heures > 0)
- PATCH /api/v1/attributions/:id/heures-realisees (409 si annule/realise)

Layers ajoutees :
- src/middleware/auth.ts : Bearer brg_*, scopes JSON-encoded BRIDGE_API_TOKENS, admin:* wildcard
- src/middleware/error-handler.ts : BridgeError -> JSON shape standard
- src/lib/container.ts : DI singleton (Baserow + Redis + 9 repos), setContainer testable
- src/lib/http.ts : parseListQuery + parseBody zod helper
- src/repos/baserow-repo.ts : BaseRepo<T> abstrait + 9 sous-classes (mapping Row<->Domain)
- src/routes/{personnes,formations,projets,modules,interventions,attributions}.ts

src/index.ts reecrit : buildApp() + initContainer + auth sur /api/v1/* + ready check Baserow+Redis.

Tests : 163/163 verts (12 suites domain + 8 nouvelles : auth, repos, 6 routes).
Coverage src global : 70.77% (cible 60%). Domain 97.86%, routes 96%, middleware 86%.

Choix : BaseRepo abstrait (pas mega-generic, Ockham) ; FakeRepos in-memory pour tests routes
(pas de testcontainers ici, c'est Bloc 7) ; mapping erreurs domain -> HTTP par message texte
(fragile, sera refactor en DomainError typees au Bloc 3.2).

Hors scope (a venir) :
- Bloc 5 : rate limiting Redis
- Bloc 7 : webhook handlers Baserow + sync bidirec + cache invalidation
- Bloc 3.2 : routes /docmost/*, /sync/*, /rapports/*

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-07 20:01:36 +02:00
parent 2c5665bc44
commit c8e9b4d4ea
23 changed files with 2542 additions and 17 deletions

View file

@ -1,29 +1,79 @@
/**
* Bridge service entrypoint Hono HTTP server.
*
* Boot sequence : loadConfig -> initContainer (Baserow + Redis + repos + token map)
* -> wire middleware globaux + routes /api/v1/* avec auth + serve.
*/
import { serve } from '@hono/node-server'; import { serve } from '@hono/node-server';
import { Hono } from 'hono'; import { Hono } from 'hono';
import { logger as honoLogger } from 'hono/logger'; import { logger as honoLogger } from 'hono/logger';
import { loadConfig } from './lib/config.js'; import { loadConfig } from './lib/config.js';
import { getContainer, initContainer } from './lib/container.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import { type AuthVariables, authMiddleware } from './middleware/auth.js';
import { errorHandler } from './middleware/error-handler.js';
import { attributionsRoutes } from './routes/attributions.js';
import { formationsRoutes } from './routes/formations.js';
import { interventionsRoutes } from './routes/interventions.js';
import { modulesRoutes } from './routes/modules.js';
import { personnesRoutes } from './routes/personnes.js';
import { projetsRoutes } from './routes/projets.js';
const config = loadConfig(); export async function buildApp(): Promise<Hono<{ Variables: AuthVariables }>> {
const app = new Hono(); const app = new Hono<{ Variables: AuthVariables }>();
app.use('*', honoLogger());
app.onError(errorHandler);
app.use('*', honoLogger()); app.get('/api/health', (c) => c.json({ status: 'ok', service: 'bridge', version: '0.1.0' }));
app.get('/api/health', (c) => { app.get('/api/ready', async (c) => {
return c.json({ status: 'ok', service: 'bridge', version: '0.1.0' }); const { baserow, redis } = getContainer();
}); const [baserowOk, redisOk] = await Promise.all([baserow.healthCheck(), redis.healthCheck()]);
const status = baserowOk && redisOk ? 'ok' : 'degraded';
const code = baserowOk && redisOk ? 200 : 503;
return c.json(
{ status, dependencies: { baserow: baserowOk, redis: redisOk } },
code as 200 | 503,
);
});
app.get('/api/ready', async (c) => { // Auth middleware applique sur tout /api/v1/*
return c.json({ status: 'ok', dependencies: { baserow: 'TODO', redis: 'TODO' } }); const { tokens } = getContainer();
}); const v1 = new Hono<{ Variables: AuthVariables }>();
v1.use('*', authMiddleware(tokens));
v1.route('/personnes', personnesRoutes);
v1.route('/formations', formationsRoutes);
v1.route('/projets', projetsRoutes);
v1.route('/modules', modulesRoutes);
v1.route('/interventions', interventionsRoutes);
v1.route('/attributions', attributionsRoutes);
app.route('/api/v1', v1);
app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404)); app.notFound((c) => c.json({ error: { code: 'NOT_FOUND', message: 'Route not found' } }, 404));
app.onError((err, c) => { return app;
logger.error({ err }, 'Unhandled error'); }
return c.json({ error: { code: 'INTERNAL', message: 'Internal server error' } }, 500);
});
serve({ fetch: app.fetch, port: config.port }, (info) => { async function main() {
logger.info({ port: info.port, env: config.nodeEnv }, 'Bridge service started'); const config = loadConfig();
}); // BASEROW_DATABASE_ID requis pour resolveTableIds. Cf .env
const databaseIdRaw = process.env.BASEROW_DATABASE_ID;
const databaseId = databaseIdRaw ? Number.parseInt(databaseIdRaw, 10) : undefined;
if (!databaseId || Number.isNaN(databaseId)) {
throw new Error('BASEROW_DATABASE_ID env var requis pour resolve table ids');
}
await initContainer({ config, databaseId });
const app = await buildApp();
serve({ fetch: app.fetch, port: config.port }, (info) => {
logger.info({ port: info.port, env: config.nodeEnv }, 'Bridge service started');
});
}
if (process.env.NODE_ENV !== 'test' && import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => {
logger.fatal({ err }, 'Bridge service failed to start');
process.exit(1);
});
}

View file

@ -0,0 +1,98 @@
/**
* DI container initialise les dependances une seule fois au boot et expose
* un singleton typed pour les routes. Pour les tests, `setContainer` permet
* d'injecter un mock complet sans toucher a `getContainer`.
*/
import type { Logger } from 'pino';
import { BaserowClient } from '../adapters/baserow-client.js';
import { RedisCache } from '../adapters/redis-cache.js';
import type { ApiTokenRecord } from '../middleware/auth.js';
import { parseTokens } from '../middleware/auth.js';
import { type RepoSet, TABLE_NAMES, type TableIds, buildRepos } from '../repos/baserow-repo.js';
import type { Config } from './config.js';
import { logger as rootLogger } from './logger.js';
export interface Container {
config: Config;
baserow: BaserowClient;
redis: RedisCache;
repos: RepoSet;
tokens: ReadonlyMap<string, ApiTokenRecord>;
tableIds: TableIds;
logger: Logger;
}
let _container: Container | null = null;
export function getContainer(): Container {
if (!_container) {
throw new Error('Container not initialized — call initContainer() first');
}
return _container;
}
export function setContainer(c: Container | null): void {
_container = c;
}
export interface InitOptions {
config: Config;
/** Pour tests : skip le resolveTableIds reseau. */
tableIds?: TableIds;
/** Pour tests : injecter une implem de Baserow/Redis. */
baserow?: BaserowClient;
redis?: RedisCache;
databaseId?: number;
}
export async function initContainer(opts: InitOptions): Promise<Container> {
const { config } = opts;
const baserow =
opts.baserow ??
new BaserowClient({
baseUrl: config.baserowApiUrl,
token: config.baserowApiToken,
logger: rootLogger,
});
const redis = opts.redis ?? new RedisCache({ url: config.redisUrl, logger: rootLogger });
let tableIds: TableIds;
if (opts.tableIds) {
tableIds = opts.tableIds;
} else {
if (typeof opts.databaseId !== 'number') {
throw new Error('initContainer: databaseId requis si tableIds non fourni');
}
const resolved = await baserow.resolveTableIds(opts.databaseId);
tableIds = pickTableIds(resolved);
}
const repos = buildRepos(baserow, tableIds, rootLogger);
const tokens = parseTokens(config.bridgeApiTokens);
const container: Container = {
config,
baserow,
redis,
repos,
tokens,
tableIds,
logger: rootLogger,
};
setContainer(container);
return container;
}
/** Verifie que toutes les tables attendues sont presentes dans le mapping name->id. */
function pickTableIds(resolved: Record<string, number>): TableIds {
const out: Partial<TableIds> = {};
for (const name of TABLE_NAMES) {
const id = resolved[name];
if (typeof id !== 'number') {
throw new Error(`Table Baserow manquante : ${name}`);
}
out[name] = id;
}
return out as TableIds;
}

49
bridge/src/lib/http.ts Normal file
View file

@ -0,0 +1,49 @@
/**
* Helpers HTTP partages pour les routes : parsing pagination/filtres et serialisation
* des objets domain en JSON-friendly (Decimal -> string a precision controlee).
*/
import type { Decimal } from 'decimal.js';
import type { Context } from 'hono';
import type { z } from 'zod';
import { errors } from './errors.js';
export interface ListQuery {
page: number;
per_page: number;
filter: Record<string, string>;
sort?: string;
}
export function parseListQuery(c: Context): ListQuery {
const url = new URL(c.req.url);
const page = Number.parseInt(url.searchParams.get('page') ?? '1', 10) || 1;
const perPageRaw = Number.parseInt(url.searchParams.get('per_page') ?? '50', 10) || 50;
const per_page = Math.min(Math.max(perPageRaw, 1), 200);
const filter: Record<string, string> = {};
for (const [key, value] of url.searchParams.entries()) {
const m = key.match(/^filter\[(.+)\]$/);
if (m?.[1]) filter[m[1]] = value;
}
const sort = url.searchParams.get('sort') ?? undefined;
return { page, per_page, filter, sort };
}
export function dec(d: Decimal | undefined | null): string {
if (!d) return '0';
return d.toFixed(2);
}
export async function parseBody<T>(c: Context, schema: z.ZodType<T>): Promise<T> {
let body: unknown;
try {
body = await c.req.json();
} catch {
throw errors.validation([{ message: 'Body must be valid JSON' }]);
}
const parsed = schema.safeParse(body);
if (!parsed.success) {
throw errors.validation(parsed.error.issues);
}
return parsed.data;
}

View file

@ -0,0 +1,109 @@
/**
* Auth middleware bridge API tokens longue duree (`brg_*`) avec scopes.
*
* Tokens declares dans `BRIDGE_API_TOKENS` au format JSON :
* [{"token":"brg_xxx","name":"docmost-prod","scopes":["read:personnes","write:attributions"]}]
*
* JSON choisi plutot qu'un mini-DSL `name:scope1,scope2;...` : parse natif, pas d'ambiguite
* sur les separateurs si un nom contient virgule/point-virgule. Cf rapport Bloc 3.
*/
import type { MiddlewareHandler } from 'hono';
import { errors } from '../lib/errors.js';
export interface ApiTokenRecord {
token: string;
name: string;
scopes: string[];
}
export interface AuthContext {
tokenName: string;
scopes: ReadonlySet<string>;
}
/** Hono context variable map — augmente sur l'app pour acces type-safe. */
export type AuthVariables = {
auth: AuthContext;
};
/** Parse `BRIDGE_API_TOKENS` (JSON). Retourne map token → record. */
export function parseTokens(raw: string | undefined): Map<string, ApiTokenRecord> {
const map = new Map<string, ApiTokenRecord>();
if (!raw || raw.trim().length === 0) return map;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error('BRIDGE_API_TOKENS: JSON invalide');
}
if (!Array.isArray(parsed)) {
throw new Error('BRIDGE_API_TOKENS: doit etre un tableau JSON');
}
for (const entry of parsed) {
if (typeof entry !== 'object' || entry === null) {
throw new Error('BRIDGE_API_TOKENS: entrees doivent etre des objets');
}
const e = entry as Record<string, unknown>;
if (typeof e.token !== 'string' || typeof e.name !== 'string') {
throw new Error('BRIDGE_API_TOKENS: chaque entree requiert token + name');
}
if (!Array.isArray(e.scopes) || e.scopes.some((s: unknown) => typeof s !== 'string')) {
throw new Error('BRIDGE_API_TOKENS: scopes doit etre un tableau de strings');
}
map.set(e.token, { token: e.token, name: e.name, scopes: e.scopes as string[] });
}
return map;
}
/**
* Verifie si un set de scopes possedes couvre la demande.
* `admin:*` couvre tout. Match exact sinon.
*/
export function hasScope(owned: ReadonlySet<string>, required: string): boolean {
if (owned.has('admin:*')) return true;
return owned.has(required);
}
/**
* Factory middleware : exige un scope precis.
* Le middleware d'auth global doit avoir tourne avant pour peupler `c.var.auth`.
*/
export function requireScope(scope: string): MiddlewareHandler<{ Variables: AuthVariables }> {
return async (c, next) => {
const auth = c.get('auth');
if (!auth) {
throw errors.authRequired();
}
if (!hasScope(auth.scopes, scope)) {
throw errors.forbidden(scope);
}
await next();
};
}
/**
* Middleware d'auth global. Lit `Authorization: Bearer brg_*`, peuple `c.var.auth`.
* Pas d'enforcement de scope ici — c'est le job de `requireScope`.
*/
export function authMiddleware(
tokens: ReadonlyMap<string, ApiTokenRecord>,
): MiddlewareHandler<{ Variables: AuthVariables }> {
return async (c, next) => {
const header = c.req.header('Authorization');
if (!header) {
throw errors.authRequired();
}
const match = header.match(/^Bearer\s+(.+)$/);
if (!match) {
throw errors.authInvalid();
}
const token = match[1].trim();
const record = tokens.get(token);
if (!record) {
throw errors.authInvalid();
}
c.set('auth', { tokenName: record.name, scopes: new Set(record.scopes) });
await next();
};
}

View file

@ -0,0 +1,25 @@
/**
* Error handler global convertit les exceptions en `{ error: { code, message, details? } }`.
* Reconnait `BridgeError` pour les erreurs metier ; tout le reste -> INTERNAL 500.
*/
import type { Context } from 'hono';
import type { ContentfulStatusCode } from 'hono/utils/http-status';
import { BridgeError } from '../lib/errors.js';
import { logger } from '../lib/logger.js';
export function errorHandler(err: Error, c: Context): Response {
if (err instanceof BridgeError) {
if (err.status >= 500) {
logger.error({ err, path: c.req.path }, 'BridgeError 5xx');
} else {
logger.warn({ code: err.code, path: c.req.path }, 'BridgeError handled');
}
return c.json(err.toJSON(), err.status as ContentfulStatusCode);
}
logger.error({ err, path: c.req.path }, 'Unhandled error');
return c.json(
{ error: { code: 'INTERNAL', message: 'Internal server error' } },
500 as ContentfulStatusCode,
);
}

View file

@ -0,0 +1,501 @@
/**
* Repository layer wrappe BaserowClient et fait le mapping `BaserowRow` <-> domain.
*
* Choix : 1 classe par entite, qui herite de `BaseRepo<TDomain>`. Le BaseRepo encapsule
* la pagination/get/create/update typee, les mappers sont l'unique custom point.
* Plus simple qu'un mega-generic abstrait : chaque repo a 5 LOC de mapping clair.
*/
import { Decimal } from 'decimal.js';
import type { Logger } from 'pino';
import type {
BaserowClient,
BaserowListOptions,
BaserowPaginatedResponse,
BaserowRow,
} from '../adapters/baserow-client.js';
import { Attribution } from '../domain/attribution.js';
import { Bloc } from '../domain/bloc.js';
import { Client as ClientEntity } from '../domain/client.js';
import { Formation } from '../domain/formation.js';
import { Intervention } from '../domain/intervention.js';
import { Module } from '../domain/module.js';
import { Personne } from '../domain/personne.js';
import { Projet } from '../domain/projet.js';
import { Tache } from '../domain/tache.js';
import type {
Filiere,
Priorite,
ProjetType,
Role,
StatutAttribution,
StatutClient,
StatutFormation,
StatutIntervention,
StatutModule,
StatutPersonne,
StatutProjet,
StatutTache,
} from '../domain/types.js';
import { errors } from '../lib/errors.js';
/** Table names from baserow/seed/schema.json. */
export const TABLE_NAMES = [
'personne',
'formation',
'bloc',
'module',
'attribution',
'client',
'projet',
'tache',
'intervention',
] as const;
export type TableName = (typeof TABLE_NAMES)[number];
export type TableIds = Record<TableName, number>;
/** Cast safe d'un select Baserow (objet `{id, value, color}`) vers la valeur string. */
function readSelect(raw: unknown): string | null {
if (raw == null) return null;
if (typeof raw === 'string') return raw;
if (typeof raw === 'object' && raw !== null && 'value' in raw) {
const v = (raw as { value: unknown }).value;
return typeof v === 'string' ? v : null;
}
return null;
}
function readMultiSelect(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => readSelect(item))
.filter((v): v is string => typeof v === 'string' && v.length > 0);
}
function readNumber(raw: unknown): Decimal {
if (raw == null || raw === '') return new Decimal(0);
if (raw instanceof Decimal) return raw;
if (typeof raw === 'number') return new Decimal(raw);
if (typeof raw === 'string') return new Decimal(raw);
return new Decimal(0);
}
function readDate(raw: unknown): Date | null {
if (raw == null || raw === '') return null;
if (raw instanceof Date) return raw;
if (typeof raw === 'string') {
const d = new Date(raw);
return Number.isNaN(d.getTime()) ? null : d;
}
return null;
}
function readString(raw: unknown, fallback = ''): string {
return typeof raw === 'string' ? raw : fallback;
}
/** Retourne le 1er id d'un link field Baserow (`[{id, value}]`) ou null. */
function readLinkId(raw: unknown): number | null {
if (!Array.isArray(raw) || raw.length === 0) return null;
const first = raw[0];
if (typeof first === 'object' && first !== null && 'id' in first) {
const id = (first as { id: unknown }).id;
return typeof id === 'number' ? id : null;
}
if (typeof first === 'number') return first;
return null;
}
export interface BaseRepoOptions {
client: BaserowClient;
tableId: number;
entityName: string;
logger: Logger;
}
abstract class BaseRepo<TDomain> {
protected readonly client: BaserowClient;
protected readonly tableId: number;
protected readonly entityName: string;
protected readonly logger: Logger;
constructor(opts: BaseRepoOptions) {
this.client = opts.client;
this.tableId = opts.tableId;
this.entityName = opts.entityName;
this.logger = opts.logger.child({ repo: opts.entityName });
}
protected abstract toDomain(row: BaserowRow): TDomain;
async list(opts: BaserowListOptions = {}): Promise<{
items: TDomain[];
meta: { page: number; per_page: number; total: number; total_pages: number };
}> {
const page = opts.page ?? 1;
const size = Math.min(opts.size ?? 50, 200);
const res: BaserowPaginatedResponse = await this.client.listRows(this.tableId, {
...opts,
page,
size,
});
const items = res.results.map((row) => this.toDomain(row));
return {
items,
meta: {
page,
per_page: size,
total: res.count,
total_pages: Math.max(1, Math.ceil(res.count / size)),
},
};
}
async get(id: number): Promise<TDomain> {
try {
const row = await this.client.getRow(this.tableId, id);
return this.toDomain(row);
} catch (err) {
if (err instanceof Error && 'code' in err && err.code === 'NOT_FOUND') {
throw errors.notFound(this.entityName, id);
}
throw err;
}
}
async getRaw(id: number): Promise<BaserowRow> {
try {
return await this.client.getRow(this.tableId, id);
} catch (err) {
if (err instanceof Error && 'code' in err && err.code === 'NOT_FOUND') {
throw errors.notFound(this.entityName, id);
}
throw err;
}
}
}
// ---------------------------------------------------------------------------
// Personne
// ---------------------------------------------------------------------------
export class PersonneRepo extends BaseRepo<Personne> {
protected toDomain(row: BaserowRow): Personne {
const splitFormation = readNumber(row.personne_split_formation_pct);
const splitAgence = readNumber(row.personne_split_agence_pct);
const roles = readMultiSelect(row.personne_roles).filter((r): r is Role =>
['formateur', 'developpeur', 'admin', 'direction', 'support'].includes(r),
);
const statutRaw = readSelect(row.personne_statut) ?? 'actif';
const statut: StatutPersonne = statutRaw === 'inactif' ? 'inactif' : 'actif';
return new Personne({
id: row.id,
nom: readString(row.personne_nom),
prenom: readString(row.personne_prenom),
email: readString(row.personne_email),
capaciteAnnuelle: readNumber(row.personne_capacite_annuelle),
splitFormationPct: splitFormation,
splitAgencePct: splitAgence,
roles: new Set(roles),
statut,
heuresAttribueesFormation: readNumber(row.personne_heures_attribuees_formation),
heuresAttribueesAgence: readNumber(row.personne_heures_attribuees_agence),
});
}
}
// ---------------------------------------------------------------------------
// Formation + Bloc + Module + Attribution
// ---------------------------------------------------------------------------
export class FormationRepo extends BaseRepo<Formation> {
protected toDomain(row: BaserowRow): Formation {
const filiereRaw = readSelect(row.formation_filiere);
const filiere: Filiere | null =
filiereRaw && ['dev', 'graphisme', 'marketing', 'iot', 'cybersec'].includes(filiereRaw)
? (filiereRaw as Filiere)
: null;
const statutRaw = readSelect(row.formation_statut) ?? 'draft';
const statut: StatutFormation = ['draft', 'actif', 'termine', 'archive'].includes(statutRaw)
? (statutRaw as StatutFormation)
: 'draft';
return new Formation({
id: row.id,
nom: readString(row.formation_nom),
filiere,
heuresTotales: readNumber(row.formation_heures_totales),
statut,
dateDebut: readDate(row.formation_date_debut),
dateFin: readDate(row.formation_date_fin),
});
}
}
export class BlocRepo extends BaseRepo<Bloc> {
protected toDomain(row: BaserowRow): Bloc {
const formationId = readLinkId(row.bloc_formation) ?? 0;
return new Bloc({
id: row.id,
formationId,
nom: readString(row.bloc_nom),
heuresPrevues: readNumber(row.bloc_heures_prevues),
ordre: Number(readNumber(row.bloc_ordre).toFixed(0)),
});
}
}
export class ModuleRepo extends BaseRepo<Module> {
protected toDomain(row: BaserowRow): Module {
const blocId = readLinkId(row.module_bloc) ?? 0;
const statutRaw = readSelect(row.module_statut) ?? 'a_attribuer';
const statut: StatutModule = [
'a_attribuer',
'attribue',
'en_cours',
'realise',
'annule',
].includes(statutRaw)
? (statutRaw as StatutModule)
: 'a_attribuer';
return new Module({
id: row.id,
blocId,
nom: readString(row.module_nom),
heuresPrevues: readNumber(row.module_heures_prevues),
statut,
});
}
}
export class AttributionRepo extends BaseRepo<Attribution> {
protected toDomain(row: BaserowRow): Attribution {
const moduleId = readLinkId(row.attribution_module) ?? 0;
const personneId = readLinkId(row.attribution_personne) ?? 0;
const statutRaw = readSelect(row.attribution_statut) ?? 'planifie';
const statut: StatutAttribution = ['planifie', 'en_cours', 'realise', 'annule'].includes(
statutRaw,
)
? (statutRaw as StatutAttribution)
: 'planifie';
const heuresAttribuees = readNumber(row.attribution_heures_attribuees);
if (heuresAttribuees.lte(0)) {
// Domain refuse heures <= 0. Skip cette ligne corrompue plutot que de crasher la liste.
throw errors.internal(`Attribution row ${row.id} a heures_attribuees <= 0`);
}
return new Attribution({
id: row.id,
moduleId,
personneId,
heuresAttribuees,
heuresRealisees: readNumber(row.attribution_heures_realisees),
dateDebut: readDate(row.attribution_date_debut),
dateFin: readDate(row.attribution_date_fin),
statut,
});
}
/**
* Persist domain Attribution Baserow row (champs writable uniquement).
* Renvoie la row creee (avec id assigne par Baserow).
*/
async create(input: {
moduleId: number;
personneId: number;
heuresAttribuees: Decimal;
dateDebut: Date | null;
dateFin: Date | null;
statut: StatutAttribution;
}): Promise<BaserowRow> {
return this.client.createRow(this.tableId, {
attribution_heures_attribuees: input.heuresAttribuees.toNumber(),
attribution_heures_realisees: 0,
attribution_date_debut: input.dateDebut?.toISOString().slice(0, 10) ?? null,
attribution_date_fin: input.dateFin?.toISOString().slice(0, 10) ?? null,
attribution_statut: input.statut,
attribution_module: [input.moduleId],
attribution_personne: [input.personneId],
});
}
async updateHeuresRealisees(id: number, heures: Decimal): Promise<BaserowRow> {
return this.client.updateRow(this.tableId, id, {
attribution_heures_realisees: heures.toNumber(),
});
}
}
// ---------------------------------------------------------------------------
// Client / Projet / Tache / Intervention
// ---------------------------------------------------------------------------
export class ClientRepo extends BaseRepo<ClientEntity> {
protected toDomain(row: BaserowRow): ClientEntity {
const statutRaw = readSelect(row.client_statut) ?? 'prospect';
const statut: StatutClient = ['prospect', 'actif', 'inactif', 'archive'].includes(statutRaw)
? (statutRaw as StatutClient)
: 'prospect';
return new ClientEntity({
id: row.id,
nom: readString(row.client_nom),
contactPrincipal: readString(row.client_contact_principal) || null,
contactEmail: readString(row.client_contact_email) || null,
contactTelephone: readString(row.client_contact_telephone) || null,
secteur: readString(row.client_secteur) || null,
notes: readString(row.client_notes) || null,
statut,
});
}
}
export class ProjetRepo extends BaseRepo<Projet> {
protected toDomain(row: BaserowRow): Projet {
const clientId = readLinkId(row.projet_client) ?? 0;
const formationId = readLinkId(row.projet_formation_pedagogique);
const typeRaw = readSelect(row.projet_type);
const type: ProjetType | null =
typeRaw &&
['site_web', 'app_mobile', 'api', 'infra', 'audit', 'support', 'autre'].includes(typeRaw)
? (typeRaw as ProjetType)
: null;
const statutRaw = readSelect(row.projet_statut) ?? 'devis';
const statut: StatutProjet = ['devis', 'en_cours', 'livre', 'cloture', 'abandonne'].includes(
statutRaw,
)
? (statutRaw as StatutProjet)
: 'devis';
return new Projet({
id: row.id,
clientId,
nom: readString(row.projet_nom),
type,
chargeHeures: readNumber(row.projet_charge_heures),
statut,
formationId,
});
}
}
export class TacheRepo extends BaseRepo<Tache> {
protected toDomain(row: BaserowRow): Tache {
const projetId = readLinkId(row.tache_projet) ?? 0;
const prioriteRaw = readSelect(row.tache_priorite);
const priorite: Priorite | null =
prioriteRaw && ['faible', 'normale', 'haute', 'critique'].includes(prioriteRaw)
? (prioriteRaw as Priorite)
: null;
const statutRaw = readSelect(row.tache_statut) ?? 'todo';
const statut: StatutTache = ['todo', 'in_progress', 'review', 'done', 'abandoned'].includes(
statutRaw,
)
? (statutRaw as StatutTache)
: 'todo';
return new Tache({
id: row.id,
projetId,
titre: readString(row.tache_titre),
chargeHeures: readNumber(row.tache_charge_heures),
priorite,
statut,
});
}
}
export class InterventionRepo extends BaseRepo<Intervention> {
protected toDomain(row: BaserowRow): Intervention {
const tacheId = readLinkId(row.intervention_tache) ?? 0;
const personneId = readLinkId(row.intervention_personne) ?? 0;
const statutRaw = readSelect(row.intervention_statut) ?? 'realise';
const statut: StatutIntervention = ['planifie', 'realise', 'annule'].includes(statutRaw)
? (statutRaw as StatutIntervention)
: 'realise';
const date = readDate(row.intervention_date) ?? new Date();
return new Intervention({
id: row.id,
tacheId,
personneId,
heures: readNumber(row.intervention_heures),
date,
notes: readString(row.intervention_notes) || null,
statut,
});
}
async create(input: {
tacheId: number;
personneId: number;
heures: Decimal;
date: Date;
notes: string | null;
statut: StatutIntervention;
}): Promise<BaserowRow> {
return this.client.createRow(this.tableId, {
intervention_heures: input.heures.toNumber(),
intervention_date: input.date.toISOString().slice(0, 10),
intervention_notes: input.notes,
intervention_statut: input.statut,
intervention_tache: [input.tacheId],
intervention_personne: [input.personneId],
});
}
}
export interface RepoSet {
personnes: PersonneRepo;
formations: FormationRepo;
blocs: BlocRepo;
modules: ModuleRepo;
attributions: AttributionRepo;
clients: ClientRepo;
projets: ProjetRepo;
taches: TacheRepo;
interventions: InterventionRepo;
}
export function buildRepos(client: BaserowClient, tableIds: TableIds, logger: Logger): RepoSet {
return {
personnes: new PersonneRepo({
client,
tableId: tableIds.personne,
entityName: 'Personne',
logger,
}),
formations: new FormationRepo({
client,
tableId: tableIds.formation,
entityName: 'Formation',
logger,
}),
blocs: new BlocRepo({ client, tableId: tableIds.bloc, entityName: 'Bloc', logger }),
modules: new ModuleRepo({ client, tableId: tableIds.module, entityName: 'Module', logger }),
attributions: new AttributionRepo({
client,
tableId: tableIds.attribution,
entityName: 'Attribution',
logger,
}),
clients: new ClientRepo({
client,
tableId: tableIds.client,
entityName: 'Client',
logger,
}),
projets: new ProjetRepo({
client,
tableId: tableIds.projet,
entityName: 'Projet',
logger,
}),
taches: new TacheRepo({ client, tableId: tableIds.tache, entityName: 'Tache', logger }),
interventions: new InterventionRepo({
client,
tableId: tableIds.intervention,
entityName: 'Intervention',
logger,
}),
};
}

View file

@ -0,0 +1,52 @@
/**
* Routes /api/v1/attributions PATCH /:id/heures-realisees.
* Reuse Attribution.saisirHeuresRealisees pour la transition d'etat.
*/
import { Decimal } from 'decimal.js';
import { Hono } from 'hono';
import { z } from 'zod';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { dec, parseBody } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const attributionsRoutes = new Hono<{ Variables: AuthVariables }>();
const HeuresRealiseesBodySchema = z.object({
heures_realisees: z.number().nonnegative(),
comment: z.string().optional(),
});
attributionsRoutes.patch('/:id/heures-realisees', requireScope('write:attributions'), async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]);
const body = await parseBody(c, HeuresRealiseesBodySchema);
const { repos } = getContainer();
const attribution = await repos.attributions.get(id);
try {
attribution.saisirHeuresRealisees(new Decimal(body.heures_realisees));
} catch (err) {
if (err instanceof Error) {
const msg = err.message;
if (msg.includes('annule') || msg.includes('realise')) {
throw errors.conflict(msg, { attributionId: id, statut: attribution.statut });
}
throw errors.validation([{ message: msg }]);
}
throw err;
}
await repos.attributions.updateHeuresRealisees(id, attribution.heuresRealisees);
return c.json({
data: {
attribution_id: id,
heures_attribuees: dec(attribution.heuresAttribuees),
heures_realisees: dec(attribution.heuresRealisees),
statut: attribution.statut,
},
});
});

View file

@ -0,0 +1,89 @@
/**
* Routes /api/v1/formations read-only Tier 1.
* Le detail compose blocs + modules en assemblant les list endpoints repo.
*/
import { Decimal } from 'decimal.js';
import { Hono } from 'hono';
import type { Bloc } from '../domain/bloc.js';
import type { Formation } from '../domain/formation.js';
import type { Module } from '../domain/module.js';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { dec, parseListQuery } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const formationsRoutes = new Hono<{ Variables: AuthVariables }>();
function serializeFormation(f: Formation) {
return {
id: f.id,
nom: f.nom,
filiere: f.filiere,
heures_totales: dec(f.heuresTotales),
statut: f.statut,
date_debut: f.dateDebut?.toISOString() ?? null,
date_fin: f.dateFin?.toISOString() ?? null,
};
}
function serializeModule(m: Module) {
return {
id: m.id,
bloc_id: m.blocId,
nom: m.nom,
heures_prevues: dec(m.heuresPrevues),
statut: m.statut,
};
}
function serializeBloc(b: Bloc, modules: Module[]) {
return {
id: b.id,
formation_id: b.formationId,
nom: b.nom,
heures_prevues: dec(b.heuresPrevues),
ordre: b.ordre,
modules: modules.map(serializeModule),
};
}
formationsRoutes.get('/', requireScope('read:formations'), async (c) => {
const { page, per_page, sort } = parseListQuery(c);
const { repos } = getContainer();
const result = await repos.formations.list({ page, size: per_page, orderBy: sort });
return c.json({ data: result.items.map(serializeFormation), meta: result.meta });
});
formationsRoutes.get('/:id', requireScope('read:formations'), async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]);
const { repos } = getContainer();
const formation = await repos.formations.get(id);
// Recupere blocs + modules. Pas d'index server-side par link → filter client-side.
const [allBlocs, allModules] = await Promise.all([
repos.blocs.list({ size: 200 }),
repos.modules.list({ size: 200 }),
]);
const blocs = allBlocs.items.filter((b) => b.formationId === id);
const blocsSerialized = blocs.map((b) => {
const modules = allModules.items.filter((m) => m.blocId === b.id);
return serializeBloc(b, modules);
});
// Le repo Formation ne charge pas la liste imbriquee de blocs (un appel par list).
// On recalcule les rollups a partir des blocs fetched ci-dessus, plutot que d'appeler
// formation.heuresAttribuees() qui retournerait 0.
const heuresAttribuees = blocs.reduce((acc, b) => acc.plus(b.heuresPrevues), new Decimal(0));
const heuresRestantes = formation.heuresTotales.minus(heuresAttribuees);
return c.json({
data: {
...serializeFormation(formation),
blocs: blocsSerialized,
heures_attribuees: dec(heuresAttribuees),
heures_restantes: dec(heuresRestantes),
},
});
});

View file

@ -0,0 +1,77 @@
/**
* Routes /api/v1/interventions write Tier 1.
* Tache.creerIntervention valide role developpeur + heures > 0.
*/
import { Decimal } from 'decimal.js';
import { Hono } from 'hono';
import { z } from 'zod';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { dec, parseBody } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const interventionsRoutes = new Hono<{ Variables: AuthVariables }>();
const InterventionBodySchema = z.object({
tache_id: z.number().int().positive(),
personne_id: z.number().int().positive(),
heures: z.number().positive(),
date: z.string(),
notes: z.string().optional().nullable(),
});
interventionsRoutes.post('/', requireScope('write:interventions'), async (c) => {
const body = await parseBody(c, InterventionBodySchema);
const { repos } = getContainer();
const [tache, personne] = await Promise.all([
repos.taches.get(body.tache_id),
repos.personnes.get(body.personne_id),
]);
const date = new Date(body.date);
if (Number.isNaN(date.getTime())) {
throw errors.validation([{ message: 'date must be a valid ISO date' }]);
}
let createdId = 0;
try {
const intervention = tache.creerIntervention(personne, new Decimal(body.heures), date, 0);
const row = await repos.interventions.create({
tacheId: tache.id,
personneId: personne.id,
heures: intervention.heures,
date: intervention.date,
notes: body.notes ?? null,
statut: intervention.statut,
});
createdId = row.id;
} catch (err) {
if (err instanceof Error) {
const msg = err.message;
if (
msg.includes('developpeur') ||
msg.includes('inactive') ||
msg.includes('heures doit etre')
) {
throw errors.validation([{ message: msg }]);
}
}
throw err;
}
return c.json(
{
data: {
intervention_id: createdId,
tache_id: tache.id,
personne_id: personne.id,
heures: dec(new Decimal(body.heures)),
date: date.toISOString(),
statut: 'realise',
},
},
201,
);
});

View file

@ -0,0 +1,101 @@
/**
* Routes /api/v1/modules write Tier 1 : POST /:id/attribuer.
*
* RG-01 enforced via Module.creerAttribution. Le domain throw si dépassement
* on convertit en BridgeError 422. La persistance suit la validation domaine
* (write-after-validate) : si Baserow echoue, le rollup deja applique cote
* Personne in-memory n'est pas persiste mais l'objet est jete (route stateless).
*/
import { Decimal } from 'decimal.js';
import { Hono } from 'hono';
import { z } from 'zod';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { dec, parseBody } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const modulesRoutes = new Hono<{ Variables: AuthVariables }>();
const AttribuerBodySchema = z.object({
personne_id: z.number().int().positive(),
heures: z.number().positive(),
date_debut: z.string().datetime().optional().nullable(),
date_fin: z.string().datetime().optional().nullable(),
});
modulesRoutes.post('/:id/attribuer', requireScope('write:attributions'), async (c) => {
const moduleId = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(moduleId)) {
throw errors.validation([{ message: 'module id must be a number' }]);
}
const body = await parseBody(c, AttribuerBodySchema);
const { repos } = getContainer();
// 1. Charger module + ses attributions actives pour evaluer RG-01.
const [moduleEntity, personne, allAttribs] = await Promise.all([
repos.modules.get(moduleId),
repos.personnes.get(body.personne_id),
repos.attributions.list({ size: 200 }),
]);
for (const attrib of allAttribs.items.filter((a) => a.moduleId === moduleId)) {
moduleEntity.attributions.push(attrib);
}
const dateDebut = body.date_debut ? new Date(body.date_debut) : null;
const dateFin = body.date_fin ? new Date(body.date_fin) : null;
let createdId = 0;
try {
const attribution = moduleEntity.creerAttribution(
personne,
new Decimal(body.heures),
dateDebut,
dateFin,
0, // id provisoire — Baserow attribuera le vrai
);
const row = await repos.attributions.create({
moduleId,
personneId: personne.id,
heuresAttribuees: attribution.heuresAttribuees,
dateDebut,
dateFin,
statut: attribution.statut,
});
createdId = row.id;
} catch (err) {
if (err instanceof Error) {
const msg = err.message;
if (msg.includes('RG-01')) {
throw errors.rgViolation('RG-01', msg, {
moduleId,
personneId: body.personne_id,
heuresPrevues: moduleEntity.heuresPrevues.toNumber(),
heuresDejaAttribuees: moduleEntity
.heuresAttribuees()
.minus(new Decimal(body.heures))
.toNumber(),
heuresDemandees: body.heures,
});
}
if (msg.includes('formateur') || msg.includes('inactive') || msg.includes('heures')) {
throw errors.validation([{ message: msg }]);
}
}
throw err;
}
return c.json(
{
data: {
attribution_id: createdId,
module_id: moduleId,
personne_id: personne.id,
heures_attribuees: dec(new Decimal(body.heures)),
statut: 'planifie',
},
},
201,
);
});

View file

@ -0,0 +1,111 @@
/**
* Routes /api/v1/personnes read-only (Tier 1 MVP).
* Le dashboard agrège attributions + interventions pour donner une vue 360 capacite.
*/
import { Decimal } from 'decimal.js';
import { Hono } from 'hono';
import type { Personne } from '../domain/personne.js';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { dec, parseListQuery } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const personnesRoutes = new Hono<{ Variables: AuthVariables }>();
function serializePersonne(p: Personne) {
return {
id: p.id,
nom: p.nom,
prenom: p.prenom,
email: p.email,
capacite_annuelle: dec(p.capaciteAnnuelle),
split_formation_pct: dec(p.splitFormationPct),
split_agence_pct: dec(p.splitAgencePct),
roles: Array.from(p.roles),
statut: p.statut,
heures_attribuees_formation: dec(p.heuresAttribueesFormation),
heures_attribuees_agence: dec(p.heuresAttribueesAgence),
heures_restantes_formation: dec(p.heuresRestantesFormation()),
heures_restantes_agence: dec(p.heuresRestantesAgence()),
heures_restantes_total: dec(p.heuresRestantesTotal()),
};
}
personnesRoutes.get('/', requireScope('read:personnes'), async (c) => {
const { page, per_page, filter, sort } = parseListQuery(c);
const { repos } = getContainer();
// Baserow filter API: only push role/statut, ignore unknown filters silencieusement.
const baseFilter: Record<string, string> = {};
if (filter.role) baseFilter.personne_roles = filter.role;
if (filter.statut) baseFilter.personne_statut = filter.statut;
const result = await repos.personnes.list({
page,
size: per_page,
filter: baseFilter,
orderBy: sort,
});
return c.json({
data: result.items.map(serializePersonne),
meta: result.meta,
});
});
personnesRoutes.get('/:id', requireScope('read:personnes'), async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]);
const { repos } = getContainer();
const personne = await repos.personnes.get(id);
return c.json({ data: serializePersonne(personne) });
});
personnesRoutes.get('/:id/dashboard', requireScope('read:personnes'), async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]);
const { repos } = getContainer();
const personne = await repos.personnes.get(id);
// Filtre cote Baserow par link id : `filter__attribution_personne__link_row_has`
// n'est pas standard ; on ramene la liste paginee large et filtre cote bridge.
const [attribs, interventions] = await Promise.all([
repos.attributions.list({ size: 200 }),
repos.interventions.list({ size: 200 }),
]);
const myAttribs = attribs.items.filter((a) => a.personneId === id);
const myInterv = interventions.items.filter((i) => i.personneId === id);
const attribsActives = myAttribs.filter((a) => a.isActive());
return c.json({
data: {
personne: serializePersonne(personne),
capacite: {
annuelle: dec(personne.capaciteAnnuelle),
formation: dec(personne.capaciteFormation()),
agence: dec(personne.capaciteAgence()),
},
attributions: {
total: myAttribs.length,
actives: attribsActives.length,
items: attribsActives.map((a) => ({
id: a.id,
module_id: a.moduleId,
heures_attribuees: dec(a.heuresAttribuees),
heures_realisees: dec(a.heuresRealisees),
statut: a.statut,
date_debut: a.dateDebut?.toISOString() ?? null,
date_fin: a.dateFin?.toISOString() ?? null,
})),
},
interventions: {
total: myInterv.length,
actives: myInterv.filter((i) => i.isActive()).length,
heures_total: dec(
myInterv
.filter((i) => i.statut !== 'annule')
.reduce((acc, i) => acc.plus(i.heures), new Decimal(0)),
),
},
},
});
});

View file

@ -0,0 +1,75 @@
/**
* Routes /api/v1/projets read-only Tier 1.
*/
import { Decimal } from 'decimal.js';
import { Hono } from 'hono';
import type { Projet } from '../domain/projet.js';
import type { Tache } from '../domain/tache.js';
import { getContainer } from '../lib/container.js';
import { errors } from '../lib/errors.js';
import { dec, parseListQuery } from '../lib/http.js';
import { type AuthVariables, requireScope } from '../middleware/auth.js';
export const projetsRoutes = new Hono<{ Variables: AuthVariables }>();
function serializeProjet(p: Projet) {
return {
id: p.id,
client_id: p.clientId,
nom: p.nom,
type: p.type,
charge_heures: dec(p.chargeHeures),
statut: p.statut,
formation_id: p.formationId,
};
}
function serializeTache(t: Tache) {
return {
id: t.id,
projet_id: t.projetId,
titre: t.titre,
charge_heures: dec(t.chargeHeures),
priorite: t.priorite,
statut: t.statut,
heures_realisees: dec(t.heuresRealisees()),
};
}
projetsRoutes.get('/', requireScope('read:projets'), async (c) => {
const { page, per_page, filter, sort } = parseListQuery(c);
const { repos } = getContainer();
const baseFilter: Record<string, string> = {};
if (filter.statut) baseFilter.projet_statut = filter.statut;
if (filter.client) baseFilter.projet_client = filter.client;
const result = await repos.projets.list({
page,
size: per_page,
filter: baseFilter,
orderBy: sort,
});
return c.json({ data: result.items.map(serializeProjet), meta: result.meta });
});
projetsRoutes.get('/:id', requireScope('read:projets'), async (c) => {
const id = Number.parseInt(c.req.param('id'), 10);
if (Number.isNaN(id)) throw errors.validation([{ message: 'id must be a number' }]);
const { repos } = getContainer();
const projet = await repos.projets.get(id);
const allTaches = await repos.taches.list({ size: 200 });
const taches = allTaches.items.filter((t) => t.projetId === id);
return c.json({
data: {
...serializeProjet(projet),
taches: taches.map(serializeTache),
heures_realisees: dec(
taches.reduce((acc, t) => acc.plus(t.heuresRealisees()), new Decimal(0)),
),
heures_restantes: dec(projet.heuresRestantes()),
},
});
});

View file

@ -0,0 +1,143 @@
/**
* Fake repos pour les tests routes : implementent l'API publique des repos
* (list/get/create/update*) en utilisant un store in-memory.
*/
import type { Decimal } from 'decimal.js';
import type { Attribution } from '../../src/domain/attribution.js';
import type { Bloc } from '../../src/domain/bloc.js';
import type { Client } from '../../src/domain/client.js';
import type { Formation } from '../../src/domain/formation.js';
import type { Intervention } from '../../src/domain/intervention.js';
import type { Module } from '../../src/domain/module.js';
import type { Personne } from '../../src/domain/personne.js';
import type { Projet } from '../../src/domain/projet.js';
import type { Tache } from '../../src/domain/tache.js';
import type { StatutAttribution, StatutIntervention } from '../../src/domain/types.js';
import { errors } from '../../src/lib/errors.js';
import type { RepoSet } from '../../src/repos/baserow-repo.js';
interface ListResult<T> {
items: T[];
meta: { page: number; per_page: number; total: number; total_pages: number };
}
class FakeReadRepo<T extends { id: number }> {
constructor(
private readonly entityName: string,
public store: T[] = [],
) {}
list(): Promise<ListResult<T>> {
return Promise.resolve({
items: this.store,
meta: { page: 1, per_page: 200, total: this.store.length, total_pages: 1 },
});
}
get(id: number): Promise<T> {
const found = this.store.find((x) => x.id === id);
if (!found) return Promise.reject(errors.notFound(this.entityName, id));
return Promise.resolve(found);
}
}
export class FakeAttributionRepo extends FakeReadRepo<Attribution> {
public lastCreated?: {
moduleId: number;
personneId: number;
heuresAttribuees: Decimal;
statut: StatutAttribution;
};
public lastUpdate?: { id: number; heures: Decimal };
public nextId = 1000;
constructor(store: Attribution[] = []) {
super('Attribution', store);
}
create(input: {
moduleId: number;
personneId: number;
heuresAttribuees: Decimal;
dateDebut: Date | null;
dateFin: Date | null;
statut: StatutAttribution;
}) {
this.lastCreated = input;
const id = this.nextId++;
return Promise.resolve({ id, order: '1', ...input });
}
updateHeuresRealisees(id: number, heures: Decimal) {
this.lastUpdate = { id, heures };
return Promise.resolve({ id, order: '1', attribution_heures_realisees: heures.toNumber() });
}
}
export class FakeInterventionRepo extends FakeReadRepo<Intervention> {
public lastCreated?: {
tacheId: number;
personneId: number;
heures: Decimal;
date: Date;
notes: string | null;
statut: StatutIntervention;
};
public nextId = 2000;
constructor(store: Intervention[] = []) {
super('Intervention', store);
}
create(input: {
tacheId: number;
personneId: number;
heures: Decimal;
date: Date;
notes: string | null;
statut: StatutIntervention;
}) {
this.lastCreated = input;
const id = this.nextId++;
return Promise.resolve({ id, order: '1', ...input });
}
}
export interface FakeReposBundle extends RepoSet {
personnes: FakeReadRepo<Personne> & RepoSet['personnes'];
formations: FakeReadRepo<Formation> & RepoSet['formations'];
blocs: FakeReadRepo<Bloc> & RepoSet['blocs'];
modules: FakeReadRepo<Module> & RepoSet['modules'];
attributions: FakeAttributionRepo & RepoSet['attributions'];
clients: FakeReadRepo<Client> & RepoSet['clients'];
projets: FakeReadRepo<Projet> & RepoSet['projets'];
taches: FakeReadRepo<Tache> & RepoSet['taches'];
interventions: FakeInterventionRepo & RepoSet['interventions'];
}
export function buildFakeRepos(stores: {
personnes?: Personne[];
formations?: Formation[];
blocs?: Bloc[];
modules?: Module[];
attributions?: Attribution[];
clients?: Client[];
projets?: Projet[];
taches?: Tache[];
interventions?: Intervention[];
}): FakeReposBundle {
// Cast force : on shippe l'interface publique RepoSet meme si les classes
// BaseRepo ne sont pas etendues. Les tests ne tapent que les methodes utilisees.
return {
personnes: new FakeReadRepo<Personne>('Personne', stores.personnes ?? []),
formations: new FakeReadRepo<Formation>('Formation', stores.formations ?? []),
blocs: new FakeReadRepo<Bloc>('Bloc', stores.blocs ?? []),
modules: new FakeReadRepo<Module>('Module', stores.modules ?? []),
attributions: new FakeAttributionRepo(stores.attributions ?? []),
clients: new FakeReadRepo<Client>('Client', stores.clients ?? []),
projets: new FakeReadRepo<Projet>('Projet', stores.projets ?? []),
taches: new FakeReadRepo<Tache>('Tache', stores.taches ?? []),
interventions: new FakeInterventionRepo(stores.interventions ?? []),
} as unknown as FakeReposBundle;
}

View file

@ -0,0 +1,101 @@
/**
* Fixtures domain partagees par les tests routes.
*/
import { Decimal } from 'decimal.js';
import { Attribution } from '../../src/domain/attribution.js';
import { Bloc } from '../../src/domain/bloc.js';
import { Formation } from '../../src/domain/formation.js';
import { Intervention } from '../../src/domain/intervention.js';
import { Module } from '../../src/domain/module.js';
import { Personne } from '../../src/domain/personne.js';
import { Projet } from '../../src/domain/projet.js';
import { Tache } from '../../src/domain/tache.js';
import type { Role } from '../../src/domain/types.js';
export function makePersonne(over: Partial<{ id: number; roles: Role[] }> = {}): Personne {
return new Personne({
id: over.id ?? 1,
nom: 'Dupont',
prenom: 'Pierre',
email: 'pierre@acadenice.fr',
capaciteAnnuelle: new Decimal(1000),
splitFormationPct: new Decimal(60),
splitAgencePct: new Decimal(40),
roles: new Set<Role>(over.roles ?? ['formateur']),
statut: 'actif',
});
}
export function makeFormation(id = 10): Formation {
return new Formation({
id,
nom: 'Dev Fullstack',
heuresTotales: new Decimal(500),
statut: 'actif',
});
}
export function makeBloc(id = 100, formationId = 10): Bloc {
return new Bloc({
id,
formationId,
nom: 'Bloc JS',
heuresPrevues: new Decimal(100),
ordre: 1,
});
}
export function makeModule(id = 200, blocId = 100): Module {
return new Module({
id,
blocId,
nom: 'JS Fondamentaux',
heuresPrevues: new Decimal(30),
});
}
export function makeAttribution(
over: Partial<{ id: number; moduleId: number; personneId: number }> = {},
): Attribution {
return new Attribution({
id: over.id ?? 500,
moduleId: over.moduleId ?? 200,
personneId: over.personneId ?? 1,
heuresAttribuees: new Decimal(10),
statut: 'planifie',
});
}
export function makeProjet(id = 300, clientId = 50): Projet {
return new Projet({
id,
clientId,
nom: 'Site Acme',
chargeHeures: new Decimal(80),
statut: 'en_cours',
});
}
export function makeTache(id = 400, projetId = 300): Tache {
return new Tache({
id,
projetId,
titre: 'Setup repo',
chargeHeures: new Decimal(8),
statut: 'todo',
});
}
export function makeIntervention(
over: Partial<{ id: number; tacheId: number; personneId: number }> = {},
): Intervention {
return new Intervention({
id: over.id ?? 600,
tacheId: over.tacheId ?? 400,
personneId: over.personneId ?? 2,
heures: new Decimal(2),
date: new Date('2026-05-01'),
statut: 'realise',
});
}

View file

@ -0,0 +1,115 @@
/**
* Test helper : construit une app Hono iso-prod avec un container minimal en memoire.
* Pas de testcontainers ici les routes utilisent les repos qu'on mock dans chaque suite.
*/
import { Hono } from 'hono';
import { logger as honoLogger } from 'hono/logger';
import type { BaserowClient } from '../../src/adapters/baserow-client.js';
import type { RedisCache } from '../../src/adapters/redis-cache.js';
import type { Container } from '../../src/lib/container.js';
import { setContainer } from '../../src/lib/container.js';
import { logger } from '../../src/lib/logger.js';
import {
type ApiTokenRecord,
type AuthVariables,
authMiddleware,
} from '../../src/middleware/auth.js';
import { errorHandler } from '../../src/middleware/error-handler.js';
import type { RepoSet, TableIds } from '../../src/repos/baserow-repo.js';
import { attributionsRoutes } from '../../src/routes/attributions.js';
import { formationsRoutes } from '../../src/routes/formations.js';
import { interventionsRoutes } from '../../src/routes/interventions.js';
import { modulesRoutes } from '../../src/routes/modules.js';
import { personnesRoutes } from '../../src/routes/personnes.js';
import { projetsRoutes } from '../../src/routes/projets.js';
const FAKE_TABLE_IDS: TableIds = {
personne: 1,
formation: 2,
bloc: 3,
module: 4,
attribution: 5,
client: 6,
projet: 7,
tache: 8,
intervention: 9,
};
export const READ_ALL_TOKEN = 'brg_read_all';
export const WRITE_ALL_TOKEN = 'brg_write_all';
export const ADMIN_TOKEN = 'brg_admin';
export const TEST_TOKENS: ApiTokenRecord[] = [
{
token: READ_ALL_TOKEN,
name: 'test-read',
scopes: ['read:personnes', 'read:formations', 'read:projets'],
},
{
token: WRITE_ALL_TOKEN,
name: 'test-write',
scopes: ['write:attributions', 'write:interventions'],
},
{ token: ADMIN_TOKEN, name: 'test-admin', scopes: ['admin:*'] },
];
export interface TestContainerOverrides {
repos: RepoSet;
baserow?: BaserowClient;
redis?: RedisCache;
tokens?: ApiTokenRecord[];
}
export function installTestContainer(over: TestContainerOverrides): Container {
const tokensMap = new Map<string, ApiTokenRecord>();
for (const t of over.tokens ?? TEST_TOKENS) tokensMap.set(t.token, t);
const fakeBaserow = over.baserow ?? ({} as BaserowClient);
const fakeRedis = over.redis ?? ({} as RedisCache);
const container: Container = {
config: {
nodeEnv: 'test',
port: 0,
logLevel: 'fatal',
baserowApiUrl: 'http://localhost',
baserowApiToken: 'fake',
redisUrl: 'redis://localhost',
baserowWebhookSecret: 'fake_secret_at_least_16_chars',
bridgeApiTokens: undefined,
},
baserow: fakeBaserow,
redis: fakeRedis,
repos: over.repos,
tokens: tokensMap,
tableIds: FAKE_TABLE_IDS,
logger,
};
setContainer(container);
return container;
}
export function resetTestContainer(): void {
setContainer(null);
}
export function buildTestApp(container: Container): Hono<{ Variables: AuthVariables }> {
const app = new Hono<{ Variables: AuthVariables }>();
app.use('*', honoLogger());
app.onError(errorHandler);
app.get('/api/health', (c) => c.json({ status: 'ok' }));
const v1 = new Hono<{ Variables: AuthVariables }>();
v1.use('*', authMiddleware(container.tokens));
v1.route('/personnes', personnesRoutes);
v1.route('/formations', formationsRoutes);
v1.route('/projets', projetsRoutes);
v1.route('/modules', modulesRoutes);
v1.route('/interventions', interventionsRoutes);
v1.route('/attributions', attributionsRoutes);
app.route('/api/v1', v1);
return app;
}

View file

@ -0,0 +1,118 @@
import { Hono } from 'hono';
import { describe, expect, it } from 'vitest';
import {
type ApiTokenRecord,
type AuthVariables,
authMiddleware,
hasScope,
parseTokens,
requireScope,
} from '../../src/middleware/auth.js';
import { errorHandler } from '../../src/middleware/error-handler.js';
function buildApp(tokens: ApiTokenRecord[]): Hono<{ Variables: AuthVariables }> {
const map = new Map<string, ApiTokenRecord>();
for (const t of tokens) map.set(t.token, t);
const app = new Hono<{ Variables: AuthVariables }>();
app.onError(errorHandler);
app.use('/protected/*', authMiddleware(map));
app.get('/protected/read', requireScope('read:personnes'), (c) =>
c.json({ ok: true, scopes: Array.from(c.get('auth').scopes) }),
);
app.get('/protected/admin', requireScope('admin:something'), (c) => c.json({ ok: true }));
return app;
}
describe('parseTokens', () => {
it('parse JSON valide', () => {
const map = parseTokens(
JSON.stringify([{ token: 'brg_x', name: 'a', scopes: ['read:personnes'] }]),
);
expect(map.get('brg_x')?.name).toBe('a');
});
it('retourne map vide si raw vide', () => {
expect(parseTokens(undefined).size).toBe(0);
expect(parseTokens('').size).toBe(0);
});
it('throw si JSON invalide', () => {
expect(() => parseTokens('{nope')).toThrow(/JSON/);
});
it('throw si pas un array', () => {
expect(() => parseTokens('{"foo": 1}')).toThrow(/tableau/);
});
it('throw si entree manque token/name/scopes', () => {
expect(() => parseTokens('[{"token":"x"}]')).toThrow();
expect(() => parseTokens('[{"token":"x","name":"y"}]')).toThrow();
expect(() => parseTokens('[{"token":"x","name":"y","scopes":[1]}]')).toThrow();
});
});
describe('hasScope', () => {
it('match exact', () => {
expect(hasScope(new Set(['read:personnes']), 'read:personnes')).toBe(true);
expect(hasScope(new Set(['read:personnes']), 'read:projets')).toBe(false);
});
it('admin:* couvre tout', () => {
expect(hasScope(new Set(['admin:*']), 'read:any')).toBe(true);
expect(hasScope(new Set(['admin:*']), 'write:something')).toBe(true);
});
});
describe('auth middleware — 5 cas', () => {
const tokens: ApiTokenRecord[] = [
{ token: 'brg_valid', name: 'demo', scopes: ['read:personnes'] },
];
it('401 si pas de header', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/read');
expect(res.status).toBe(401);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('AUTH_REQUIRED');
});
it('401 si format wrong (pas Bearer)', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/read', {
headers: { Authorization: 'Token brg_valid' },
});
expect(res.status).toBe(401);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('AUTH_INVALID');
});
it('401 si token inconnu', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/read', {
headers: { Authorization: 'Bearer brg_unknown' },
});
expect(res.status).toBe(401);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('AUTH_INVALID');
});
it('403 si scope manquant', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/admin', {
headers: { Authorization: 'Bearer brg_valid' },
});
expect(res.status).toBe(403);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('FORBIDDEN_SCOPE');
});
it('200 si token + scope OK', async () => {
const app = buildApp(tokens);
const res = await app.request('/protected/read', {
headers: { Authorization: 'Bearer brg_valid' },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { ok: boolean; scopes: string[] };
expect(body.ok).toBe(true);
expect(body.scopes).toContain('read:personnes');
});
});

View file

@ -0,0 +1,223 @@
/**
* Tests des mappers Row -> Domain. On instancie les repos avec un BaserowClient mock
* minimal qui rend juste getRow/listRows.
*/
import type { Logger } from 'pino';
import { describe, expect, it, vi } from 'vitest';
import type {
BaserowClient,
BaserowPaginatedResponse,
BaserowRow,
} from '../../src/adapters/baserow-client.js';
import { logger } from '../../src/lib/logger.js';
import {
AttributionRepo,
FormationRepo,
ModuleRepo,
PersonneRepo,
ProjetRepo,
} from '../../src/repos/baserow-repo.js';
function fakeClient(rowsByTable: Record<number, BaserowRow[]>): BaserowClient {
return {
listRows: vi.fn(
(tableId: number): Promise<BaserowPaginatedResponse> =>
Promise.resolve({
count: rowsByTable[tableId]?.length ?? 0,
next: null,
previous: null,
results: rowsByTable[tableId] ?? [],
}),
),
getRow: vi.fn((tableId: number, rowId: number): Promise<BaserowRow> => {
const row = (rowsByTable[tableId] ?? []).find((r) => r.id === rowId);
if (!row) return Promise.reject(Object.assign(new Error('not found'), { code: 'NOT_FOUND' }));
return Promise.resolve(row);
}),
createRow: vi.fn(),
updateRow: vi.fn(),
deleteRow: vi.fn(),
resolveTableIds: vi.fn(),
healthCheck: vi.fn(),
} as unknown as BaserowClient;
}
const log = logger as Logger;
describe('PersonneRepo', () => {
it('mappe une row Baserow vers Personne', async () => {
const row: BaserowRow = {
id: 42,
order: '1',
personne_nom: 'Dupont',
personne_prenom: 'Pierre',
personne_email: 'p@a.fr',
personne_capacite_annuelle: '1000',
personne_split_formation_pct: 60,
personne_split_agence_pct: 40,
personne_roles: [{ id: 1, value: 'formateur', color: 'blue' }],
personne_statut: { id: 2, value: 'actif', color: 'green' },
personne_heures_attribuees_formation: '0',
personne_heures_attribuees_agence: '0',
};
const repo = new PersonneRepo({
client: fakeClient({ 1: [row] }),
tableId: 1,
entityName: 'Personne',
logger: log,
});
const personne = await repo.get(42);
expect(personne.id).toBe(42);
expect(personne.nom).toBe('Dupont');
expect(personne.hasRole('formateur')).toBe(true);
expect(personne.statut).toBe('actif');
expect(personne.capaciteAnnuelle.toNumber()).toBe(1000);
});
it('list pagine et map', async () => {
const repo = new PersonneRepo({
client: fakeClient({
1: [
{
id: 1,
order: '1',
personne_nom: 'A',
personne_prenom: 'B',
personne_email: 'a@b.c',
personne_capacite_annuelle: '1',
personne_split_formation_pct: 50,
personne_split_agence_pct: 50,
personne_roles: [],
personne_statut: 'actif',
},
],
}),
tableId: 1,
entityName: 'Personne',
logger: log,
});
const res = await repo.list({ size: 50 });
expect(res.items).toHaveLength(1);
expect(res.meta.total).toBe(1);
});
it('throw NOT_FOUND si row inexistante', async () => {
const repo = new PersonneRepo({
client: fakeClient({ 1: [] }),
tableId: 1,
entityName: 'Personne',
logger: log,
});
await expect(repo.get(999)).rejects.toMatchObject({ code: 'NOT_FOUND' });
});
});
describe('FormationRepo', () => {
it('mappe filiere/statut select', async () => {
const row: BaserowRow = {
id: 10,
order: '1',
formation_nom: 'Dev',
formation_filiere: { id: 1, value: 'dev', color: 'blue' },
formation_heures_totales: 500,
formation_statut: { id: 2, value: 'actif', color: 'green' },
};
const repo = new FormationRepo({
client: fakeClient({ 2: [row] }),
tableId: 2,
entityName: 'Formation',
logger: log,
});
const f = await repo.get(10);
expect(f.filiere).toBe('dev');
expect(f.statut).toBe('actif');
});
});
describe('ModuleRepo', () => {
it('mappe blocId via link field', async () => {
const row: BaserowRow = {
id: 200,
order: '1',
module_nom: 'JS',
module_heures_prevues: 30,
module_statut: 'a_attribuer',
module_bloc: [{ id: 100, value: 'Bloc JS' }],
};
const repo = new ModuleRepo({
client: fakeClient({ 4: [row] }),
tableId: 4,
entityName: 'Module',
logger: log,
});
const m = await repo.get(200);
expect(m.blocId).toBe(100);
expect(m.statut).toBe('a_attribuer');
});
});
describe('AttributionRepo', () => {
it('mappe + create persiste les bons fields', async () => {
const row: BaserowRow = {
id: 500,
order: '1',
attribution_heures_attribuees: 10,
attribution_heures_realisees: 0,
attribution_module: [{ id: 200, value: 'JS' }],
attribution_personne: [{ id: 1, value: 'Pierre' }],
attribution_statut: 'planifie',
};
const client = fakeClient({ 5: [row] });
const repo = new AttributionRepo({
client,
tableId: 5,
entityName: 'Attribution',
logger: log,
});
const a = await repo.get(500);
expect(a.moduleId).toBe(200);
expect(a.personneId).toBe(1);
await repo.create({
moduleId: 200,
personneId: 1,
heuresAttribuees: a.heuresAttribuees,
dateDebut: new Date('2026-09-01'),
dateFin: null,
statut: 'planifie',
});
expect(client.createRow).toHaveBeenCalledWith(
5,
expect.objectContaining({
attribution_module: [200],
attribution_personne: [1],
attribution_statut: 'planifie',
}),
);
});
});
describe('ProjetRepo', () => {
it('mappe statut + clientId', async () => {
const row: BaserowRow = {
id: 300,
order: '1',
projet_nom: 'Acme',
projet_charge_heures: 80,
projet_client: [{ id: 50, value: 'Acme Inc' }],
projet_statut: { id: 1, value: 'en_cours', color: 'blue' },
projet_type: { id: 2, value: 'site_web', color: 'blue' },
};
const repo = new ProjetRepo({
client: fakeClient({ 7: [row] }),
tableId: 7,
entityName: 'Projet',
logger: log,
});
const p = await repo.get(300);
expect(p.clientId).toBe(50);
expect(p.statut).toBe('en_cours');
expect(p.type).toBe('site_web');
});
});

View file

@ -0,0 +1,74 @@
import { Decimal } from 'decimal.js';
import { afterEach, describe, expect, it } from 'vitest';
import { Attribution } from '../../src/domain/attribution.js';
import { buildFakeRepos } from '../helpers/fake-repos.js';
import { makeAttribution } from '../helpers/fixtures.js';
import {
WRITE_ALL_TOKEN,
buildTestApp,
installTestContainer,
resetTestContainer,
} from '../helpers/test-app.js';
function patchHeures(
app: ReturnType<typeof buildTestApp>,
id: number,
body: Record<string, unknown>,
) {
return app.request(`/api/v1/attributions/${id}/heures-realisees`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${WRITE_ALL_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
describe('PATCH /api/v1/attributions/:id/heures-realisees', () => {
afterEach(resetTestContainer);
it('happy path 200', async () => {
const attrib = makeAttribution({ id: 500 });
const repos = buildFakeRepos({ attributions: [attrib] });
const app = buildTestApp(installTestContainer({ repos }));
const res = await patchHeures(app, 500, { heures_realisees: 4.5, comment: 'sprint 1' });
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { heures_realisees: string } };
expect(body.data.heures_realisees).toBe('4.50');
expect(repos.attributions.lastUpdate?.id).toBe(500);
expect(repos.attributions.lastUpdate?.heures.toNumber()).toBe(4.5);
});
it('409 si attribution annulee', async () => {
const annulee = new Attribution({
id: 500,
moduleId: 200,
personneId: 1,
heuresAttribuees: new Decimal(10),
statut: 'annule',
});
const repos = buildFakeRepos({ attributions: [annulee] });
const app = buildTestApp(installTestContainer({ repos }));
const res = await patchHeures(app, 500, { heures_realisees: 2 });
expect(res.status).toBe(409);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('CONFLICT');
});
it('404 si attribution inconnue', async () => {
const repos = buildFakeRepos({});
const app = buildTestApp(installTestContainer({ repos }));
const res = await patchHeures(app, 9999, { heures_realisees: 1 });
expect(res.status).toBe(404);
});
it('400 si heures negatives', async () => {
const repos = buildFakeRepos({ attributions: [makeAttribution({ id: 500 })] });
const app = buildTestApp(installTestContainer({ repos }));
const res = await patchHeures(app, 500, { heures_realisees: -1 });
expect(res.status).toBe(400);
});
});

View file

@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildFakeRepos } from '../helpers/fake-repos.js';
import { makeBloc, makeFormation, makeModule } from '../helpers/fixtures.js';
import {
READ_ALL_TOKEN,
WRITE_ALL_TOKEN,
buildTestApp,
installTestContainer,
resetTestContainer,
} from '../helpers/test-app.js';
function bootApp() {
const repos = buildFakeRepos({
formations: [makeFormation(10)],
blocs: [makeBloc(100, 10)],
modules: [makeModule(200, 100)],
});
const container = installTestContainer({ repos });
return { app: buildTestApp(container) };
}
describe('GET /api/v1/formations', () => {
afterEach(resetTestContainer);
it('401 sans token', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/formations');
expect(res.status).toBe(401);
});
it('403 si scope manquant', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/formations', {
headers: { Authorization: `Bearer ${WRITE_ALL_TOKEN}` },
});
expect(res.status).toBe(403);
});
it('200 list', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/formations', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { id: number }[] };
expect(body.data).toHaveLength(1);
});
it('GET /:id avec blocs/modules + rollups', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/formations/10', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { blocs: { id: number; modules: { id: number }[] }[]; heures_attribuees: string };
};
expect(body.data.blocs).toHaveLength(1);
expect(body.data.blocs[0]?.modules).toHaveLength(1);
expect(body.data.heures_attribuees).toBe('100.00');
});
it('404 si formation inconnue', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/formations/9999', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(404);
});
});

View file

@ -0,0 +1,88 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildFakeRepos } from '../helpers/fake-repos.js';
import { makePersonne, makeTache } from '../helpers/fixtures.js';
import {
WRITE_ALL_TOKEN,
buildTestApp,
installTestContainer,
resetTestContainer,
} from '../helpers/test-app.js';
function postIntervention(app: ReturnType<typeof buildTestApp>, body: Record<string, unknown>) {
return app.request('/api/v1/interventions', {
method: 'POST',
headers: {
Authorization: `Bearer ${WRITE_ALL_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
describe('POST /api/v1/interventions', () => {
afterEach(resetTestContainer);
it('happy path 201', async () => {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 2, roles: ['developpeur'] })],
taches: [makeTache(400, 300)],
});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postIntervention(app, {
tache_id: 400,
personne_id: 2,
heures: 3,
date: '2026-05-07T10:00:00Z',
notes: 'cours JS',
});
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { intervention_id: number; heures: string } };
expect(body.data.heures).toBe('3.00');
expect(repos.interventions.lastCreated?.heures.toNumber()).toBe(3);
});
it('422 (validation) si role pas developpeur', async () => {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 1, roles: ['formateur'] })],
taches: [makeTache(400, 300)],
});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postIntervention(app, {
tache_id: 400,
personne_id: 1,
heures: 3,
date: '2026-05-07T10:00:00Z',
});
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('VALIDATION_ERROR');
});
it('400 si heures <= 0 (Zod)', async () => {
const repos = buildFakeRepos({});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postIntervention(app, {
tache_id: 400,
personne_id: 2,
heures: 0,
date: '2026-05-07T10:00:00Z',
});
expect(res.status).toBe(400);
});
it('404 si tache inconnue', async () => {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 2, roles: ['developpeur'] })],
});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postIntervention(app, {
tache_id: 999,
personne_id: 2,
heures: 3,
date: '2026-05-07T10:00:00Z',
});
expect(res.status).toBe(404);
});
});

View file

@ -0,0 +1,100 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildFakeRepos } from '../helpers/fake-repos.js';
import { makeAttribution, makeModule, makePersonne } from '../helpers/fixtures.js';
import {
READ_ALL_TOKEN,
WRITE_ALL_TOKEN,
buildTestApp,
installTestContainer,
resetTestContainer,
} from '../helpers/test-app.js';
function postAttribuer(
app: ReturnType<typeof buildTestApp>,
body: Record<string, unknown>,
token = WRITE_ALL_TOKEN,
) {
return app.request('/api/v1/modules/200/attribuer', {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
}
describe('POST /api/v1/modules/:id/attribuer', () => {
afterEach(resetTestContainer);
it('happy path 201 + persistance via repo', async () => {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 1, roles: ['formateur'] })],
modules: [makeModule(200, 100)],
});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postAttribuer(app, {
personne_id: 1,
heures: 10,
date_debut: '2026-09-01T00:00:00Z',
});
expect(res.status).toBe(201);
const body = (await res.json()) as { data: { attribution_id: number; statut: string } };
expect(body.data.statut).toBe('planifie');
expect(repos.attributions.lastCreated?.heuresAttribuees.toNumber()).toBe(10);
});
it('422 RG-01 si heures > capacite module', async () => {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 1, roles: ['formateur'] })],
modules: [makeModule(200, 100)], // heuresPrevues = 30
attributions: [makeAttribution({ id: 500, moduleId: 200, personneId: 99 })], // 10h deja
});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postAttribuer(app, { personne_id: 1, heures: 50 });
expect(res.status).toBe(422);
const body = (await res.json()) as { error: { code: string; details: { rule: string } } };
expect(body.error.code).toBe('RG_VIOLATION');
expect(body.error.details.rule).toBe('RG-01');
});
it('422 (validation) si role formateur manquant', async () => {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 1, roles: ['developpeur'] })],
modules: [makeModule(200, 100)],
});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postAttribuer(app, { personne_id: 1, heures: 5 });
expect(res.status).toBe(400);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('VALIDATION_ERROR');
});
it('400 si body invalide (heures negatives)', async () => {
const repos = buildFakeRepos({});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postAttribuer(app, { personne_id: 1, heures: -3 });
expect(res.status).toBe(400);
});
it('401 sans token', async () => {
const repos = buildFakeRepos({});
const app = buildTestApp(installTestContainer({ repos }));
const res = await app.request('/api/v1/modules/200/attribuer', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ personne_id: 1, heures: 5 }),
});
expect(res.status).toBe(401);
});
it('403 avec mauvais scope', async () => {
const repos = buildFakeRepos({});
const app = buildTestApp(installTestContainer({ repos }));
const res = await postAttribuer(app, { personne_id: 1, heures: 5 }, READ_ALL_TOKEN);
expect(res.status).toBe(403);
});
});

View file

@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { buildFakeRepos } from '../helpers/fake-repos.js';
import { makeAttribution, makeIntervention, makePersonne } from '../helpers/fixtures.js';
import { resetTestContainer } from '../helpers/test-app.js';
import { READ_ALL_TOKEN, buildTestApp, installTestContainer } from '../helpers/test-app.js';
function bootApp() {
const repos = buildFakeRepos({
personnes: [makePersonne({ id: 1 })],
attributions: [
makeAttribution({ id: 500, moduleId: 200, personneId: 1 }),
makeAttribution({ id: 501, moduleId: 200, personneId: 99 }),
],
interventions: [makeIntervention({ id: 600, personneId: 1 })],
});
const container = installTestContainer({ repos });
return { app: buildTestApp(container), repos };
}
describe('GET /api/v1/personnes', () => {
beforeEach(() => {});
afterEach(resetTestContainer);
it('401 sans token', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/personnes');
expect(res.status).toBe(401);
});
it('200 list paginee', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/personnes', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: unknown[]; meta: { total: number } };
expect(body.data).toHaveLength(1);
expect(body.meta.total).toBe(1);
});
it('GET /:id 200 avec heures restantes', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/personnes/1', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
id: number;
heures_restantes_formation: string;
heures_restantes_total: string;
};
};
expect(body.data.id).toBe(1);
expect(body.data.heures_restantes_formation).toBe('600.00');
expect(body.data.heures_restantes_total).toBe('1000.00');
});
it('GET /:id 404 si inconnu', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/personnes/9999', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(404);
const body = (await res.json()) as { error: { code: string } };
expect(body.error.code).toBe('NOT_FOUND');
});
it('GET /:id 400 si id non numerique', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/personnes/abc', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(400);
});
it('GET /:id/dashboard agrege attributions + interventions', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/personnes/1/dashboard', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
attributions: { total: number; actives: number };
interventions: { total: number; heures_total: string };
};
};
expect(body.data.attributions.total).toBe(1);
expect(body.data.attributions.actives).toBe(1);
expect(body.data.interventions.total).toBe(1);
expect(body.data.interventions.heures_total).toBe('2.00');
});
});

View file

@ -0,0 +1,62 @@
import { afterEach, describe, expect, it } from 'vitest';
import { buildFakeRepos } from '../helpers/fake-repos.js';
import { makeIntervention, makeProjet, makeTache } from '../helpers/fixtures.js';
import {
READ_ALL_TOKEN,
buildTestApp,
installTestContainer,
resetTestContainer,
} from '../helpers/test-app.js';
function bootApp() {
const tache = makeTache(400, 300);
// Une intervention realisee de 2h sur la tache.
tache.interventions.push(makeIntervention({ id: 600, tacheId: 400, personneId: 2 }));
const repos = buildFakeRepos({
projets: [makeProjet(300, 50)],
taches: [tache],
});
const container = installTestContainer({ repos });
return { app: buildTestApp(container) };
}
describe('GET /api/v1/projets', () => {
afterEach(resetTestContainer);
it('401 sans token', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/projets');
expect(res.status).toBe(401);
});
it('200 list', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/projets', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { id: number }[] };
expect(body.data).toHaveLength(1);
});
it('GET /:id avec taches + heures rollup', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/projets/300', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { taches: { id: number; heures_realisees: string }[]; heures_realisees: string };
};
expect(body.data.taches).toHaveLength(1);
expect(body.data.heures_realisees).toBe('2.00');
});
it('404 si projet inconnu', async () => {
const { app } = bootApp();
const res = await app.request('/api/v1/projets/9999', {
headers: { Authorization: `Bearer ${READ_ALL_TOKEN}` },
});
expect(res.status).toBe(404);
});
});