Files
heygo/tests/api.promotions.test.ts
T
2026-06-20 21:07:56 +10:00

837 lines
27 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import worker from '../worker/index';
import { AUTH_SESSION_COOKIE_NAME, hashSessionToken } from '../worker/auth';
type LinkScope = 'public' | 'private';
type LinkStatus = 'active' | 'archived' | 'deleted';
type LinkType = 'redirect' | 'custom';
type SubmissionStatus = 'pending' | 'approved' | 'rejected' | 'needs_changes';
type LinkRow = {
id: string;
scope: LinkScope;
owner_user_id: string | null;
alias: string;
link_type: LinkType;
target_url: string | null;
content_markdown: string | null;
description: string | null;
status: LinkStatus;
click_count: number;
created_at: string;
updated_at: string;
};
type SubmissionRow = {
id: string;
private_link_id: string;
submitted_by_user_id: string;
proposed_alias: string;
note: string | null;
status: SubmissionStatus;
reviewed_by_user_id: string | null;
rejection_reason: string | null;
public_link_id: string | null;
created_at: string;
reviewed_at: 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 SubmissionJoined = SubmissionRow & {
private_link_alias: string | null;
private_link_target_url: string | null;
link_type: LinkType | null;
content_markdown: string | null;
description: string | null;
};
type AllResult<T> = {
results: T[];
success: true;
meta: Record<string, never>;
};
type FakeD1Options = {
throwOnInsert?: Error;
};
type NotificationRow = {
id: string;
user_id: string;
title: string;
body: string | null;
kind: string;
related_submission_id: string | null;
};
class FakeD1Database {
readonly preparedSql: string[] = [];
readonly runCalls: { sql: string; params: unknown[] }[] = [];
readonly notifications: NotificationRow[] = [];
constructor(
readonly links: LinkRow[] = [],
readonly submissions: SubmissionRow[] = [],
private readonly sessions: SessionRow[] = [],
private readonly options: FakeD1Options = {},
) {}
prepare(sql: string): FakeD1PreparedStatement {
this.preparedSql.push(sql);
return new FakeD1PreparedStatement(this, sql);
}
findSession(hash: string): SessionRow | null {
return this.sessions.find((s) => s.session_token_hash === hash) ?? null;
}
findPrivateLink(id: string, ownerUserId: string): LinkRow | null {
return (
this.links.find(
(l) =>
l.id === id &&
l.scope === 'private' &&
l.owner_user_id === ownerUserId &&
l.status === 'active',
) ?? null
);
}
findPublicAliasConflict(alias: string): LinkRow | null {
return (
this.links.find(
(l) => l.scope === 'public' && l.status !== 'deleted' && l.alias === alias,
) ?? null
);
}
findPendingDuplicate(
privateLinkId: string,
submittedByUserId: string,
proposedAlias: string,
): SubmissionRow | null {
return (
this.submissions.find(
(s) =>
s.private_link_id === privateLinkId &&
s.submitted_by_user_id === submittedByUserId &&
s.proposed_alias === proposedAlias &&
s.status === 'pending',
) ?? null
);
}
findSubmission(id: string): SubmissionRow | null {
return this.submissions.find((s) => s.id === id) ?? null;
}
listMine(userId: string): SubmissionJoined[] {
return this.submissions
.filter((s) => s.submitted_by_user_id === userId)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map((s) => this.joinLink(s));
}
listByStatus(status: SubmissionStatus): SubmissionJoined[] {
return this.submissions
.filter((s) => s.status === status)
.sort((a, b) => b.created_at.localeCompare(a.created_at))
.map((s) => this.joinLink(s));
}
joinLink(s: SubmissionRow): SubmissionJoined {
const link = this.links.find((l) => l.id === s.private_link_id) ?? null;
return {
...s,
private_link_alias: link?.alias ?? null,
private_link_target_url: link?.target_url ?? null,
link_type: link?.link_type ?? null,
content_markdown: link?.content_markdown ?? null,
description: link?.description ?? null,
};
}
insertSubmission(params: unknown[]): SubmissionRow {
const [id, privateLinkId, submittedByUserId, proposedAlias, note] = params;
const row: SubmissionRow = {
id: String(id),
private_link_id: String(privateLinkId),
submitted_by_user_id: String(submittedByUserId),
proposed_alias: String(proposedAlias),
note: note == null ? null : String(note),
status: 'pending',
reviewed_by_user_id: null,
rejection_reason: null,
public_link_id: null,
created_at: '2026-06-20T00:00:00.000Z',
reviewed_at: null,
};
this.submissions.push(row);
return row;
}
insertPublicLink(params: unknown[]): LinkRow {
const [id, alias, linkType, targetUrl, contentMarkdown, description] = params;
const row: LinkRow = {
id: String(id),
scope: 'public',
owner_user_id: null,
alias: String(alias),
link_type: linkType as LinkType,
target_url: targetUrl == null ? null : String(targetUrl),
content_markdown: contentMarkdown == null ? null : String(contentMarkdown),
description: description == null ? null : String(description),
status: 'active',
click_count: 0,
created_at: '2026-06-20T00:00:00.000Z',
updated_at: '2026-06-20T00:00:00.000Z',
};
this.links.push(row);
return row;
}
maybeThrowOnInsert(): void {
if (this.options.throwOnInsert) {
throw this.options.throwOnInsert;
}
}
}
class FakeD1PreparedStatement {
constructor(
private readonly db: FakeD1Database,
private readonly sql: string,
private readonly params: unknown[] = [],
) {}
bind(...params: unknown[]): FakeD1PreparedStatement {
return new FakeD1PreparedStatement(this.db, this.sql, params);
}
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('promotion_submissions') && this.sql.includes('s.id=?')) {
const row = this.db.findSubmission(String(this.params[0]));
if (!row) return null;
return this.db.joinLink(row) as unknown as T;
}
if (this.sql.includes('promotion_submissions') && this.sql.includes("status='pending'")) {
const row = this.db.findPendingDuplicate(
String(this.params[0]),
String(this.params[1]),
String(this.params[2]),
);
return (row ? { id: row.id } : null) as T | null;
}
if (
this.sql.includes('FROM links') &&
this.sql.includes("scope='public'") &&
this.sql.includes("status!='deleted'") &&
this.sql.includes('alias=?')
) {
const row = this.db.findPublicAliasConflict(String(this.params[0]));
return (row ? { id: row.id } : null) as T | null;
}
if (
this.sql.includes('FROM links') &&
this.sql.includes("scope='private'") &&
this.sql.includes('owner_user_id=?') &&
this.sql.includes('id=?')
) {
const row = this.db.findPrivateLink(String(this.params[0]), String(this.params[1]));
if (!row) return null;
return {
id: row.id,
alias: row.alias,
scope: row.scope,
link_type: row.link_type,
target_url: row.target_url,
content_markdown: row.content_markdown,
description: row.description,
owner_user_id: row.owner_user_id,
status: row.status,
} as T;
}
return null;
}
async all<T>(): Promise<AllResult<T>> {
if (this.sql.includes('promotion_submissions') && this.sql.includes('s.submitted_by_user_id=?')) {
return { results: this.db.listMine(String(this.params[0])) as unknown as T[], success: true, meta: {} };
}
if (this.sql.includes('promotion_submissions') && this.sql.includes('s.status=?')) {
return { results: this.db.listByStatus(String(this.params[0]) as SubmissionStatus) 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 promotion_submissions')) {
this.db.insertSubmission(this.params);
}
if (this.sql.startsWith('INSERT INTO links')) {
this.db.maybeThrowOnInsert();
this.db.insertPublicLink(this.params);
}
if (this.sql.startsWith('INSERT INTO notifications')) {
const [id, userId, title, body, kind, relatedSubmissionId] = this.params;
this.db.notifications.push({
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),
});
}
if (this.sql.startsWith('UPDATE promotion_submissions')) {
const submission = this.db.findSubmission(String(this.params[this.params.length - 1]));
if (submission && submission.status === 'pending') {
if (this.sql.includes("status='approved'")) {
submission.status = 'approved';
submission.reviewed_by_user_id = String(this.params[0]);
submission.reviewed_at = String(this.params[1]);
submission.public_link_id = String(this.params[2]);
} else if (this.sql.includes("status='rejected'")) {
submission.status = 'rejected';
submission.reviewed_by_user_id = String(this.params[0]);
submission.reviewed_at = String(this.params[1]);
submission.rejection_reason = this.params[2] == null ? null : String(this.params[2]);
} else if (this.sql.includes("status='needs_changes'")) {
submission.status = 'needs_changes';
submission.reviewed_by_user_id = String(this.params[0]);
submission.reviewed_at = String(this.params[1]);
submission.rejection_reason = this.params[2] == null ? null : String(this.params[2]);
}
}
}
return { success: true, meta: { changes: 1 } } as unknown as D1Result;
}
}
class FakeExecutionContext {
waitUntil(): void {}
passThroughOnException(): void {}
}
function link(overrides: Partial<LinkRow> = {}): LinkRow {
return {
id: 'link_1',
scope: 'private',
owner_user_id: 'user_1',
alias: 'docs',
link_type: 'redirect',
target_url: 'https://example.com/docs',
content_markdown: null,
description: null,
status: 'active',
click_count: 0,
created_at: '2026-06-20T00:00:00.000Z',
updated_at: '2026-06-20T00:00:00.000Z',
...overrides,
};
}
function submission(overrides: Partial<SubmissionRow> = {}): SubmissionRow {
return {
id: 'sub_1',
private_link_id: 'link_1',
submitted_by_user_id: 'user_1',
proposed_alias: 'docs',
note: null,
status: 'pending',
reviewed_by_user_id: null,
rejection_reason: null,
public_link_id: null,
created_at: '2026-06-20T00:00:00.000Z',
reviewed_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(
links: LinkRow[] = [],
submissions: SubmissionRow[] = [],
sessions: SessionRow[] = [],
dbOptions: FakeD1Options = {},
) {
const db = new FakeD1Database(links, submissions, sessions, dbOptions);
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;
links?: LinkRow[];
submissions?: SubmissionRow[];
sessions?: SessionRow[];
cookie?: string;
dbOptions?: FakeD1Options;
} = {},
) {
const { env, db, ctx } = makeEnv(
opts.links ?? [],
opts.submissions ?? [],
opts.sessions ?? [],
opts.dbOptions ?? {},
);
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>;
}
function uniqueConstraintError(): Error {
return new Error('D1_ERROR: UNIQUE constraint failed: links.alias');
}
describe('promotion submission API', () => {
it('lets a user submit their own private link for promotion', async () => {
const session = await userSession('token-a', 'user_1');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const { response } = await fetchWorker('/api/promotions', {
method: 'POST',
cookie: cookie('token-a'),
sessions: [session],
links,
body: { privateLinkId: 'priv_1', proposedAlias: 'public-docs', note: 'please review' },
});
expect(response.status).toBe(201);
const body = await expectJson(response);
expect(body.submission).toMatchObject({
privateLinkId: 'priv_1',
submittedByUserId: 'user_1',
proposedAlias: 'public-docs',
status: 'pending',
note: 'please review',
});
});
it('rejects submitting another user private link with 404', async () => {
const session = await userSession('token-a', 'user_1');
const links = [link({ id: 'priv_other', owner_user_id: 'user_2', alias: 'secret' })];
const { response } = await fetchWorker('/api/promotions', {
method: 'POST',
cookie: cookie('token-a'),
sessions: [session],
links,
body: { privateLinkId: 'priv_other', proposedAlias: 'leaked' },
});
expect(response.status).toBe(404);
await expect(expectJson(response)).resolves.toHaveProperty('error');
});
it('rejects a duplicate pending submission with 409', async () => {
const session = await userSession('token-a', 'user_1');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({
id: 'sub_existing',
private_link_id: 'priv_1',
submitted_by_user_id: 'user_1',
proposed_alias: 'public-docs',
}),
];
const { response } = await fetchWorker('/api/promotions', {
method: 'POST',
cookie: cookie('token-a'),
sessions: [session],
links,
submissions,
body: { privateLinkId: 'priv_1', proposedAlias: 'public-docs' },
});
expect(response.status).toBe(409);
await expect(expectJson(response)).resolves.toHaveProperty('error');
});
it('lets an admin list pending submissions', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_pending', private_link_id: 'priv_1', proposed_alias: 'docs' }),
submission({
id: 'sub_approved',
private_link_id: 'priv_1',
proposed_alias: 'done',
status: 'approved',
}),
];
const { response } = await fetchWorker('/api/admin/promotions?status=pending', {
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.submissions.map((s: { id: string }) => s.id)).toEqual(['sub_pending']);
});
it('rejects non-admin access to admin promotions list with 403', async () => {
const session = await userSession('token-a', 'user_1', 'user');
const { response } = await fetchWorker('/api/admin/promotions', {
cookie: cookie('token-a'),
sessions: [session],
});
expect(response.status).toBe(403);
await expect(expectJson(response)).resolves.toEqual({ error: 'Admin access required' });
});
it('creates a public link and marks submission approved on admin approval', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [
link({
id: 'priv_1',
owner_user_id: 'user_1',
alias: 'mylink',
link_type: 'redirect',
target_url: 'https://example.com/docs',
}),
];
const submissions = [
submission({
id: 'sub_1',
private_link_id: 'priv_1',
submitted_by_user_id: 'user_1',
proposed_alias: 'public-docs',
}),
];
const { response, db } = await fetchWorker('/api/admin/promotions/sub_1/approve', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.submission).toMatchObject({ id: 'sub_1', status: 'approved' });
expect(body.publicLink).toMatchObject({
alias: 'public-docs',
scope: 'public',
ownerUserId: null,
linkType: 'redirect',
targetUrl: 'https://example.com/docs',
status: 'active',
});
expect(db.submissions[0].status).toBe('approved');
expect(db.submissions[0].reviewed_by_user_id).toBe('admin_1');
expect(db.submissions[0].public_link_id).toBe(body.publicLink.id);
expect(db.links.some((l) => l.id === body.publicLink.id && l.scope === 'public')).toBe(true);
});
it('returns 409 on approval alias conflict and does not approve', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [
link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' }),
link({
id: 'pub_existing',
scope: 'public',
owner_user_id: null,
alias: 'public-docs',
}),
];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', proposed_alias: 'public-docs' }),
];
const { response, db } = await fetchWorker('/api/admin/promotions/sub_1/approve', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
});
expect(response.status).toBe(409);
await expect(expectJson(response)).resolves.toHaveProperty('error');
expect(db.submissions[0].status).toBe('pending');
expect(db.submissions[0].public_link_id).toBeNull();
});
it('stores a reason and marks submission rejected', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', proposed_alias: 'docs' }),
];
const { response, db } = await fetchWorker('/api/admin/promotions/sub_1/reject', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
body: { reason: 'alias too generic' },
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.submission).toMatchObject({ id: 'sub_1', status: 'rejected' });
expect(body.submission.rejectionReason).toBe('alias too generic');
expect(db.submissions[0].status).toBe('rejected');
expect(db.submissions[0].rejection_reason).toBe('alias too generic');
});
it('stores a reason and marks submission needs_changes', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', proposed_alias: 'docs' }),
];
const { response, db } = await fetchWorker('/api/admin/promotions/sub_1/needs-changes', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
body: { reason: 'add a better description' },
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.submission).toMatchObject({ id: 'sub_1', status: 'needs_changes' });
expect(body.submission.rejectionReason).toBe('add a better description');
expect(db.submissions[0].status).toBe('needs_changes');
});
it('returns 400 for invalid alias and invalid bodies', async () => {
const session = await userSession('token-a', 'user_1');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const badAlias = await fetchWorker('/api/promotions', {
method: 'POST',
cookie: cookie('token-a'),
sessions: [session],
links,
body: { privateLinkId: 'priv_1', proposedAlias: '../bad' },
});
expect(badAlias.response.status).toBe(400);
await expect(expectJson(badAlias.response)).resolves.toHaveProperty('error');
const missingField = await fetchWorker('/api/promotions', {
method: 'POST',
cookie: cookie('token-a'),
sessions: [session],
links,
body: { proposedAlias: 'ok' },
});
expect(missingField.response.status).toBe(400);
await expect(expectJson(missingField.response)).resolves.toHaveProperty('error');
});
it('returns 401 for unauthenticated promotion submission', async () => {
const { response } = await fetchWorker('/api/promotions', {
method: 'POST',
body: { privateLinkId: 'priv_1', proposedAlias: 'docs' },
});
expect(response.status).toBe(401);
await expect(expectJson(response)).resolves.toEqual({ error: 'Authentication required' });
});
it('lets a user list their own submissions', async () => {
const session = await userSession('token-a', 'user_1');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1' }),
submission({ id: 'sub_2', private_link_id: 'priv_1', submitted_by_user_id: 'user_2' }),
];
const { response } = await fetchWorker('/api/promotions/mine', {
cookie: cookie('token-a'),
sessions: [session],
links,
submissions,
});
expect(response.status).toBe(200);
const body = await expectJson(response);
expect(body.submissions.map((s: { id: string }) => s.id)).toEqual(['sub_1']);
});
it('returns 409 when approve insert hits a unique constraint race', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', proposed_alias: 'docs' }),
];
const { response, db } = await fetchWorker('/api/admin/promotions/sub_1/approve', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
dbOptions: { throwOnInsert: uniqueConstraintError() },
});
expect(response.status).toBe(409);
expect(db.submissions[0].status).toBe('pending');
});
it('creates an unread notification for the submitter on approval', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [
link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink', target_url: 'https://example.com' }),
];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1', proposed_alias: 'docs' }),
];
const { db } = await fetchWorker('/api/admin/promotions/sub_1/approve', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
});
expect(db.notifications).toHaveLength(1);
expect(db.notifications[0]).toMatchObject({
user_id: 'user_1',
kind: 'promotion_approved',
title: 'Promotion approved',
related_submission_id: 'sub_1',
});
expect(db.notifications[0].body).toContain('mylink');
expect(db.notifications[0].body).toContain('docs');
});
it('creates a notification with the reason on rejection', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1', proposed_alias: 'docs' }),
];
const { db } = await fetchWorker('/api/admin/promotions/sub_1/reject', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
body: { reason: 'too generic' },
});
expect(db.notifications).toHaveLength(1);
expect(db.notifications[0]).toMatchObject({
user_id: 'user_1',
kind: 'promotion_rejected',
title: 'Promotion rejected',
});
expect(db.notifications[0].body).toContain('too generic');
});
it('creates a notification on needs-changes', async () => {
const admin = await userSession('admin-token', 'admin_1', 'admin');
const links = [link({ id: 'priv_1', owner_user_id: 'user_1', alias: 'mylink' })];
const submissions = [
submission({ id: 'sub_1', private_link_id: 'priv_1', submitted_by_user_id: 'user_1', proposed_alias: 'docs' }),
];
const { db } = await fetchWorker('/api/admin/promotions/sub_1/needs-changes', {
method: 'POST',
cookie: cookie('admin-token'),
sessions: [admin],
links,
submissions,
body: { reason: 'add a description' },
});
expect(db.notifications).toHaveLength(1);
expect(db.notifications[0]).toMatchObject({
user_id: 'user_1',
kind: 'promotion_needs_changes',
});
expect(db.notifications[0].body).toContain('add a description');
});
});