Files
arboretum/packages/server/test/group-manager.test.ts
Johan LEROY e38b3a5d5e feat: groupes de travail (P5)
Ajoute la notion de groupe — collection nommée de repos (membership légère
persistée, many-to-many) — pour piloter plusieurs repos en simultané :

- DB : migration #5 (tables groups + group_repos, FK ON DELETE CASCADE).
- GroupManager (synchrone, EventEmitter) + routes REST /api/v1/groups ;
  ne persiste que la membership, worktrees/sessions filtrés par repoId côté client.
- Protocole : GroupSummary, DTOs, topic WS « groups », events group_update/removed
  (additifs, PROTOCOL_VERSION inchangé).
- Web : store groups, GroupsListView, GroupView (réutilise RepoSection),
  grille multi-terminaux (TerminalGrid/TerminalCell), action « feature cross-repo »
  (orchestration client, tolérante aux échecs partiels), routes + i18n EN/FR.
- Tests : group-manager.test.ts + extension protocol.test.ts ; acceptance-p5.mjs.
2026-06-18 10:03:19 +02:00

101 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { GroupManager } from '../src/core/group-manager.js';
import { openDb, type Db } from '../src/db/index.js';
/** Insère un repo minimal directement (le GroupManager n'a besoin que de repos.id). */
function insertRepo(db: Db, id: string, path: string): void {
db.prepare(
"INSERT INTO repos (id, path, label, default_branch, post_create_hooks, pre_trust, created_at) VALUES (?, ?, ?, NULL, '[]', 0, ?)",
).run(id, path, id, new Date().toISOString());
}
describe('GroupManager', () => {
let db: Db;
let gm: GroupManager;
beforeEach(() => {
db = openDb(':memory:');
insertRepo(db, 'repo-a', '/tmp/a');
insertRepo(db, 'repo-b', '/tmp/b');
gm = new GroupManager(db);
});
it('createGroup : groupe vide, persisté, événement group_update émis', () => {
const spy = vi.fn();
gm.on('group_update', spy);
const g = gm.createGroup({ label: 'Sprint 42' });
expect(g).toMatchObject({ label: 'Sprint 42', description: null, color: null, repoIds: [] });
expect(gm.listGroups()).toHaveLength(1);
expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: g.id }));
});
it('createGroup : repoIds initiaux ordonnés et dédoublonnés', () => {
const g = gm.createGroup({ label: 'Eco', repoIds: ['repo-b', 'repo-a', 'repo-b'] });
expect(g.repoIds).toEqual(['repo-b', 'repo-a']);
});
it('createGroup : repoId inexistant → 404', () => {
expect(() => gm.createGroup({ label: 'X', repoIds: ['nope'] })).toThrow(
expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }),
);
expect(gm.listGroups()).toHaveLength(0); // rollback de la transaction
});
it('createGroup : validations (label vide / trop long, couleur invalide)', () => {
expect(() => gm.createGroup({ label: ' ' })).toThrow(expect.objectContaining({ statusCode: 400 }));
expect(() => gm.createGroup({ label: 'x'.repeat(101) })).toThrow(expect.objectContaining({ statusCode: 400 }));
expect(() => gm.createGroup({ label: 'X', color: 'red' })).toThrow(expect.objectContaining({ statusCode: 400 }));
expect(gm.createGroup({ label: 'X', color: '#4f46e5' }).color).toBe('#4f46e5');
});
it('getGroup : id inconnu → 404', () => {
expect(() => gm.getGroup('nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
});
it('updateGroup : modifie label/description/color et bump updatedAt', () => {
const g = gm.createGroup({ label: 'A' });
const updated = gm.updateGroup(g.id, { label: 'B', description: 'hello', color: '#000000' });
expect(updated).toMatchObject({ label: 'B', description: 'hello', color: '#000000' });
expect(gm.updateGroup(g.id, { description: '' }).description).toBeNull(); // '' → null
});
it('addRepo : idempotent (PRIMARY KEY), émet group_update', () => {
const g = gm.createGroup({ label: 'A' });
const spy = vi.fn();
gm.on('group_update', spy);
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']);
expect(gm.addRepo(g.id, 'repo-a').repoIds).toEqual(['repo-a']); // pas de doublon
expect(gm.addRepo(g.id, 'repo-b').repoIds).toEqual(['repo-a', 'repo-b']);
expect(spy).toHaveBeenCalledTimes(3);
});
it('addRepo : groupe ou repo inexistant → 404', () => {
const g = gm.createGroup({ label: 'A' });
expect(() => gm.addRepo('nope', 'repo-a')).toThrow(expect.objectContaining({ statusCode: 404, code: 'NOT_FOUND' }));
expect(() => gm.addRepo(g.id, 'nope')).toThrow(expect.objectContaining({ statusCode: 404, code: 'REPO_NOT_FOUND' }));
});
it('removeRepo : idempotent (retrait dun repo absent = no-op)', () => {
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a'] });
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]);
expect(gm.removeRepo(g.id, 'repo-a').repoIds).toEqual([]); // déjà absent → succès
});
it('deleteGroup : true + group_removed ; id inconnu → false', () => {
const g = gm.createGroup({ label: 'A' });
const spy = vi.fn();
gm.on('group_removed', spy);
expect(gm.deleteGroup(g.id)).toBe(true);
expect(spy).toHaveBeenCalledWith(g.id);
expect(gm.deleteGroup(g.id)).toBe(false);
expect(gm.listGroups()).toHaveLength(0);
});
it('CASCADE : supprimer un repo purge la membership (PRAGMA foreign_keys = ON)', () => {
const g = gm.createGroup({ label: 'A', repoIds: ['repo-a', 'repo-b'] });
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-a', 'repo-b']);
db.prepare('DELETE FROM repos WHERE id = ?').run('repo-a');
expect(gm.getGroup(g.id).repoIds).toEqual(['repo-b']);
});
});