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. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
100
packages/server/test/group-manager.test.ts
Normal file
100
packages/server/test/group-manager.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
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 d’un 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']);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user