import { describe, it, expect, beforeEach } from 'vitest'; import { mkdtemp } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { writeCache, type CacheFile } from '../../src/lib/cache.js'; import { parseTargetProject, resolveTarget } from '../../src/lib/resolve-target.js'; describe('parseTargetProject', () => { it('accepts the agenda literal', () => { const r = parseTargetProject('agenda'); expect(r).toEqual({ agenda: true }); }); it('accepts qualified /', () => { const r = parseTargetProject('victor/books'); expect(r).toEqual({ agenda: false, owner: 'victor', repo: 'books' }); }); it('rejects bare repo name with hint', () => { const r = parseTargetProject('books'); expect(r).toEqual({ error: expect.stringContaining('must be qualified') }); const err = (r as { error: string }).error; expect(err).toContain('use `/`'); expect(err).toContain('victor/books'); expect(err).toContain('Got: `books`'); }); it('rejects the legacy _meta literal (replaced by `agenda` in 2.0)', () => { const r = parseTargetProject('_meta'); expect(r).toEqual({ error: expect.stringContaining('must be qualified') }); expect((r as { error: string }).error).toContain('Got: `_meta`'); }); it('rejects empty string', () => { const r = parseTargetProject(''); expect('error' in r).toBe(true); }); it('rejects leading slash', () => { const r = parseTargetProject('/books'); expect('error' in r).toBe(true); }); it('rejects trailing slash', () => { const r = parseTargetProject('victor/'); expect('error' in r).toBe(true); }); it('rejects more than one slash', () => { const r = parseTargetProject('a/b/c'); expect('error' in r).toBe(true); }); it('rejects whitespace inside', () => { const r = parseTargetProject('victor /books'); expect('error' in r).toBe(true); }); it('rejects empty owner segment', () => { const r = parseTargetProject('//books'); expect('error' in r).toBe(true); }); }); describe('resolveTarget', () => { let cacheFile: string; const baseFixture: CacheFile = { schemaVersion: 3, synced_at: '2026-05-08T00:00:00Z', synced_from: 'https://g', machine: 'm', projects: [ { name: 'victor/books', default_branch: 'main', fetched_at: '2026-05-08T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, { name: 'OpeItcLoc03/common', default_branch: 'master', fetched_at: '2026-05-08T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], errors: [], }; beforeEach(async () => { const dir = await mkdtemp(join(tmpdir(), 'resolve-target-')); cacheFile = join(dir, 'cache.json'); await writeCache(cacheFile, baseFixture); }); it('returns owner+repo+branch for qualified target in cache', async () => { const r = await resolveTarget(cacheFile, 'victor/books', 'agenda', 'OpeItcLoc03'); expect(r).toEqual({ owner: 'victor', repo: 'books', branch: 'main', isAgenda: false }); }); it('routes agenda to fallbackOwner + agendaRepo', async () => { await writeCache(cacheFile, { ...baseFixture, projects: [ ...baseFixture.projects, { name: 'agenda', default_branch: 'main', fetched_at: '2026-05-08T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], }); const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03'); expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true }); }); it('rejects bare name with hint message', async () => { const r = await resolveTarget(cacheFile, 'books', 'agenda', 'OpeItcLoc03'); expect('error' in r).toBe(true); expect((r as { error: string }).error).toContain('must be qualified'); }); it('errors on cache miss for qualified target', async () => { const r = await resolveTarget(cacheFile, 'ghost/repo', 'agenda', 'OpeItcLoc03'); expect('error' in r).toBe(true); expect((r as { error: string }).error).toContain('not in cache'); }); it('qualified-unknown owner returns cache-miss error, not shape-error', async () => { const r = await resolveTarget(cacheFile, 'ekassir/books', 'agenda', 'OpeItcLoc03'); expect('error' in r).toBe(true); const err = (r as { error: string }).error; expect(err).not.toContain('must be qualified'); expect(err).toContain('not in cache'); expect(err).toContain('ekassir/books'); }); it('errors on missing agenda entry in cache', async () => { const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03'); expect('error' in r).toBe(true); expect((r as { error: string }).error).toContain('agenda not in cache'); }); it('errors when agenda is local-only', async () => { await writeCache(cacheFile, { ...baseFixture, projects: [ ...baseFixture.projects, { name: 'agenda', default_branch: 'local', fetched_at: '2026-05-08T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], }); const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03'); expect('error' in r).toBe(true); expect((r as { error: string }).error).toContain('Gitea repo "agenda" not found'); }); it('errors when cache missing entirely', async () => { const r = await resolveTarget('/nonexistent/cache.json', 'victor/books', 'agenda', 'OpeItcLoc03'); expect('error' in r).toBe(true); expect((r as { error: string }).error).toContain('cache missing'); }); it('routes agenda to agendaOwner override (qualified agenda_tasks_repo)', async () => { await writeCache(cacheFile, { ...baseFixture, projects: [ ...baseFixture.projects, { name: 'agenda', default_branch: 'main', fetched_at: '2026-05-08T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], }); const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', 'victor'); expect(r).toEqual({ owner: 'victor', repo: 'agenda', branch: 'main', isAgenda: true }); }); it('falls back to fallbackOwner when agendaOwner is undefined (bare agenda_tasks_repo)', async () => { await writeCache(cacheFile, { ...baseFixture, projects: [ ...baseFixture.projects, { name: 'agenda', default_branch: 'main', fetched_at: '2026-05-08T00:00:00Z', active_tasks: [], all_tasks_count: 0, raw: '', }, ], }); const r = await resolveTarget(cacheFile, 'agenda', 'agenda', 'OpeItcLoc03', undefined); expect(r).toEqual({ owner: 'OpeItcLoc03', repo: 'agenda', branch: 'main', isAgenda: true }); }); });