Files
hope-events/e2e/api-v2.spec.ts
T

355 lines
24 KiB
TypeScript

import { test, expect, seed, API } from "./fixtures";
// /api/v2 (1.51.0) against the real server and database: paged public events, the v2 error
// shape, the signed-in person, and the OpenAPI reference at /docs/v2.
test.describe("API v2", () => {
test("public events come a page at a time; errors have a code", async ({ request }) => {
const res = await request.get(`${API}/api/v2/events?limit=2`);
expect(res.ok()).toBe(true);
const body = await res.json();
expect(body.data).toHaveLength(2);
expect(body.meta).toMatchObject({ page: 1, limit: 2 });
expect(body.meta.total).toBeGreaterThan(2);
const search = await (await request.get(`${API}/api/v2/events?q=free%20evening`)).json();
expect(search.data.map((e: { title: string }) => e.title)).toEqual(["E2E Free Evening"]);
const one = await (await request.get(`${API}/api/v2/events/${seed().events.paid}`)).json();
expect(one.data.ticketOptions.length).toBeGreaterThan(0);
const bad = await request.get(`${API}/api/v2/events?limit=1000`);
expect(bad.status()).toBe(400);
expect((await bad.json()).error).toMatchObject({ code: "VALIDATION_FAILED", fields: { limit: expect.any(String) } });
const missing = await request.get(`${API}/api/v2/events/00000000-0000-4000-8000-000000000000`);
expect((await missing.json()).error.code).toBe("NOT_FOUND");
});
test("me and my registrations need a token", async ({ request }) => {
expect((await request.get(`${API}/api/v2/me`)).status()).toBe(401);
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
const me = await (await request.get(`${API}/api/v2/me`, { headers })).json();
expect(me.data).toMatchObject({ id: seed().users.user.id, role: "user" });
const regs = await (await request.get(`${API}/api/v2/me/registrations?past=true`, { headers })).json();
expect(regs.meta).toMatchObject({ page: 1, limit: 25 });
expect(regs.data.length).toBe(Math.min(25, regs.meta.total));
});
test("register for a free event, then cancel it", async ({ request }) => {
const headers = { Authorization: `Bearer ${seed().users.staff.token}` };
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
const made = await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
expect(made.status()).toBe(201);
const reg = (await made.json()).data;
expect(reg).toMatchObject({ status: "paid", balance: 0, event: { id: event.id } });
const someoneElse = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: { Authorization: `Bearer ${seed().users.user.token}` } });
expect((await someoneElse.json()).error.code).toBe("NOT_FOUND");
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
expect((await cancelled.json()).data.status).toBe("cancelled");
const again = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers });
expect((await again.json()).error).toMatchObject({ code: "VALIDATION_FAILED", message: "Registration is already cancelled" });
});
test("log in, change my details, and check a ticket in", async ({ request }) => {
const bad = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "wrong-password" } });
expect(bad.status()).toBe(401);
expect((await bad.json()).error).toMatchObject({ code: "UNAUTHENTICATED", message: "Invalid email or password" });
const login = await (await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "E2e-password-123" } })).json();
expect(login.data).toMatchObject({ status: "signed_in", user: { id: seed().users.user.id, role: "user" } });
const headers = { Authorization: `Bearer ${login.data.token}` };
const changed = await (await request.patch(`${API}/api/v2/me`, { headers, data: { notificationPreference: "email" } })).json();
expect(changed.data).toMatchObject({ id: seed().users.user.id, notificationPreference: "email" });
expect(changed.token).toEqual(expect.any(String));
// A free ticket for the user, then check it in twice through v2.
const event = (await (await request.get(`${API}/api/v2/events/${seed().events.free}`)).json()).data;
await request.post(`${API}/api/v2/registrations`, { headers, data: { eventId: event.id, options: [{ eventOptionId: event.ticketOptions[0].id, quantity: 1 }] } });
const tickets = await (await request.get(`${API}/api/tickets/mytickets`, { headers })).json();
const qr = tickets.find((t: { eventId: string; qrCode: string }) => t.eventId === event.id).qrCode;
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
expect((await request.post(`${API}/api/v2/checkin/${qr}`, { headers, data: {} })).status()).toBe(403);
const look = await (await request.get(`${API}/api/v2/checkin/${qr}`, { headers: staff })).json();
expect(look.data).toMatchObject({ outcome: "found", ticket: { eventId: event.id } });
expect(look.data.ticket.remaining).toBeGreaterThan(0);
const scanned = await (await request.post(`${API}/api/v2/checkin/${qr}`, { headers: staff, data: { eventId: event.id } })).json();
expect(scanned.data).toMatchObject({ outcome: "checked_in", remaining: 0 });
const again = await (await request.post(`${API}/api/v2/checkin/${qr}`, { headers: staff, data: {} })).json();
expect(again.data).toMatchObject({ outcome: "already_used", checkedIn: 0 });
expect((await (await request.post(`${API}/api/v2/checkin/NOT-A-CODE`, { headers: staff, data: {} })).json()).error.code).toBe("NOT_FOUND");
});
test("v1 endpoints with a v2 version say so", async ({ request }) => {
const v1 = await request.get(`${API}/api/events`);
expect(v1.headers()["deprecation"]).toMatch(/^@\d+$/);
expect(v1.headers()["link"]).toBe(`</api/v2/events>; rel="successor-version"`);
const all = await request.get(`${API}/api/events/all`, { headers: { Authorization: `Bearer ${seed().users.staff.token}` } });
expect(all.headers()["link"]).toBe(`</api/v2/events/manage>; rel="successor-version"`);
expect((await request.get(`${API}/api/settings/needs-setup`)).headers()["link"]).toBe(`</api/v2/setup/needed>; rel="successor-version"`);
// Incoming webhooks: the v1 address still works (same handler) and names its v2 one.
const hook = await request.post(`${API}/api/webhooks/whatsapp`, { data: {} });
expect(hook.status()).toBe(200);
expect(hook.headers()["link"]).toBe(`</api/v2/webhooks/whatsapp>; rel="successor-version"`);
expect((await request.post(`${API}/api/v2/webhooks/whatsapp`, { data: { event: "noop" } })).status()).toBe(200);
// A calendar link in an email already sent goes to its v2 address.
const ics = await request.get(`${API}/api/events/${seed().events.conference}/ics`, { maxRedirects: 0 });
expect(ics.status()).toBe(308);
expect(ics.headers()["location"]).toBe(`/api/v2/events/${seed().events.conference}/calendar.ics`);
const followed = await request.get(`${API}/api/events/${seed().events.conference}/ics`);
expect(followed.headers()["content-type"]).toContain("text/calendar");
});
test("staff list every event; supervisors create, change and admins deactivate", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const list = await (await request.get(`${API}/api/v2/events/manage`, { headers: staff })).json();
// The kids event is hidden from the public list but staff see it.
expect(list.data.map((e: { id: string }) => e.id)).toContain(seed().events.kids);
expect((await (await request.get(`${API}/api/v2/events?limit=100`)).json()).data.map((e: { id: string }) => e.id)).not.toContain(seed().events.kids);
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const start = new Date(Date.now() + 20 * 864e5);
const made = await request.post(`${API}/api/v2/events`, { headers: sup, data: { title: "E2E v2 Event", description: "Made through v2", price: 0, location: "1 Test Street", startDate: start.toISOString(), endDate: new Date(start.getTime() + 3600e3).toISOString() } });
expect(made.status()).toBe(201);
const ev = (await made.json()).data;
expect(ev.eventOptions.map((o: { name: string }) => o.name)).toEqual(["Main Ticket"]);
const changed = await (await request.patch(`${API}/api/v2/events/${ev.id}`, { headers: sup, data: { title: "E2E v2 Event (renamed)", waitlistEnabled: true } })).json();
expect(changed.data).toMatchObject({ title: "E2E v2 Event (renamed)", waitlistEnabled: true, sessionChanges: null });
expect((await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: sup })).status()).toBe(403);
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
expect((await (await request.delete(`${API}/api/v2/events/${ev.id}`, { headers: admin })).json()).data).toEqual({ id: ev.id, isActive: false });
});
test("an event in full, its calendar file, ticket options, sections and costs", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const full = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
expect(full.eventOptions.length).toBeGreaterThan(0);
expect(full).toHaveProperty("attachments");
// The public don't see who gets the emails; staff do.
expect(full.notifyRecipients).toBeUndefined();
expect((await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`, { headers: sup })).json()).data).toHaveProperty("notifyRecipients");
const ics = await request.get(`${API}/api/v2/events/${seed().events.conference}/calendar.ics`);
expect(ics.headers()["content-type"]).toContain("text/calendar");
expect(await ics.text()).toContain("BEGIN:VCALENDAR");
const opt = (await (await request.post(`${API}/api/v2/events/${seed().events.conference}/ticket-options`, { headers: sup, data: { name: "E2E v2 Extra", price: 25 } })).json()).data;
expect(opt).toMatchObject({ name: "E2E v2 Extra", price: 25, isMainTicket: false });
const withVariant = (await (await request.patch(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup, data: { variants: [{ name: "Large", price: 30 }] } })).json()).data;
expect(withVariant.variants.map((v: { name: string }) => v.name)).toEqual(["Large"]);
expect((await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: sup })).status()).toBe(403);
expect((await (await request.delete(`${API}/api/v2/ticket-options/${opt.id}`, { headers: admin })).json()).data).toEqual({ id: opt.id, deleted: true });
const section = (await (await request.post(`${API}/api/v2/sections`, { headers: sup, data: { eventId: seed().events.conference, name: "E2E Side door", allowedOptionIds: [full.eventOptions[0].id] } })).json()).data;
expect(section.allowedOptions).toHaveLength(1);
const listed = (await (await request.get(`${API}/api/v2/sections?eventId=${seed().events.conference}`, { headers: sup })).json()).data;
expect(listed.map((x: { id: string }) => x.id)).toContain(section.id);
const cost = await request.post(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: admin, data: { label: "E2E Hall", costType: "once_off", amount: 500 } });
expect(cost.status()).toBe(201);
const costs = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/costs`, { headers: sup })).json()).data;
expect(costs.map((c: { label: string }) => c.label)).toContain("E2E Hall");
});
test("series, coupon codes, bundle quotes and campuses", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const made = await request.post(`${API}/api/v2/series`, { headers: sup, data: { name: "E2E v2 Series", description: "Made through v2" } });
expect(made.status()).toBe(201);
const series = (await made.json()).data;
const renamed = (await (await request.patch(`${API}/api/v2/series/${series.id}`, { headers: sup, data: { name: "E2E v2 Series (renamed)" } })).json()).data;
expect(renamed.name).toBe("E2E v2 Series (renamed)");
const page = (await (await request.get(`${API}/api/v2/series/by-slug/${series.slug}`, { headers: user })).json()).data;
expect(page).toMatchObject({ id: series.id, following: false, upcoming: [], past: [] });
expect((await (await request.put(`${API}/api/v2/series/${series.id}/follow`, { headers: user })).json()).data).toEqual({ following: true });
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
const opt = ev.eventOptions[0];
const code = await request.post(`${API}/api/v2/coupons`, { headers: sup, data: { code: "E2EV2TEN", type: "percent", value: 10, eventIds: [ev.id] } });
expect(code.status()).toBe(201);
const basket = { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }] };
const ok = (await (await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "E2EV2TEN" } })).json()).data;
expect(ok).toMatchObject({ valid: true, code: "E2EV2TEN" });
const bad = await request.post(`${API}/api/v2/coupons/check`, { headers: user, data: { ...basket, code: "NO-SUCH-CODE" } });
expect(bad.status()).toBe(200);
expect((await bad.json()).data.valid).toBe(false);
const quote = (await (await request.post(`${API}/api/v2/bundles/quote`, { data: basket })).json()).data;
expect(quote).toMatchObject({ bundleDiscount: 0 });
expect(quote.total).toBe(quote.subtotal);
const campuses = (await (await request.get(`${API}/api/v2/campuses`)).json()).data;
// On or off depending on which tests ran first: when off, the list is empty.
if (campuses.enabled) expect(campuses.campuses.map((c: { slug: string }) => c.slug)).toEqual(expect.arrayContaining([seed().campuses.north.slug]));
else expect(campuses.campuses).toEqual([]);
expect((await request.get(`${API}/api/v2/campuses/admin`, { headers: sup })).status()).toBe(403);
});
test("staff register someone at the door and manage the registration", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const ev = (await (await request.get(`${API}/api/v2/events/${seed().events.conference}/full`)).json()).data;
const opt = ev.eventOptions[0];
const made = await request.post(`${API}/api/v2/registrations/manual`, {
headers: sup,
data: { eventId: ev.id, options: [{ eventOptionId: opt.id, quantity: 1 }], user: { name: "E2E Door Guest", email: "e2e-v2-door@example.com" }, skipActivationNotice: true },
});
expect(made.status()).toBe(201);
const reg = (await made.json()).data;
expect(reg).toMatchObject({ eventId: ev.id, status: expect.any(String) });
const list = (await (await request.get(`${API}/api/v2/events/${ev.id}/registrations?search=e2e-v2-door`, { headers: sup })).json()).data;
expect(list.map((r: { id: string }) => r.id)).toContain(reg.id);
const full = (await (await request.get(`${API}/api/v2/registrations/${reg.id}/full`, { headers: sup })).json()).data;
expect(full.registrationOptions[0]).toMatchObject({ eventOptionId: opt.id, quantity: 1 });
const bad = await request.patch(`${API}/api/v2/registrations/${reg.id}/status`, { headers: sup, data: { status: "refunded" } });
expect(bad.status()).toBe(400);
expect((await bad.json()).error.code).toBe("VALIDATION_FAILED");
const page = await (await request.get(`${API}/api/v2/registrations?limit=2`, { headers: sup })).json();
expect(page.meta).toMatchObject({ page: 1, limit: 2 });
expect(page.data.length).toBeLessThanOrEqual(2);
// Tidy up: cancelled registrations don't count towards anything.
const cancelled = await request.post(`${API}/api/v2/registrations/${reg.id}/cancel`, { headers: admin });
expect((await cancelled.json()).data.status).toBe("cancelled");
});
test("money: providers, cash-up, my payments, and links that don't exist", async ({ request }) => {
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const providers = await request.get(`${API}/api/v2/payments/providers`);
expect(providers.status()).toBe(200);
expect(await providers.json()).toHaveProperty("data");
const cash = await request.get(`${API}/api/v2/events/${seed().events.conference}/cashup`, { headers: sup });
expect(cash.status()).toBe(200);
expect((await cash.json()).data).toBeTruthy();
const mine = await request.get(`${API}/api/v2/me/payments?page=1&limit=5`, { headers: user });
expect(mine.status()).toBe(200);
const page = await request.get(`${API}/api/v2/payments?page=1&limit=3`, { headers: sup });
expect((await page.json()).data).toMatchObject({ page: 1, limit: 3 });
for (const path of ["/api/v2/waitlist/claims/not-a-real-token", "/api/v2/consent/not-a-real-token", "/api/v2/feedback-forms/not-a-real-token"]) {
const res = await request.get(`${API}${path}`);
expect(res.status(), path).toBeGreaterThanOrEqual(400);
expect((await res.json()).error.code, path).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
}
});
test("tickets and attendance: mine, the register, a spreadsheet download, links that don't exist", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
const mine = await request.get(`${API}/api/v2/me/tickets`, { headers: user });
expect(mine.status()).toBe(200);
expect(Array.isArray((await mine.json()).data)).toBe(true);
const reg = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance`, { headers: staff });
expect(reg.status()).toBe(200);
const xlsx = await request.get(`${API}/api/v2/events/${seed().events.conference}/attendance/export?format=xlsx`, { headers: staff });
expect(xlsx.status()).toBe(200);
expect(xlsx.headers()["content-type"]).toContain("spreadsheetml");
expect(xlsx.headers()["content-disposition"]).toContain(".xlsx");
expect((await xlsx.body()).subarray(0, 2).toString()).toBe("PK");
// A code that isn't a certificate is an answer, not an error.
expect((await (await request.get(`${API}/api/v2/certificates/verify/NO-SUCH-CODE`)).json()).data).toEqual({ valid: false });
const transfer = await request.get(`${API}/api/v2/ticket-transfers/not-a-real-token`);
expect(transfer.status()).toBeGreaterThanOrEqual(400);
expect((await transfer.json()).error.code).toMatch(/NOT_FOUND|VALIDATION_FAILED/);
});
test("kids and volunteers: my family, where I serve, the staff lists", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
for (const [path, headers] of [["/api/v2/me/family", user], ["/api/v2/me/volunteering", user], ["/api/v2/kids/events", staff], ["/api/v2/volunteering/events?all=1", staff]] as const) {
const res = await request.get(`${API}${path}`, { headers });
expect(res.status(), path).toBe(200);
expect(await res.json(), path).toHaveProperty("data");
}
// Staff lists aren't for everyone.
expect((await request.get(`${API}/api/v2/kids/events`, { headers: user })).status()).toBe(403);
});
test("people and messaging: event listing, dashboards, saved filters, accounts", async ({ request }) => {
const staff = { Authorization: `Bearer ${seed().users.staff.token}` };
const sup = { Authorization: `Bearer ${seed().users.supervisor.token}` };
const listing = (await (await request.get(`${API}/api/v2/event-listing`)).json()).data;
expect(Array.isArray(listing)).toBe(true);
expect(listing[0]).toHaveProperty("eventOptions");
for (const path of ["/api/v2/stats/staff", "/api/v2/dashboard/today", "/api/v2/banner"]) {
const res = await request.get(`${API}${path}`, { headers: staff });
expect(res.status(), path).toBe(200);
}
// Saved filters run the v1 router: save one, list it, delete it.
const saved = await request.post(`${API}/api/v2/saved-filters`, { headers: sup, data: { page: "events", name: "E2E v2 filter", params: { q: "x" } } });
expect(saved.status()).toBe(200);
const list = (await (await request.get(`${API}/api/v2/saved-filters?page=events`, { headers: sup })).json()).data;
const mine = (Array.isArray(list) ? list : list.filters || []).find((x: { name: string }) => x.name === "E2E v2 filter");
expect(mine).toBeTruthy();
expect((await request.delete(`${API}/api/v2/saved-filters/${mine.id}`, { headers: sup })).status()).toBe(200);
const users = (await (await request.get(`${API}/api/v2/users?page=1&limit=2`, { headers: sup })).json()).data;
expect(users).toMatchObject({ page: 1, limit: 2 });
// Forgot-password doesn't say whether an account exists (1.51.17): the same answer either way.
const unknown = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: "nobody-e2e@example.com" } });
const known = await request.post(`${API}/api/v2/auth/password/forgot`, { data: { email: seed().users.user.email } });
expect(unknown.status()).toBe(200);
expect(await unknown.json()).toEqual(await known.json());
// Nor does sign-in: an unknown email is refused like a wrong password.
const noAccount = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: "nobody-e2e@example.com", password: "Wrong-password-1" } });
const wrongPassword = await request.post(`${API}/api/v2/auth/login`, { data: { identifier: seed().users.user.email, password: "Wrong-password-1" } });
expect(noAccount.status()).toBe(wrongPassword.status());
expect(await noAccount.json()).toEqual(await wrongPassword.json());
});
test("privacy and admin: settings, my data, the audit log, webhooks, backups", async ({ request }) => {
const admin = { Authorization: `Bearer ${seed().users.admin.token}` };
const user = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await (await request.get(`${API}/api/v2/settings`)).json()).data).toBeTruthy();
expect((await request.get(`${API}/api/v2/setup/needed`)).status()).toBe(200);
expect((await request.get(`${API}/api/v2/me/privacy`, { headers: user })).status()).toBe(200);
const exported = await request.get(`${API}/api/v2/me/privacy/export`, { headers: user });
expect(exported.status()).toBe(200);
expect(exported.headers()["content-disposition"]).toContain("my-data-");
expect(JSON.parse((await exported.body()).toString())).toBeTruthy();
for (const path of ["/api/v2/admin/audit-log?page=1&limit=5", "/api/v2/outbound-webhooks", "/api/v2/backups", "/api/v2/privacy/requests"]) {
const res = await request.get(`${API}${path}`, { headers: admin });
expect(res.status(), path).toBe(200);
}
// Admin areas aren't for everyone.
expect((await request.get(`${API}/api/v2/admin/audit-log`, { headers: user })).status()).toBe(403);
});
test("an admin can read the OpenAPI reference at /docs/v2", async ({ page, request }) => {
const headers = { Authorization: `Bearer ${seed().users.user.token}` };
expect((await request.get(`${API}/api/v2/openapi.json`, { headers })).status()).toBe(403);
await page.goto(`${API}/docs/v2?token=${seed().users.admin.token}`);
await expect(page.getByRole("heading", { name: /Cross Events API/ })).toBeVisible();
// Sections start collapsed (there are hundreds of endpoints): open Events.
await page.locator('h3.opblock-tag[data-tag="Events"]').click();
await expect(page.getByRole("button", { name: /^GET \/events\/\{id\} One public/ })).toBeVisible();
// The token isn't left in the address bar.
expect(page.url()).not.toContain("token=");
});
});