mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-08 21:05:48 +10:00
415 lines
13 KiB
TypeScript
415 lines
13 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import worker from '../worker/index';
|
|
import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth';
|
|
|
|
type NotificationStatus = 'unread' | 'viewed';
|
|
|
|
type NotificationRow = {
|
|
id: string;
|
|
user_id: string;
|
|
title: string;
|
|
body: string | null;
|
|
kind: string;
|
|
related_submission_id: string | null;
|
|
status: NotificationStatus;
|
|
created_at: string;
|
|
viewed_at: string | null;
|
|
proposed_alias?: string | null;
|
|
private_link_id?: string | null;
|
|
private_link_alias?: string | null;
|
|
};
|
|
|
|
type SessionRow = {
|
|
id: string;
|
|
email: string | null;
|
|
name: string | null;
|
|
image_url: string | null;
|
|
role: 'user' | 'admin';
|
|
expires_at: string;
|
|
session_token_hash: string;
|
|
};
|
|
|
|
type SubmissionRow = {
|
|
id: string;
|
|
private_link_id: string;
|
|
submitted_by_user_id: string;
|
|
proposed_alias: string;
|
|
note: string | null;
|
|
status: 'pending' | 'approved' | 'rejected' | 'needs_changes';
|
|
reviewed_by_user_id: string | null;
|
|
rejection_reason: string | null;
|
|
public_link_id: string | null;
|
|
created_at: string;
|
|
reviewed_at: string | null;
|
|
};
|
|
|
|
type LinkRow = {
|
|
id: string;
|
|
scope: 'public' | 'private';
|
|
owner_user_id: string | null;
|
|
alias: string;
|
|
link_type: 'redirect' | 'custom';
|
|
target_url: string | null;
|
|
content_markdown: string | null;
|
|
description: string | null;
|
|
status: 'active' | 'archived' | 'deleted';
|
|
click_count: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
};
|
|
|
|
type AllResult<T> = {
|
|
results: T[];
|
|
success: true;
|
|
meta: Record<string, never>;
|
|
};
|
|
|
|
class FakeD1Database {
|
|
readonly runCalls: { sql: string; params: unknown[] }[] = [];
|
|
|
|
constructor(
|
|
readonly notifications: NotificationRow[] = [],
|
|
readonly submissions: SubmissionRow[] = [],
|
|
readonly links: LinkRow[] = [],
|
|
private readonly sessions: SessionRow[] = [],
|
|
) {}
|
|
|
|
prepare(sql: string): FakeD1PreparedStatement {
|
|
return new FakeD1PreparedStatement(this, sql);
|
|
}
|
|
|
|
findSession(hash: string): SessionRow | null {
|
|
return this.sessions.find((s) => s.session_token_hash === hash) ?? null;
|
|
}
|
|
|
|
findNotification(id: string): NotificationRow | null {
|
|
return this.notifications.find((n) => n.id === id) ?? null;
|
|
}
|
|
|
|
listForUser(userId: string): NotificationRow[] {
|
|
return this.notifications
|
|
.filter((n) => n.user_id === userId)
|
|
.sort((a, b) => b.created_at.localeCompare(a.created_at))
|
|
.map((n) => this.enrich(n));
|
|
}
|
|
|
|
enrich(n: NotificationRow): NotificationRow {
|
|
const submission = this.submissions.find((s) => s.id === n.related_submission_id) ?? null;
|
|
const link = submission ? this.links.find((l) => l.id === submission.private_link_id) ?? null : null;
|
|
return {
|
|
...n,
|
|
proposed_alias: submission?.proposed_alias ?? null,
|
|
private_link_id: submission?.private_link_id ?? null,
|
|
private_link_alias: link?.alias ?? null,
|
|
};
|
|
}
|
|
|
|
countUnread(userId: string): number {
|
|
return this.notifications.filter((n) => n.user_id === userId && n.status === 'unread').length;
|
|
}
|
|
|
|
insertNotification(params: unknown[]): NotificationRow {
|
|
const [id, userId, title, body, kind, relatedSubmissionId] = params;
|
|
const row: NotificationRow = {
|
|
id: String(id),
|
|
user_id: String(userId),
|
|
title: String(title),
|
|
body: body == null ? null : String(body),
|
|
kind: String(kind),
|
|
related_submission_id: relatedSubmissionId == null ? null : String(relatedSubmissionId),
|
|
status: 'unread',
|
|
created_at: '2026-06-20T00:00:00.000Z',
|
|
viewed_at: null,
|
|
};
|
|
this.notifications.push(row);
|
|
return row;
|
|
}
|
|
}
|
|
|
|
class FakeD1PreparedStatement {
|
|
private params: unknown[] = [];
|
|
|
|
constructor(
|
|
private readonly db: FakeD1Database,
|
|
private readonly sql: string,
|
|
) {}
|
|
|
|
bind(...params: unknown[]): FakeD1PreparedStatement {
|
|
this.params = params;
|
|
return this;
|
|
}
|
|
|
|
async first<T>(): Promise<T | null> {
|
|
if (this.sql.includes('session_token_hash')) {
|
|
const row = this.db.findSession(String(this.params[0]));
|
|
if (!row) return null;
|
|
return {
|
|
id: row.id,
|
|
email: row.email,
|
|
name: row.name,
|
|
image_url: row.image_url,
|
|
role: row.role,
|
|
expires_at: row.expires_at,
|
|
} as T;
|
|
}
|
|
|
|
if (this.sql.includes('SELECT COUNT(*)')) {
|
|
return { count: this.db.countUnread(String(this.params[0])) } as T;
|
|
}
|
|
|
|
if (this.sql.includes('SELECT id, user_id, status FROM notifications')) {
|
|
const row = this.db.findNotification(String(this.params[0]));
|
|
if (!row) return null;
|
|
return { id: row.id, user_id: row.user_id, status: row.status } as T;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async all<T>(): Promise<AllResult<T>> {
|
|
if (this.sql.includes('FROM notifications n')) {
|
|
return { results: this.db.listForUser(String(this.params[0])) as unknown as T[], success: true, meta: {} };
|
|
}
|
|
return { results: [], success: true, meta: {} };
|
|
}
|
|
|
|
async run(): Promise<D1Result> {
|
|
this.db.runCalls.push({ sql: this.sql, params: this.params });
|
|
|
|
if (this.sql.startsWith('INSERT INTO notifications')) {
|
|
this.db.insertNotification(this.params);
|
|
}
|
|
|
|
if (this.sql.startsWith('UPDATE notifications') && this.sql.includes("status='viewed'")) {
|
|
const userId = String(this.params[0]);
|
|
for (const n of this.db.notifications) {
|
|
if (n.user_id === userId && n.status === 'unread') {
|
|
n.status = 'viewed';
|
|
n.viewed_at = '2026-06-20T00:00:00.000Z';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.sql.startsWith('DELETE FROM notifications')) {
|
|
const id = String(this.params[0]);
|
|
const userId = String(this.params[1]);
|
|
const idx = this.db.notifications.findIndex((n) => n.id === id && n.user_id === userId);
|
|
const changes = idx === -1 ? 0 : 1;
|
|
if (idx !== -1) this.db.notifications.splice(idx, 1);
|
|
return { success: true, meta: { changes } } as unknown as D1Result;
|
|
}
|
|
|
|
return { success: true, meta: { changes: 1 } } as unknown as D1Result;
|
|
}
|
|
}
|
|
|
|
class FakeExecutionContext {
|
|
waitUntil(): void {}
|
|
passThroughOnException(): void {}
|
|
}
|
|
|
|
function notification(overrides: Partial<NotificationRow> = {}): NotificationRow {
|
|
return {
|
|
id: 'notif_1',
|
|
user_id: 'user_1',
|
|
title: 'Promotion approved',
|
|
body: 'Your link was approved.',
|
|
kind: 'promotion_approved',
|
|
related_submission_id: 'sub_1',
|
|
status: 'unread',
|
|
created_at: '2026-06-20T00:00:00.000Z',
|
|
viewed_at: null,
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function futureIso(): string {
|
|
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
|
}
|
|
|
|
async function userSession(token: string, userId: string, role: 'user' | 'admin' = 'user'): Promise<SessionRow> {
|
|
return {
|
|
id: userId,
|
|
email: `${userId}@heygo.cc`,
|
|
name: userId,
|
|
image_url: null,
|
|
role,
|
|
expires_at: futureIso(),
|
|
session_token_hash: await hashSessionToken(token),
|
|
};
|
|
}
|
|
|
|
function cookie(token: string): string {
|
|
return `${AUTH_SESSION_COOKIE_NAME}=${token}`;
|
|
}
|
|
|
|
function makeEnv(
|
|
notifications: NotificationRow[] = [],
|
|
submissions: SubmissionRow[] = [],
|
|
links: LinkRow[] = [],
|
|
sessions: SessionRow[] = [],
|
|
) {
|
|
const db = new FakeD1Database(notifications, submissions, links, sessions);
|
|
return {
|
|
env: {
|
|
DB: db as unknown as D1Database,
|
|
PUBLIC_HOST: 'heygo.cc',
|
|
PRIVATE_HOST: 'my.heygo.cc',
|
|
APP_BASE_URL: 'https://heygo.cc',
|
|
COOKIE_DOMAIN: '.heygo.cc',
|
|
},
|
|
db,
|
|
ctx: new FakeExecutionContext(),
|
|
};
|
|
}
|
|
|
|
async function fetchWorker(
|
|
path: string,
|
|
opts: {
|
|
method?: string;
|
|
body?: unknown;
|
|
notifications?: NotificationRow[];
|
|
sessions?: SessionRow[];
|
|
cookie?: string;
|
|
} = {},
|
|
) {
|
|
const { env, db, ctx } = makeEnv(opts.notifications ?? [], [], [], opts.sessions ?? []);
|
|
const headers = new Headers();
|
|
if (opts.cookie) headers.set('cookie', opts.cookie);
|
|
if (opts.body !== undefined) headers.set('content-type', 'application/json');
|
|
|
|
const response = await worker.fetch(
|
|
new Request(`https://heygo.cc${path}`, {
|
|
method: opts.method ?? 'GET',
|
|
headers,
|
|
body: opts.body === undefined ? undefined : JSON.stringify(opts.body),
|
|
}) as unknown as Parameters<typeof worker.fetch>[0],
|
|
env as unknown as Parameters<typeof worker.fetch>[1],
|
|
ctx as unknown as Parameters<typeof worker.fetch>[2],
|
|
);
|
|
|
|
return { response, db };
|
|
}
|
|
|
|
async function expectJson<T = any>(response: Response): Promise<T> {
|
|
expect(response.headers.get('content-type')).toContain('application/json');
|
|
return response.json() as Promise<T>;
|
|
}
|
|
|
|
describe('notifications API', () => {
|
|
it('lists the current user notifications and auto-marks them viewed', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const notifications = [
|
|
notification({ id: 'n1', user_id: 'user_1', status: 'unread', title: 'A', created_at: '2026-06-20T00:00:00.000Z' }),
|
|
notification({ id: 'n2', user_id: 'user_1', status: 'unread', title: 'B', created_at: '2026-06-20T01:00:00.000Z' }),
|
|
notification({ id: 'n_other', user_id: 'user_2', title: 'Other' }),
|
|
];
|
|
const { response, db } = await fetchWorker('/api/notifications', {
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
notifications,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await expectJson(response);
|
|
expect(body.notifications.map((n: { id: string }) => n.id)).toEqual(['n2', 'n1']);
|
|
expect(db.notifications.filter((n) => n.user_id === 'user_1').every((n) => n.status === 'viewed')).toBe(true);
|
|
});
|
|
|
|
it('returns the unread count for the current user', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const notifications = [
|
|
notification({ id: 'n1', user_id: 'user_1', status: 'unread' }),
|
|
notification({ id: 'n2', user_id: 'user_1', status: 'viewed' }),
|
|
notification({ id: 'n3', user_id: 'user_2', status: 'unread' }),
|
|
];
|
|
const { response } = await fetchWorker('/api/notifications/unread-count', {
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
notifications,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await expectJson(response);
|
|
expect(body.count).toBe(1);
|
|
});
|
|
|
|
it('deletes a notification owned by the current user', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const notifications = [notification({ id: 'n1', user_id: 'user_1' })];
|
|
const { response, db } = await fetchWorker('/api/notifications/n1', {
|
|
method: 'DELETE',
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
notifications,
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(db.notifications.some((n) => n.id === 'n1')).toBe(false);
|
|
});
|
|
|
|
it('returns 404 when deleting another user notification', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const notifications = [notification({ id: 'n_secret', user_id: 'user_2' })];
|
|
const { response, db } = await fetchWorker('/api/notifications/n_secret', {
|
|
method: 'DELETE',
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
notifications,
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(db.notifications.some((n) => n.id === 'n_secret')).toBe(true);
|
|
});
|
|
|
|
it('deletes multiple notifications via delete-batch', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const notifications = [
|
|
notification({ id: 'n1', user_id: 'user_1' }),
|
|
notification({ id: 'n2', user_id: 'user_1' }),
|
|
notification({ id: 'n3', user_id: 'user_2' }),
|
|
];
|
|
const { response, db } = await fetchWorker('/api/notifications/delete-batch', {
|
|
method: 'POST',
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
notifications,
|
|
body: { ids: ['n1', 'n2', 'n3'] },
|
|
});
|
|
|
|
expect(response.status).toBe(200);
|
|
const body = await expectJson(response);
|
|
expect(body.ok).toBe(true);
|
|
expect(body.deleted).toBe(2);
|
|
expect(db.notifications.map((n) => n.id)).toEqual(['n3']);
|
|
});
|
|
|
|
it('rejects unauthenticated requests with 401', async () => {
|
|
const { response } = await fetchWorker('/api/notifications');
|
|
expect(response.status).toBe(401);
|
|
await expect(expectJson(response)).resolves.toEqual({ error: 'Authentication required' });
|
|
});
|
|
|
|
it('returns 400 for an invalid delete-batch body', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const { response } = await fetchWorker('/api/notifications/delete-batch', {
|
|
method: 'POST',
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
body: { ids: [] },
|
|
});
|
|
expect(response.status).toBe(400);
|
|
await expect(expectJson(response)).resolves.toHaveProperty('error');
|
|
});
|
|
|
|
it('returns 405 for unsupported methods', async () => {
|
|
const session = await userSession('token-a', 'user_1');
|
|
const { response } = await fetchWorker('/api/notifications', {
|
|
method: 'POST',
|
|
cookie: cookie('token-a'),
|
|
sessions: [session],
|
|
});
|
|
expect(response.status).toBe(405);
|
|
});
|
|
});
|