Files
buzz-sheet/lib/notifications/repository.test.ts
T

79 lines
4.4 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { PgDialect } from 'drizzle-orm/pg-core';
import type { SQL } from 'drizzle-orm';
const state = vi.hoisted(() => ({ rows: [] as unknown[][], filters: [] as unknown[], joins: [] as unknown[], select: vi.fn() }));
vi.mock('server-only', () => ({}));
vi.mock('./events', () => ({ notifyNotificationChange: vi.fn() }));
vi.mock('@/lib/media/storage', () => ({ publicMediaUrl: (key: string) => `https://cdn.example/${key}` }));
vi.mock('@/db', () => ({ getDb: () => ({ select: state.select }) }));
import { listNotifications } from './repository';
const user = { id: 'reader', role: 'user', emailVerified: true };
const guideId = '00000000-0000-4000-8000-000000000001';
const otherGuideId = '00000000-0000-4000-8000-000000000002';
function row(kind: string, eventKey: string) {
return { notification: { id: eventKey, kind, eventKey }, cursorTime: '2026-10-12T00:00:00Z', authorName: null, authorImage: null };
}
beforeEach(() => {
state.rows = [];
state.filters = [];
state.joins = [];
state.select.mockReset().mockImplementation(() => {
const rows = state.rows.shift() ?? [];
const query = {
from: () => query, leftJoin: (_table: unknown, condition: unknown) => { state.joins.push(condition); return query; }, innerJoin: () => query,
where: (filter: unknown) => { state.filters.push(filter); return query; }, orderBy: () => query, limit: () => query,
then: (resolve: (value: unknown[]) => unknown) => Promise.resolve(rows).then(resolve),
};
return query;
});
});
describe('guide overview images in notification feeds', () => {
it('loads multiple guide covers in one bounded query while preserving comment authors and unread counts', async () => {
state.rows = [[row('guide_published', `guide:${guideId}:published`), row('guide_published', `guide:${otherGuideId}:published`),
{ ...row('comment_new', 'comment:1'), authorName: 'Reader', authorImage: 'https://profile.example/reader.png' }],
[{ count: 3 }], [{ id: guideId, objectKey: 'guide/cover.png' }, { id: otherGuideId, objectKey: 'guide/other.png' }]];
const result = await listNotifications(user);
expect(result.unread).toBe(3);
expect(result.nextCursor).toBeNull();
expect(result.items.map(item => item.imageUrl)).toEqual(['https://cdn.example/guide/cover.png', 'https://cdn.example/guide/other.png', null]);
expect(result.items[2]).toMatchObject({ authorName: 'Reader', authorImage: 'https://profile.example/reader.png' });
expect(state.select).toHaveBeenCalledTimes(3);
const filter = new PgDialect().sqlToQuery(state.filters[2] as SQL);
expect(filter.params).toContain(guideId);
expect(filter.params).toContain(otherGuideId);
expect(filter.params).toContain(true);
expect(filter.sql).toContain('"is_public"');
expect(filter.sql).toContain('"trashed_at" is null');
});
it('avoids cover queries for other notification kinds or malformed publication keys', async () => {
state.rows = [[row('commission', `guide:${guideId}:published`), row('guide_published', 'guide:invalid:published')], [{ count: 2 }]];
const result = await listNotifications(user);
expect(result.items.map(item => item.imageUrl)).toEqual([null, null]);
expect(state.select).toHaveBeenCalledTimes(2);
});
it('keeps the notification usable without an image when its guide is unavailable', async () => {
state.rows = [[row('guide_published', `guide:${guideId}:published`)], [{ count: 1 }], []];
expect((await listNotifications(user)).items[0].imageUrl).toBeNull();
});
it('uses the heart giver for heart notifications and the author for new comments and replies', async () => {
state.rows = [[{ ...row('comment_heart', 'comment:1:heart'), authorName: 'Admin', authorImage: 'https://profile.example/admin.png' }], [{ count: 1 }]];
const result = await listNotifications(user);
expect(result.items[0]).toMatchObject({ authorName: 'Admin', authorImage: 'https://profile.example/admin.png', imageUrl: null });
const joins = state.joins.map(condition => new PgDialect().sqlToQuery(condition as SQL));
const profileJoin = joins.find(join => join.sql.includes('case when'))!;
expect(profileJoin.sql).toContain("= 'comment_heart' then");
expect(profileJoin.sql).toContain('"hearted_by_id" else');
expect(profileJoin.sql).toContain('"author_id" end');
expect(profileJoin.params).toContain('comment_heart');
expect(state.select).toHaveBeenCalledTimes(2);
});
});