mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
Add link search
This commit is contained in:
@@ -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
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user