diff --git a/bridge/src/index.ts b/bridge/src/index.ts index 58c4b1b..9f10f37 100644 --- a/bridge/src/index.ts +++ b/bridge/src/index.ts @@ -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 { Hono } from 'hono'; import { logger as honoLogger } from 'hono/logger'; import { loadConfig } from './lib/config.js'; +import { getContainer, initContainer } from './lib/container.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(); -const app = new Hono(); +export async function buildApp(): Promise> { + 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) => { - return c.json({ status: 'ok', service: 'bridge', version: '0.1.0' }); -}); + app.get('/api/ready', async (c) => { + 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) => { - return c.json({ status: 'ok', dependencies: { baserow: 'TODO', redis: 'TODO' } }); -}); + // Auth middleware applique sur tout /api/v1/* + 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) => { - logger.error({ err }, 'Unhandled error'); - return c.json({ error: { code: 'INTERNAL', message: 'Internal server error' } }, 500); -}); + return app; +} -serve({ fetch: app.fetch, port: config.port }, (info) => { - logger.info({ port: info.port, env: config.nodeEnv }, 'Bridge service started'); -}); +async function main() { + 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); + }); +} diff --git a/bridge/src/lib/container.ts b/bridge/src/lib/container.ts new file mode 100644 index 0000000..677b755 --- /dev/null +++ b/bridge/src/lib/container.ts @@ -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; + 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 { + 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): TableIds { + const out: Partial = {}; + 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; +} diff --git a/bridge/src/lib/http.ts b/bridge/src/lib/http.ts new file mode 100644 index 0000000..e09c7be --- /dev/null +++ b/bridge/src/lib/http.ts @@ -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; + 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 = {}; + 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(c: Context, schema: z.ZodType): Promise { + 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; +} diff --git a/bridge/src/middleware/auth.ts b/bridge/src/middleware/auth.ts new file mode 100644 index 0000000..804f4f1 --- /dev/null +++ b/bridge/src/middleware/auth.ts @@ -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; +} + +/** 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 { + const map = new Map(); + 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; + 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, 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, +): 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(); + }; +} diff --git a/bridge/src/middleware/error-handler.ts b/bridge/src/middleware/error-handler.ts new file mode 100644 index 0000000..824bbf5 --- /dev/null +++ b/bridge/src/middleware/error-handler.ts @@ -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, + ); +} diff --git a/bridge/src/repos/baserow-repo.ts b/bridge/src/repos/baserow-repo.ts new file mode 100644 index 0000000..cf42b6a --- /dev/null +++ b/bridge/src/repos/baserow-repo.ts @@ -0,0 +1,501 @@ +/** + * Repository layer — wrappe BaserowClient et fait le mapping `BaserowRow` <-> domain. + * + * Choix : 1 classe par entite, qui herite de `BaseRepo`. 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; + +/** 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.client.updateRow(this.tableId, id, { + attribution_heures_realisees: heures.toNumber(), + }); + } +} + +// --------------------------------------------------------------------------- +// Client / Projet / Tache / Intervention +// --------------------------------------------------------------------------- + +export class ClientRepo extends BaseRepo { + 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 { + 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 { + 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 { + 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 { + 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, + }), + }; +} diff --git a/bridge/src/routes/attributions.ts b/bridge/src/routes/attributions.ts new file mode 100644 index 0000000..d9aa69a --- /dev/null +++ b/bridge/src/routes/attributions.ts @@ -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, + }, + }); +}); diff --git a/bridge/src/routes/formations.ts b/bridge/src/routes/formations.ts new file mode 100644 index 0000000..93f56d3 --- /dev/null +++ b/bridge/src/routes/formations.ts @@ -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), + }, + }); +}); diff --git a/bridge/src/routes/interventions.ts b/bridge/src/routes/interventions.ts new file mode 100644 index 0000000..4e226e7 --- /dev/null +++ b/bridge/src/routes/interventions.ts @@ -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, + ); +}); diff --git a/bridge/src/routes/modules.ts b/bridge/src/routes/modules.ts new file mode 100644 index 0000000..8728a78 --- /dev/null +++ b/bridge/src/routes/modules.ts @@ -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, + ); +}); diff --git a/bridge/src/routes/personnes.ts b/bridge/src/routes/personnes.ts new file mode 100644 index 0000000..313c51b --- /dev/null +++ b/bridge/src/routes/personnes.ts @@ -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 = {}; + 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)), + ), + }, + }, + }); +}); diff --git a/bridge/src/routes/projets.ts b/bridge/src/routes/projets.ts new file mode 100644 index 0000000..153c286 --- /dev/null +++ b/bridge/src/routes/projets.ts @@ -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 = {}; + 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()), + }, + }); +}); diff --git a/bridge/tests/helpers/fake-repos.ts b/bridge/tests/helpers/fake-repos.ts new file mode 100644 index 0000000..2b6a0f7 --- /dev/null +++ b/bridge/tests/helpers/fake-repos.ts @@ -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 { + items: T[]; + meta: { page: number; per_page: number; total: number; total_pages: number }; +} + +class FakeReadRepo { + constructor( + private readonly entityName: string, + public store: T[] = [], + ) {} + + list(): Promise> { + return Promise.resolve({ + items: this.store, + meta: { page: 1, per_page: 200, total: this.store.length, total_pages: 1 }, + }); + } + + get(id: number): Promise { + 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 { + 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 { + 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 & RepoSet['personnes']; + formations: FakeReadRepo & RepoSet['formations']; + blocs: FakeReadRepo & RepoSet['blocs']; + modules: FakeReadRepo & RepoSet['modules']; + attributions: FakeAttributionRepo & RepoSet['attributions']; + clients: FakeReadRepo & RepoSet['clients']; + projets: FakeReadRepo & RepoSet['projets']; + taches: FakeReadRepo & 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', stores.personnes ?? []), + formations: new FakeReadRepo('Formation', stores.formations ?? []), + blocs: new FakeReadRepo('Bloc', stores.blocs ?? []), + modules: new FakeReadRepo('Module', stores.modules ?? []), + attributions: new FakeAttributionRepo(stores.attributions ?? []), + clients: new FakeReadRepo('Client', stores.clients ?? []), + projets: new FakeReadRepo('Projet', stores.projets ?? []), + taches: new FakeReadRepo('Tache', stores.taches ?? []), + interventions: new FakeInterventionRepo(stores.interventions ?? []), + } as unknown as FakeReposBundle; +} diff --git a/bridge/tests/helpers/fixtures.ts b/bridge/tests/helpers/fixtures.ts new file mode 100644 index 0000000..76af2e1 --- /dev/null +++ b/bridge/tests/helpers/fixtures.ts @@ -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(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', + }); +} diff --git a/bridge/tests/helpers/test-app.ts b/bridge/tests/helpers/test-app.ts new file mode 100644 index 0000000..d56c80c --- /dev/null +++ b/bridge/tests/helpers/test-app.ts @@ -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(); + 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; +} diff --git a/bridge/tests/middleware/auth.test.ts b/bridge/tests/middleware/auth.test.ts new file mode 100644 index 0000000..1666ca3 --- /dev/null +++ b/bridge/tests/middleware/auth.test.ts @@ -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(); + 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'); + }); +}); diff --git a/bridge/tests/repos/baserow-repo.test.ts b/bridge/tests/repos/baserow-repo.test.ts new file mode 100644 index 0000000..b6a48a5 --- /dev/null +++ b/bridge/tests/repos/baserow-repo.test.ts @@ -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): BaserowClient { + return { + listRows: vi.fn( + (tableId: number): Promise => + Promise.resolve({ + count: rowsByTable[tableId]?.length ?? 0, + next: null, + previous: null, + results: rowsByTable[tableId] ?? [], + }), + ), + getRow: vi.fn((tableId: number, rowId: number): Promise => { + 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'); + }); +}); diff --git a/bridge/tests/routes/attributions.test.ts b/bridge/tests/routes/attributions.test.ts new file mode 100644 index 0000000..c42ef9a --- /dev/null +++ b/bridge/tests/routes/attributions.test.ts @@ -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, + id: number, + body: Record, +) { + 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); + }); +}); diff --git a/bridge/tests/routes/formations.test.ts b/bridge/tests/routes/formations.test.ts new file mode 100644 index 0000000..d4a9f43 --- /dev/null +++ b/bridge/tests/routes/formations.test.ts @@ -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); + }); +}); diff --git a/bridge/tests/routes/interventions.test.ts b/bridge/tests/routes/interventions.test.ts new file mode 100644 index 0000000..1fddf2d --- /dev/null +++ b/bridge/tests/routes/interventions.test.ts @@ -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, body: Record) { + 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); + }); +}); diff --git a/bridge/tests/routes/modules.test.ts b/bridge/tests/routes/modules.test.ts new file mode 100644 index 0000000..504fb2a --- /dev/null +++ b/bridge/tests/routes/modules.test.ts @@ -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, + body: Record, + 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); + }); +}); diff --git a/bridge/tests/routes/personnes.test.ts b/bridge/tests/routes/personnes.test.ts new file mode 100644 index 0000000..fbd8378 --- /dev/null +++ b/bridge/tests/routes/personnes.test.ts @@ -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'); + }); +}); diff --git a/bridge/tests/routes/projets.test.ts b/bridge/tests/routes/projets.test.ts new file mode 100644 index 0000000..62d53ed --- /dev/null +++ b/bridge/tests/routes/projets.test.ts @@ -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); + }); +});