293 lines
12 KiB
TypeScript
293 lines
12 KiB
TypeScript
// @vitest-environment node
|
|
|
|
import { copyFile, mkdir, mkdtemp, realpath, 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<string> {
|
|
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, '.makelore'), { recursive: true });
|
|
await copyFile(
|
|
path.join(movedPath, '.makelore', 'project.json'),
|
|
path.join(copiedPath, '.makelore', '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(await realpath(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' },
|
|
});
|
|
});
|
|
});
|