mirror of
https://github.com/wahyd4/heygo.git
synced 2026-08-09 05:15:52 +10:00
- Terraform configs for D1 databases, KV namespaces, Worker custom domains - wrangler.dev.jsonc and wrangler.prod.jsonc for environment-specific deployments - Worker code refactored to use env vars for host checking (PUBLIC_HOST, PRIVATE_HOST) - Configurable app URLs and cookie domain via env vars - Deploy and migrate npm scripts for dev/prod - Updated all tests with new env fixtures - Deployment guide in README
741 lines
23 KiB
TypeScript
741 lines
23 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;
|
|
};
|
|
|
|
class FakeD1Database {
|
|
readonly preparedSql: string[] = [];
|
|
readonly runCalls: { sql: string; params: unknown[] }[] = [];
|
|
|
|
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('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');
|
|
});
|
|
});
|