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']); }); it('deleteGroup : désorpheline le group_id des sessions de groupe (P6)', () => { const g = gm.createGroup({ label: 'A' }); // session de groupe liée à g (insérée directement, comme le ferait PtyManager.spawn). db.prepare( 'INSERT INTO sessions (id, cwd, command, created_at, group_id) VALUES (?, ?, ?, ?, ?)', ).run('sess-1', '/tmp/a', 'claude', new Date().toISOString(), g.id); expect(gm.deleteGroup(g.id)).toBe(true); const row = db.prepare('SELECT group_id FROM sessions WHERE id = ?').get('sess-1') as { group_id: string | null }; expect(row.group_id).toBeNull(); }); });