// @vitest-environment node import { copyFile, mkdir, mkdtemp, rename, rm, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { HostApiContext } from '../../electron/api/context'; import { dispatchHostApiRequest } from '../../electron/api/host-api-dispatcher'; import { createCodingProjectConfigV2, isCanonicalCodingProjectId, normalizeCodingProjectConfigV2, readCodingProjectConfigV2, writeCodingProjectConfigV2, } from '../../electron/coding-projects/project-config'; import { CodingProjectService } from '../../electron/coding-projects/project-service'; import { createCodingProjectStore, createLocalCodingProject, createMemoryCodingProjectStorage, normalizeCodingProjectPath, } from '../../electron/coding-projects/project-store'; const roots: string[] = []; const CREATED = '2026-08-26T00:00:00.000Z'; const UPDATED = '2026-08-26T00:01:00.000Z'; const PROJECT_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; const NEXT_PROJECT_ID = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; afterEach(async () => { await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); }); async function makeRoot(prefix = 'makelore-project-identity-'): Promise { const root = await mkdtemp(path.join(tmpdir(), prefix)); roots.push(root); return root; } function makeStore(localId = 'local-project-id') { return createCodingProjectStore(createMemoryCodingProjectStorage(), { createId: () => localId, now: () => CREATED, }); } describe('coding project durable identity', () => { it('preserves canonical identity and rejects noncanonical values without backfill', () => { const initialized = createCodingProjectConfigV2(CREATED, 'custom', PROJECT_ID); expect(initialized.projectId).toBe(PROJECT_ID); expect(isCanonicalCodingProjectId(PROJECT_ID)).toBe(true); const legacy = createCodingProjectConfigV2(CREATED, 'custom'); expect(legacy).not.toHaveProperty('projectId'); expect(normalizeCodingProjectConfigV2(legacy)).not.toHaveProperty('projectId'); for (const projectId of [ PROJECT_ID.toUpperCase(), `{${PROJECT_ID}}`, PROJECT_ID.replaceAll('-', ''), ` ${PROJECT_ID}`, 'not-a-project-id', null, ]) { expect(() => normalizeCodingProjectConfigV2({ ...legacy, projectId })) .toThrow('Project identity is invalid'); } }); it('requires an explicit create/bind choice and writes no config on invalid bind', async () => { const projectPath = await makeRoot(); const store = makeStore(); const createProjectId = vi.fn(() => PROJECT_ID); const identityChanging = vi.fn(); const projects = new CodingProjectService(store, { createProjectId, onProjectIdentityChanging: identityChanging, }); const created = await projects.createProject({ projectPath, identity: { kind: 'create' }, }); expect(created.config.projectId).toBe(PROJECT_ID); expect(createProjectId).toHaveBeenCalledTimes(1); expect(identityChanging).not.toHaveBeenCalled(); const invalidPath = path.join(await makeRoot('makelore-project-identity-parent-'), 'invalid'); await expect(projects.createProject({ projectPath: invalidPath, identity: { kind: 'bind', projectId: 'not-canonical' }, })).rejects.toMatchObject({ code: 'CODING_PROJECT_REQUEST_INVALID', status: 400, }); await expect(readCodingProjectConfigV2(invalidPath)).resolves.toEqual({ status: 'missing' }); await expect(stat(invalidPath)).rejects.toMatchObject({ code: 'ENOENT' }); const boundPath = await makeRoot(); const bound = await projects.createProject({ projectPath: boundPath, identity: { kind: 'bind', projectId: NEXT_PROJECT_ID }, }); expect(bound.config.projectId).toBe(NEXT_PROJECT_ID); expect(createProjectId).toHaveBeenCalledTimes(1); }); it('resolves a legacy identity once and invalidates before writing resources', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, now: CREATED }, store); const events: string[] = []; const projects = new CodingProjectService(store, { createProjectId: () => PROJECT_ID, now: () => UPDATED, onProjectIdentityChanging: () => { events.push('invalidate'); }, writeConfig: async (filePath, value) => { events.push('write'); await writeCodingProjectConfigV2(filePath, value); }, onResourcesChanged: () => { events.push('resources'); }, }); expect(local.config).not.toHaveProperty('projectId'); const resolved = await projects.resolveProjectIdentity(local.project.id, { kind: 'create' }); expect(resolved.config.projectId).toBe(PROJECT_ID); expect(events).toEqual(['invalidate', 'write', 'resources']); await expect(projects.resolveProjectIdentity(local.project.id, { kind: 'bind', projectId: NEXT_PROJECT_ID, })).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409, }); expect((await projects.getConfig(local.project.id)).config.projectId).toBe(PROJECT_ID); }); it('keeps ordinary saves immutable and supports a confirmed independent copy', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, projectId: PROJECT_ID, now: CREATED, }, store); const invalidating = vi.fn(); const projects = new CodingProjectService(store, { createProjectId: () => NEXT_PROJECT_ID, now: () => UPDATED, onProjectIdentityChanging: invalidating, }); const current = await projects.getConfig(local.project.id); await expect(projects.saveConfig(local.project.id, { ...current.config, projectId: undefined, })).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409 }); await expect(projects.saveConfig(local.project.id, { ...current.config, projectId: NEXT_PROJECT_ID, })).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_IMMUTABLE', status: 409 }); await expect(projects.makeProjectIndependentCopy( local.project.id, false as unknown as true, )).rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED', status: 400, }); const independent = await projects.makeProjectIndependentCopy(local.project.id, true); expect(independent.config.projectId).toBe(NEXT_PROJECT_ID); expect(independent.config.updatedAt).toBe(UPDATED); expect(invalidating).toHaveBeenCalledWith(local.project, PROJECT_ID, NEXT_PROJECT_ID); }); it('does not write a legacy identity when preview invalidation fails', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath }, store); const projects = new CodingProjectService(store, { createProjectId: () => PROJECT_ID, onProjectIdentityChanging: () => { throw new Error('preview close failed'); }, }); await expect(projects.resolveProjectIdentity(local.project.id, { kind: 'create' })) .rejects.toThrow('preview close failed'); const current = await readCodingProjectConfigV2(projectPath); expect(current).toMatchObject({ status: 'valid', config: { projectType: 'custom' } }); if (current.status === 'valid') expect(current.config).not.toHaveProperty('projectId'); }); it('retains identity across a move and raw folder copy', async () => { const parent = await makeRoot('makelore-project-identity-move-parent-'); const originalPath = path.join(parent, 'original'); await mkdir(originalPath); const store = makeStore(); const original = await createLocalCodingProject({ projectPath: originalPath, projectId: PROJECT_ID, now: CREATED, }, store); const movedPath = path.join(parent, 'moved'); await rename(originalPath, movedPath); const movedStore = makeStore('moved-local-project-id'); const moved = await movedStore.openFolder(movedPath); expect((await readCodingProjectConfigV2(moved.path)).status).toBe('valid'); const movedConfig = await readCodingProjectConfigV2(moved.path); expect(movedConfig).toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID } }); const copiedPath = path.join(parent, 'copied'); await mkdir(path.join(copiedPath, '.niancode'), { recursive: true }); await copyFile( path.join(movedPath, '.niancode', 'project.json'), path.join(copiedPath, '.niancode', 'project.json'), ); const copied = await readCodingProjectConfigV2(copiedPath); expect(copied).toMatchObject({ status: 'valid', config: { projectId: PROJECT_ID } }); expect(original.project.id).not.toBe(moved.id); }); it('centralizes the active real path and durable identity checks', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath, projectId: PROJECT_ID }, store); const projects = new CodingProjectService(store); const active = await projects.requireActiveRealProjectWithIdentity(projectPath); expect(active).toMatchObject({ project: local.project, projectId: PROJECT_ID }); expect(normalizeCodingProjectPath(active.path)).toBe(normalizeCodingProjectPath(local.project.path)); const otherPath = await makeRoot('makelore-project-identity-other-'); await expect(projects.requireActiveRealProjectWithIdentity(otherPath)).rejects.toMatchObject({ code: 'CODING_ACTIVE_PROJECT_PATH_MISMATCH', status: 409, }); const legacyPath = await makeRoot('makelore-project-identity-legacy-'); const legacyStore = makeStore(); await createLocalCodingProject({ projectPath: legacyPath }, legacyStore); await expect(new CodingProjectService(legacyStore).requireActiveRealProjectWithIdentity()) .rejects.toMatchObject({ code: 'CODING_PROJECT_IDENTITY_REQUIRED', status: 409 }); }); it('exposes identity resolution and independent-copy through the Host routes', async () => { const projectPath = await makeRoot(); const store = makeStore(); const local = await createLocalCodingProject({ projectPath }, store); const generatedIds = [PROJECT_ID, NEXT_PROJECT_ID]; const projects = new CodingProjectService(store, { createProjectId: () => generatedIds.shift() ?? 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', }); const context = { codingProducts: { projects, conversations: {} }, } as unknown as HostApiContext; const resolved = await dispatchHostApiRequest(context, { path: '/api/coding/projects/identity', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ localProjectId: local.project.id, identity: { kind: 'create' } }), }); expect(resolved).toMatchObject({ status: 200, json: { snapshot: { config: { projectId: PROJECT_ID } } }, }); const independent = await dispatchHostApiRequest(context, { path: '/api/coding/projects/identity/independent-copy', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ localProjectId: local.project.id, confirmed: true }), }); expect(independent.status).toBe(200); expect((independent.json as { snapshot: { config: { projectId: string } } }).snapshot.config.projectId) .not.toBe(PROJECT_ID); const rejected = await dispatchHostApiRequest(context, { path: '/api/coding/projects/identity/independent-copy', method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ localProjectId: local.project.id, confirmed: false }), }); expect(rejected).toMatchObject({ status: 400, json: { code: 'CODING_PROJECT_IDENTITY_CONFIRMATION_REQUIRED' }, }); }); });