Add a retargetable, human-readable vanity link namespace /l/<alias> that sits alongside the untouched /share/... routes. - New share_aliases table (workspace-scoped, UNIQUE(workspace_id, alias), page_id nullable ON DELETE SET NULL so the address outlives its target). - ShareAliasRepo + ShareAliasService (create / no-op / 409 reassign guard / availability / request-time readable-target resolution through the single existing share boundary). - Public ShareAliasRedirectController (GET /l/:alias) issues a 302 (never 301, the target is mutable) to the canonical /share/:key/p/:slug page; unknown / dangling / no-longer-readable aliases serve the SPA index with no leak. 'l/:alias' excluded from the global /api prefix. - Authenticated ShareAliasController (set/remove/availability/for-page). - Shared ASCII-only normalize/validate util (server + client copies). - Client: Custom address block in the share modal (live normalize + debounced availability + copy + reassign confirmation dialog). - Unit tests: util, repo SQL-shape, service semantics, migration/entity sanity (server jest) + client alias util (vitest). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
121 lines
4.0 KiB
TypeScript
121 lines
4.0 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 builder: any = {
|
|
select: jest.fn(() => builder),
|
|
where: jest.fn((...args: unknown[]) => {
|
|
where(...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, 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', async () => {
|
|
const { repo, where } = makeSelectRepo(undefined);
|
|
await repo.findByPageId('p-1', 'ws-1');
|
|
expect(where).toHaveBeenCalledWith('pageId', '=', 'p-1');
|
|
expect(where).toHaveBeenCalledWith('workspaceId', '=', 'ws-1');
|
|
});
|
|
|
|
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),
|
|
executeTakeFirst: 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('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');
|
|
});
|
|
});
|