Add link search

This commit is contained in:
2026-06-20 22:08:52 +10:00
parent a3ba6dd395
commit b6212d8b3e
9 changed files with 780 additions and 14 deletions
+40
View File
@@ -160,6 +160,46 @@ describe('api client', () => {
expect(calls[0].path).toBe('/api/links/public');
});
it('listPublicLinks appends q query parameter when search is provided', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks('opencode');
expect(calls[0].path).toBe('/api/links/public?q=opencode');
});
it('listPublicLinks encodes special characters in the search query', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks('go links');
expect(calls[0].path).toBe('/api/links/public?q=go%20links');
});
it('listPublicLinks ignores blank/whitespace-only queries', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPublicLinks(' ');
expect(calls[0].path).toBe('/api/links/public');
});
it('listPrivateLinks appends q query parameter when search is provided', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPrivateLinks('docs');
expect(calls[0].path).toBe('/api/links/private?q=docs');
});
it('listPrivateLinks ignores blank/whitespace-only queries', async () => {
const { calls } = mockFetch(() => ({ body: { links: [] } }));
await listPrivateLinks(' ');
expect(calls[0].path).toBe('/api/links/private');
});
it('public detail endpoints use the readable /api/links/public routes', async () => {
const { calls } = mockFetch(() => ({ body: { link: {}, stats: [], history: [] } }));
+139 -5
View File
@@ -66,15 +66,30 @@ class FakeD1Database {
return this.sessions.find((candidate) => candidate.session_token_hash === hash) ?? null;
}
listLinks(scope: LinkScope, ownerUserId?: string): LinkRow[] {
listLinks(scope: LinkScope, ownerUserId?: string, search?: string): LinkRow[] {
const term = search?.toLowerCase();
return this.links
.filter((link) => {
if (link.scope !== scope || link.status !== 'active') {
return false;
}
return scope === 'public' ? true : link.owner_user_id === ownerUserId;
if (scope === 'private' && link.owner_user_id !== ownerUserId) {
return false;
}
if (term && !link.alias.toLowerCase().includes(term)) {
return false;
}
return true;
})
.sort((a, b) => b.updated_at.localeCompare(a.updated_at));
.sort((a, b) => {
if (term) {
const aExact = a.alias.toLowerCase() === term ? 0 : 1;
const bExact = b.alias.toLowerCase() === term ? 0 : 1;
if (aExact !== bExact) return aExact - bExact;
}
if (a.click_count !== b.click_count) return b.click_count - a.click_count;
return b.updated_at.localeCompare(a.updated_at);
});
}
findDuplicate(
@@ -191,13 +206,23 @@ class FakeD1PreparedStatement {
}
async all<T>(): Promise<AllResult<T>> {
const isSearch = this.sql.includes(' LIKE ');
let searchTerm: string | undefined;
if (isSearch) {
if (this.sql.includes("scope='public'")) {
searchTerm = String(this.params[1]);
} else {
searchTerm = String(this.params[2]);
}
}
if (this.sql.includes("scope='public'")) {
return { results: this.db.listLinks('public').map(rowToDbResult) as T[], success: true, meta: {} };
return { results: this.db.listLinks('public', undefined, searchTerm).map(rowToDbResult) as T[], success: true, meta: {} };
}
if (this.sql.includes("scope='private'")) {
return {
results: this.db.listLinks('private', String(this.params[0])).map(rowToDbResult) as T[],
results: this.db.listLinks('private', String(this.params[0]), searchTerm).map(rowToDbResult) as T[],
success: true,
meta: {},
};
@@ -779,3 +804,112 @@ describe('link CRUD API', () => {
await expect(expectJson(missingTarget.response)).resolves.toHaveProperty('error');
});
});
describe('link list ordering and search', () => {
it('lists public links sorted by click_count desc then updated_at desc', async () => {
const { response } = await fetchWorker('/api/links/public', {
links: [
link({ id: 'low', scope: 'public', owner_user_id: null, alias: 'low', click_count: 5, updated_at: '2026-06-20T00:00:03.000Z' }),
link({ id: 'high', scope: 'public', owner_user_id: null, alias: 'high', click_count: 100, updated_at: '2026-06-20T00:00:01.000Z' }),
link({ id: 'mid', scope: 'public', owner_user_id: null, alias: 'mid', click_count: 50, updated_at: '2026-06-20T00:00:02.000Z' }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.links.map((item: { id: string }) => item.id)).toEqual(['high', 'mid', 'low']);
});
it('lists private links sorted by click_count desc', async () => {
const session = await userSession('token-a', 'user_1');
const { response } = await fetchWorker('/api/links/private', {
cookie: cookie('token-a'),
sessions: [session],
links: [
link({ id: 'few', scope: 'private', owner_user_id: 'user_1', alias: 'few', click_count: 2 }),
link({ id: 'many', scope: 'private', owner_user_id: 'user_1', alias: 'many', click_count: 80 }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.links.map((item: { id: string }) => item.id)).toEqual(['many', 'few']);
});
it('searches public links by alias contains and pins exact match at top', async () => {
const { response } = await fetchWorker('/api/links/public?q=op', {
links: [
link({ id: 'popular_contains', scope: 'public', owner_user_id: null, alias: 'opencode', click_count: 500 }),
link({ id: 'exact_op', scope: 'public', owner_user_id: null, alias: 'op', click_count: 10 }),
link({ id: 'other_contains', scope: 'public', owner_user_id: null, alias: 'open-shop', click_count: 200 }),
link({ id: 'unrelated', scope: 'public', owner_user_id: null, alias: 'docs', click_count: 999 }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
const ids = body.links.map((item: { id: string }) => item.id);
expect(ids).toEqual(['exact_op', 'popular_contains', 'other_contains']);
expect(ids).not.toContain('unrelated');
});
it('search is case-insensitive on the query parameter', async () => {
const { response } = await fetchWorker('/api/links/public?q=OPEN', {
links: [
link({ id: 'exact', scope: 'public', owner_user_id: null, alias: 'open', click_count: 1 }),
link({ id: 'contains', scope: 'public', owner_user_id: null, alias: 'opencode', click_count: 100 }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
const ids = body.links.map((item: { id: string }) => item.id);
expect(ids).toEqual(['exact', 'contains']);
});
it('searches private links by alias contains for the current user only', async () => {
const session = await userSession('token-a', 'user_1');
const { response } = await fetchWorker('/api/links/private?q=doc', {
cookie: cookie('token-a'),
sessions: [session],
links: [
link({ id: 'mine_exact', scope: 'private', owner_user_id: 'user_1', alias: 'doc', click_count: 3 }),
link({ id: 'mine_contains', scope: 'private', owner_user_id: 'user_1', alias: 'docs', click_count: 30 }),
link({ id: 'theirs', scope: 'private', owner_user_id: 'user_2', alias: 'docs', click_count: 999 }),
link({ id: 'unrelated', scope: 'private', owner_user_id: 'user_1', alias: 'blog', click_count: 50 }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
const ids = body.links.map((item: { id: string }) => item.id);
expect(ids).toEqual(['mine_exact', 'mine_contains']);
expect(ids).not.toContain('theirs');
expect(ids).not.toContain('unrelated');
});
it('returns empty results for a query matching no aliases', async () => {
const { response } = await fetchWorker('/api/links/public?q=nonexistent', {
links: [
link({ id: 'pub', scope: 'public', owner_user_id: null, alias: 'docs' }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.links).toEqual([]);
});
it('treats a blank query as no search (returns all links sorted by clicks)', async () => {
const { response } = await fetchWorker('/api/links/public?q=%20%20', {
links: [
link({ id: 'few', scope: 'public', owner_user_id: null, alias: 'few', click_count: 1 }),
link({ id: 'many', scope: 'public', owner_user_id: null, alias: 'many', click_count: 99 }),
],
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.links.map((item: { id: string }) => item.id)).toEqual(['many', 'few']);
});
});