Address PR #227 re-review (comment 2193). - Stability: `updatePageId`/`updateAlias` now `executeTakeFirstOrThrow`, so a row reaped by a concurrent `removeAlias` between the read and the UPDATE (READ COMMITTED) raises `NoResultError` instead of returning `undefined`. The service maps that to a retryable `ConflictException` (`ALIAS_PAGE_RACE`) rather than a 200-without-alias (swap) or a generic 400 from `undefined.id` (rename). Tests cover both branches. - Simplification: drop the redundant secondary "unexpected unique index" warn and the now-unused `UNIQUE_ALIAS_INDEX` const (the constraint name is already logged unconditionally; both index branches still distinguish "Alias already taken" vs ALIAS_PAGE_RACE). - Architecture: extract `isUniqueViolation`/`violatedConstraint` into database/utils.ts; adopt them in the share-alias service and favorite.repo (the bare `23505` check). ai-agent-roles (#222) is on a separate unmerged branch and should adopt them after #227 merges (noted at the helpers). Helper unit test added. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.7 KiB
TypeScript
186 lines
6.7 KiB
TypeScript
import { ShareAliasRepo } from './share-alias.repo';
|
|
import type { KyselyDB } from '../../types/kysely.types';
|
|
|
|
/**
|
|
* SQL-shape unit tests for ShareAliasRepo. A live Postgres is out of scope;
|
|
* instead we spy on the Kysely builder to assert each method pins the
|
|
* workspace scope (so a name in one workspace can never resolve another's
|
|
* page) and threads the right columns.
|
|
*/
|
|
describe('ShareAliasRepo', () => {
|
|
function makeSelectRepo(result: unknown) {
|
|
const where = jest.fn();
|
|
const orderBy = jest.fn();
|
|
const builder: any = {
|
|
select: jest.fn(() => builder),
|
|
where: jest.fn((...args: unknown[]) => {
|
|
where(...args);
|
|
return builder;
|
|
}),
|
|
orderBy: jest.fn((...args: unknown[]) => {
|
|
orderBy(...args);
|
|
return builder;
|
|
}),
|
|
executeTakeFirst: jest.fn().mockResolvedValue(result),
|
|
};
|
|
const db = { selectFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
|
return { repo: new ShareAliasRepo(db), db, where, orderBy, builder };
|
|
}
|
|
|
|
it('findByAliasAndWorkspace scopes by alias AND workspace', async () => {
|
|
const row = { id: 'a-1', alias: 'foo', workspaceId: 'ws-1' };
|
|
const { repo, db, where } = makeSelectRepo(row);
|
|
|
|
const res = await repo.findByAliasAndWorkspace('foo', 'ws-1');
|
|
|
|
expect(res).toBe(row);
|
|
expect(db.selectFrom).toHaveBeenCalledWith('shareAliases');
|
|
expect(where).toHaveBeenCalledWith('alias', '=', 'foo');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
});
|
|
|
|
it('findByPageId scopes by page AND workspace, deterministically ordered', async () => {
|
|
const { repo, where, orderBy } = makeSelectRepo(undefined);
|
|
await repo.findByPageId('p-1', 'ws-1');
|
|
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
// Explicit ORDER BY removes the nondeterministic heap order for any legacy
|
|
// duplicate rows (newest createdAt wins, id as a stable tiebreak).
|
|
expect(orderBy).toHaveBeenCalledWith('createdAt', 'desc');
|
|
expect(orderBy).toHaveBeenCalledWith('id', 'desc');
|
|
});
|
|
|
|
it('insert writes the provided columns and returns the row', async () => {
|
|
const values = jest.fn();
|
|
const inserted = { id: 'a-1' };
|
|
const builder: any = {
|
|
values: jest.fn((v: unknown) => {
|
|
values(v);
|
|
return builder;
|
|
}),
|
|
returning: jest.fn(() => builder),
|
|
executeTakeFirst: jest.fn().mockResolvedValue(inserted),
|
|
};
|
|
const db = { insertInto: jest.fn(() => builder) } as unknown as KyselyDB;
|
|
const repo = new ShareAliasRepo(db);
|
|
|
|
const res = await repo.insert({
|
|
workspaceId: 'ws-1',
|
|
alias: 'foo',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
});
|
|
|
|
expect(db.insertInto).toHaveBeenCalledWith('shareAliases');
|
|
expect(values).toHaveBeenCalledWith({
|
|
workspaceId: 'ws-1',
|
|
alias: 'foo',
|
|
pageId: 'p-1',
|
|
creatorId: 'u-1',
|
|
});
|
|
expect(res).toBe(inserted);
|
|
});
|
|
|
|
it('updatePageId retargets a single row scoped by id + workspace', async () => {
|
|
const set = jest.fn();
|
|
const where = jest.fn();
|
|
const builder: any = {
|
|
set: jest.fn((s: unknown) => {
|
|
set(s);
|
|
return builder;
|
|
}),
|
|
where: jest.fn((...args: unknown[]) => {
|
|
where(...args);
|
|
return builder;
|
|
}),
|
|
returning: jest.fn(() => builder),
|
|
// Retarget uses executeTakeFirstOrThrow so a row reaped by a concurrent
|
|
// delete (0 rows matched) raises NoResultError instead of returning undefined.
|
|
executeTakeFirstOrThrow: jest.fn().mockResolvedValue({ id: 'a-1' }),
|
|
};
|
|
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
|
|
const repo = new ShareAliasRepo(db);
|
|
|
|
await repo.updatePageId('a-1', 'p-2', 'ws-1');
|
|
|
|
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
|
|
expect(set.mock.calls[0][0].pageId).toBe('p-2');
|
|
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
|
|
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
});
|
|
|
|
it('updateAlias renames a single row scoped by id + workspace', async () => {
|
|
const set = jest.fn();
|
|
const where = jest.fn();
|
|
const builder: any = {
|
|
set: jest.fn((s: unknown) => {
|
|
set(s);
|
|
return builder;
|
|
}),
|
|
where: jest.fn((...args: unknown[]) => {
|
|
where(...args);
|
|
return builder;
|
|
}),
|
|
returning: jest.fn(() => builder),
|
|
// Rename uses executeTakeFirstOrThrow so a row reaped by a concurrent
|
|
// delete (0 rows matched) raises NoResultError instead of returning undefined.
|
|
executeTakeFirstOrThrow: jest
|
|
.fn()
|
|
.mockResolvedValue({ id: 'a-1', alias: 'ted' }),
|
|
};
|
|
const db = { updateTable: jest.fn(() => builder) } as unknown as KyselyDB;
|
|
const repo = new ShareAliasRepo(db);
|
|
|
|
const res = await repo.updateAlias('a-1', 'ted', 'ws-1');
|
|
|
|
expect(db.updateTable).toHaveBeenCalledWith('shareAliases');
|
|
expect(set.mock.calls[0][0].alias).toBe('ted');
|
|
expect(set.mock.calls[0][0].updatedAt).toBeInstanceOf(Date);
|
|
// a rename must NOT touch page_id (the page's pointer is preserved)
|
|
expect(set.mock.calls[0][0]).not.toHaveProperty('pageId');
|
|
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
expect(res).toMatchObject({ alias: 'ted' });
|
|
});
|
|
|
|
it('deleteOthersForPage reaps every row for the page except keepId', async () => {
|
|
const where = jest.fn();
|
|
const builder: any = {
|
|
where: jest.fn((...args: unknown[]) => {
|
|
where(...args);
|
|
return builder;
|
|
}),
|
|
execute: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
|
const repo = new ShareAliasRepo(db);
|
|
|
|
await repo.deleteOthersForPage('p-1', 'a-keep', 'ws-1');
|
|
|
|
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
|
|
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
expect(where).toHaveBeenCalledWith('id', '!=', 'a-keep');
|
|
});
|
|
|
|
it('delete scopes by id + workspace', async () => {
|
|
const where = jest.fn();
|
|
const builder: any = {
|
|
where: jest.fn((...args: unknown[]) => {
|
|
where(...args);
|
|
return builder;
|
|
}),
|
|
execute: jest.fn().mockResolvedValue(undefined),
|
|
};
|
|
const db = { deleteFrom: jest.fn(() => builder) } as unknown as KyselyDB;
|
|
const repo = new ShareAliasRepo(db);
|
|
|
|
await repo.delete('a-1', 'ws-1');
|
|
|
|
expect(db.deleteFrom).toHaveBeenCalledWith('shareAliases');
|
|
expect(where).toHaveBeenCalledWith('id', '=', 'a-1');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
});
|
|
});
|