From 448cd12ac0e356f91779533a9e95677e80ca9d4d Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Thu, 3 Sep 2026 15:35:05 +0200 Subject: [PATCH 1/4] fix(core): make test newsletter provider stateful Keep contacts in memory and apply add/remove/replace group changes the way Mailchimp does, so partial updates no longer wipe or overwrite the locally stored groups. Unknown group IDs are rejected like Mailchimp's invalid interest ID response. Generated with AI Co-Authored-By: An LLM --- .../src/providers/newsletter/TestProvider.ts | 75 +++++++++++++++++-- 1 file changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/core/src/providers/newsletter/TestProvider.ts b/packages/core/src/providers/newsletter/TestProvider.ts index 346c80697..5932094c6 100644 --- a/packages/core/src/providers/newsletter/TestProvider.ts +++ b/packages/core/src/providers/newsletter/TestProvider.ts @@ -4,12 +4,20 @@ import { TestNewsletterIntegrationData, } from '@beabee/beabee-common'; +import { CantUpdateNewsletterGroupsError } from '#errors/index'; +import OptionsService from '#services/OptionsService'; import { NewsletterContact, NewsletterProvider, UpdateNewsletterContact, } from '#type/index'; +/** + * In-memory stand-in for Mailchimp. Contacts are kept per process so that + * group changes behave like they do on Mailchimp's side: partial updates only + * touch the listed groups, a full update declares every cached group, and an + * unknown group ID is rejected. + */ export class TestProvider implements NewsletterProvider { static testGroups: BaseNewsletterGroupData[] = [ { id: 'b8e4acb751', label: 'Kombucha' }, @@ -17,21 +25,74 @@ export class TestProvider implements NewsletterProvider { { id: '7bd89a737b', label: 'Coffee' }, ]; + private readonly contacts = new Map(); + async getContact(email: string): Promise { - return; + return this.contacts.get(email); } + async upsertContact( contact: UpdateNewsletterContact, - oldEmail?: string + oldEmail = contact.email ): Promise { - return { + const existing = this.contacts.get(oldEmail); + + const updated: NewsletterContact = { ...contact, - groups: contact.groups ?? [], - joined: new Date(), - tags: [], + groups: this.applyGroupChange(existing?.groups ?? [], contact), + joined: existing?.joined ?? new Date(), + tags: existing?.tags ?? [], }; + + this.contacts.delete(oldEmail); + this.contacts.set(contact.email, updated); + return updated; + } + + /** + * Mirror what `nlContactToMCMember` sends to Mailchimp and how Mailchimp + * applies it. `add`/`remove` send only the listed IDs, `replace` sends every + * cached group ID. Any sent ID the provider doesn't know is rejected. + */ + private applyGroupChange( + current: string[], + contact: UpdateNewsletterContact + ): string[] { + if (!contact.groups) { + return current; + } + + const isPartial = + contact.newsletterGroupChange === 'add' || + contact.newsletterGroupChange === 'remove'; + + const sentIds: string[] = isPartial + ? contact.groups + : OptionsService.getJSON('newsletter-groups').map( + (group: BaseNewsletterGroupData) => group.id + ); + + const knownIds = new Set(TestProvider.testGroups.map((g) => g.id)); + const invalidId = sentIds.find((id) => !knownIds.has(id)); + if (invalidId) { + throw new CantUpdateNewsletterGroupsError( + `Invalid Interest ID: ${invalidId}` + ); + } + + switch (contact.newsletterGroupChange) { + case 'add': + return [...new Set([...current, ...contact.groups])]; + case 'remove': + return current.filter((id) => !contact.groups?.includes(id)); + default: + return sentIds.filter((id) => contact.groups?.includes(id)); + } + } + + async permanentlyDeleteContact(email: string): Promise { + this.contacts.delete(email); } - async permanentlyDeleteContact(email: string): Promise {} async updateContactFields( email: string, fields: Record From 65f2ca332b65011ac694e52de1aad9d62c3bc596 Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Thu, 3 Sep 2026 15:35:05 +0200 Subject: [PATCH 2/4] test: cover newsletter group subscribe paths Add API tests for the contact newsletter-groups endpoints, callout opt-in and admin group updates, plus a browser test for the account subscriptions tab. Generated with AI Co-Authored-By: An LLM --- .../src/tests/account-newsletter.spec.ts | 87 +++++++ .../tests/client/contact-newsletter.test.ts | 219 ++++++++++++++++++ 2 files changed, 306 insertions(+) create mode 100644 apps/browser-tests/src/tests/account-newsletter.spec.ts create mode 100644 apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts diff --git a/apps/browser-tests/src/tests/account-newsletter.spec.ts b/apps/browser-tests/src/tests/account-newsletter.spec.ts new file mode 100644 index 000000000..8ec04d00f --- /dev/null +++ b/apps/browser-tests/src/tests/account-newsletter.spec.ts @@ -0,0 +1,87 @@ +import { test, expect } from "@playwright/test"; +import { + api, + rateLimitedTestUser as member, + testUser as admin, +} from "@beabee/test-utils/test-data"; +import { nonAdminAuthFile } from "../setup/auth-states"; + +test.use({ storageState: nonAdminAuthFile }); + +// Groups provided by the test newsletter provider +const KOMBUCHA = { id: "b8e4acb751", label: "Kombucha" }; +const TEA = { id: "c0b1a133d1", label: "Tea" }; + +test("Manage newsletter subscriptions", async ({ page, request }) => { + await test.step("Give the member a known set of groups", async () => { + // A full group update also pushes the groups to the newsletter provider, + // which is what the unsubscribe below is applied against + const response = await request.patch( + `${api.host}${api.path}/contact/${member.contactId}`, + { + headers: { Authorization: `Bearer ${admin.apiKey}` }, + data: { + profile: { + newsletterStatus: "subscribed", + newsletterGroups: [KOMBUCHA.id, TEA.id], + }, + }, + }, + ); + expect(response.ok(), "Profile update succeeded").toBeTruthy(); + }); + + const unsubscribeButtons = page.getByRole("button", { + name: /unsubscribe/i, + }); + const groupLabel = (label: string) => page.getByText(label, { exact: true }); + + await test.step("Groups are listed on the subscriptions tab", async () => { + await page.goto("/profile/account"); + await page.getByRole("tab", { name: /subscriptions/i }).click(); + + await expect(groupLabel(KOMBUCHA.label), "Kombucha listed").toBeVisible(); + await expect(groupLabel(TEA.label), "Tea listed").toBeVisible(); + await expect(unsubscribeButtons, "One button per group").toHaveCount(2); + }); + + await test.step("Unsubscribe from a single group", async () => { + await groupLabel(TEA.label) + .locator("..") + .getByRole("button", { name: /unsubscribe/i }) + .click(); + + await expect( + page.getByRole("alert").getByText(/unsubscribed from tea/i), + "Success notification visible", + ).toBeVisible(); + await expect(groupLabel(TEA.label), "Tea removed").not.toBeVisible(); + await expect(groupLabel(KOMBUCHA.label), "Kombucha kept").toBeVisible(); + await expect(unsubscribeButtons).toHaveCount(1); + }); + + await test.step("Change persists after reload", async () => { + await page.reload(); + await page.getByRole("tab", { name: /subscriptions/i }).click(); + + await expect(groupLabel(KOMBUCHA.label), "Kombucha kept").toBeVisible(); + await expect(groupLabel(TEA.label), "Tea still gone").not.toBeVisible(); + }); + + await test.step("Failed unsubscribe keeps the group", async () => { + await page.route("**/api/1.0/contact/me/newsletter-groups/*", (route) => + route.request().method() === "DELETE" + ? route.abort() + : route.continue(), + ); + + await unsubscribeButtons.click(); + + await expect( + page.getByRole("alert").getByText(/something went wrong/i), + "Error notification visible", + ).toBeVisible(); + await expect(groupLabel(KOMBUCHA.label), "Kombucha kept").toBeVisible(); + await expect(unsubscribeButtons).toHaveCount(1); + }); +}); diff --git a/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts b/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts new file mode 100644 index 000000000..ca7a0525c --- /dev/null +++ b/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts @@ -0,0 +1,219 @@ +import { GetContactWith, NewsletterStatus } from '@beabee/beabee-common'; +import { BeabeeClient } from '@beabee/client'; +import { api, testUser } from '@beabee/test-utils/test-data'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { + createTestCallout, + createTestCalloutResponseAnswers, +} from '../../fixtures/callouts.js'; + +// Groups provided by the test newsletter provider. Coffee is deliberately +// left out because the newsletter integrations test removes it. +const KOMBUCHA = { id: 'b8e4acb751', label: 'Kombucha' }; +const TEA = { id: 'c0b1a133d1', label: 'Tea' }; + +const PASSWORD = 'testPassword123!'; + +interface TestMember { + id: string; + client: BeabeeClient; +} + +describe('Contact newsletter groups API', () => { + let admin: BeabeeClient; + let member: TestMember; + let unsubscribedMember: TestMember; + const createdContactIds: string[] = []; + let calloutSlug: string; + + /** + * Create a contact and log in as them. Groups are set with a separate + * profile update so they are pushed to the newsletter provider. + */ + async function createMember(groups?: string[]): Promise { + const contact = await admin.contact.create({ + email: `nl-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`, + firstname: 'Newsletter', + lastname: 'Tester', + password: PASSWORD, + }); + createdContactIds.push(contact.id); + + if (groups) { + await admin.contact.update(contact.id, { + profile: { + newsletterStatus: NewsletterStatus.Subscribed, + newsletterGroups: groups, + }, + }); + } + + const client = new BeabeeClient({ host: api.host, path: api.path }); + await client.auth.login({ email: contact.email, password: PASSWORD }); + + return { id: contact.id, client }; + } + + async function getProfile(contactId: string) { + const contact = await admin.contact.get(contactId, [ + GetContactWith.Profile, + ]); + return contact.profile; + } + + beforeAll(async () => { + admin = new BeabeeClient({ + host: api.host, + path: api.path, + token: testUser.apiKey, + }); + + member = await createMember([KOMBUCHA.id, TEA.id]); + unsubscribedMember = await createMember(); + + const callout = await admin.callout.create(createTestCallout()); + calloutSlug = callout.slug; + }); + + afterAll(async () => { + await admin.callout.delete(calloutSlug); + for (const id of createdContactIds) { + await admin.contact.delete(id); + } + }); + + describe('get', () => { + it('should list the groups a contact is subscribed to', async () => { + const groups = await member.client.contact.newsletter.getGroups('me'); + + expect(groups).toHaveLength(2); + expect(groups).toEqual(expect.arrayContaining([KOMBUCHA, TEA])); + }); + + it('should return an empty list for a contact without groups', async () => { + const groups = + await unsubscribedMember.client.contact.newsletter.getGroups('me'); + + expect(groups).toEqual([]); + }); + + it("should let admins read another contact's groups", async () => { + const groups = await admin.contact.newsletter.getGroups(member.id); + + expect(groups).toHaveLength(2); + }); + + it("should not let members read another contact's groups", async () => { + await expect( + member.client.contact.newsletter.getGroups(unsubscribedMember.id) + ).rejects.toMatchObject({ httpCode: 401 }); + }); + }); + + describe('unsubscribe', () => { + it("should not let members change another contact's groups", async () => { + await expect( + member.client.contact.newsletter.unsubscribe( + unsubscribedMember.id, + KOMBUCHA.id + ) + ).rejects.toMatchObject({ httpCode: 401 }); + }); + + it('should reject an unknown group without retrying', async () => { + await expect( + member.client.contact.newsletter.unsubscribe('me', 'not-a-group') + ).rejects.toMatchObject({ httpCode: 400 }); + + const groups = await member.client.contact.newsletter.getGroups('me'); + expect(groups).toHaveLength(2); + }); + + it('should fail rather than silently succeed for a contact without newsletter status', async () => { + await expect( + unsubscribedMember.client.contact.newsletter.unsubscribe( + 'me', + KOMBUCHA.id + ) + ).rejects.toMatchObject({ httpCode: 500 }); + }); + + it('should remove only the given group', async () => { + await member.client.contact.newsletter.unsubscribe('me', TEA.id); + + const groups = await member.client.contact.newsletter.getGroups('me'); + expect(groups).toEqual([KOMBUCHA]); + + const profile = await getProfile(member.id); + expect(profile.newsletterGroups).toEqual([KOMBUCHA.id]); + expect(profile.newsletterStatus).toBe(NewsletterStatus.Subscribed); + }); + + it('should leave the contact subscribed with no groups after the last one', async () => { + await member.client.contact.newsletter.unsubscribe('me', KOMBUCHA.id); + + const groups = await member.client.contact.newsletter.getGroups('me'); + expect(groups).toEqual([]); + + const profile = await getProfile(member.id); + expect(profile.newsletterGroups).toEqual([]); + expect(profile.newsletterStatus).toBe(NewsletterStatus.Subscribed); + }); + }); + + describe('other contact updates', () => { + let otherMember: TestMember; + + beforeAll(async () => { + otherMember = await createMember([KOMBUCHA.id, TEA.id]); + }); + + it('should keep groups when a contact is updated without group changes', async () => { + await admin.contact.update(otherMember.id, { firstname: 'Renamed' }); + + const profile = await getProfile(otherMember.id); + expect(profile.newsletterGroups.sort()).toEqual( + [KOMBUCHA.id, TEA.id].sort() + ); + }); + + it('should replace groups when an admin sets them', async () => { + await admin.contact.update(otherMember.id, { + profile: { newsletterGroups: [KOMBUCHA.id] }, + }); + + const profile = await getProfile(otherMember.id); + expect(profile.newsletterGroups).toEqual([KOMBUCHA.id]); + + const groups = await admin.contact.newsletter.getGroups(otherMember.id); + expect(groups).toEqual([KOMBUCHA]); + }); + + it('should add groups on callout opt-in without removing existing ones', async () => { + await otherMember.client.callout.createResponse(calloutSlug, { + answers: createTestCalloutResponseAnswers('slide1'), + newsletter: { optIn: true, groups: [TEA.id] }, + }); + + const profile = await getProfile(otherMember.id); + expect(profile.newsletterGroups.sort()).toEqual( + [KOMBUCHA.id, TEA.id].sort() + ); + // A subscribed contact must not be sent back to pending + expect(profile.newsletterStatus).toBe(NewsletterStatus.Subscribed); + }); + + it('should set pending status on callout opt-in for a contact without newsletter status', async () => { + await unsubscribedMember.client.callout.createResponse(calloutSlug, { + answers: createTestCalloutResponseAnswers('slide1'), + newsletter: { optIn: true, groups: [TEA.id] }, + }); + + const profile = await getProfile(unsubscribedMember.id); + expect(profile.newsletterGroups).toEqual([TEA.id]); + expect(profile.newsletterStatus).toBe(NewsletterStatus.Pending); + }); + }); +}); From 88c472047208d10f6362847e3d933c6520e7efcd Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Thu, 3 Sep 2026 15:49:03 +0200 Subject: [PATCH 3/4] test(e2e): act as contacts via x-contact-id header The client's cookie store is shared between all instances in a process, so logging in as several contacts invalidated each other's sessions. Generated with AI Co-Authored-By: An LLM --- .../tests/client/contact-newsletter.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts b/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts index ca7a0525c..a1d87c8b1 100644 --- a/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts +++ b/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts @@ -14,8 +14,6 @@ import { const KOMBUCHA = { id: 'b8e4acb751', label: 'Kombucha' }; const TEA = { id: 'c0b1a133d1', label: 'Tea' }; -const PASSWORD = 'testPassword123!'; - interface TestMember { id: string; client: BeabeeClient; @@ -29,15 +27,17 @@ describe('Contact newsletter groups API', () => { let calloutSlug: string; /** - * Create a contact and log in as them. Groups are set with a separate - * profile update so they are pushed to the newsletter provider. + * Create a contact and a client acting as them. The client uses the admin + * API key with the `x-contact-id` header rather than a cookie login, as the + * client's cookie store is shared between all instances in a process. + * Groups are set with a separate profile update so they are pushed to the + * newsletter provider. */ async function createMember(groups?: string[]): Promise { const contact = await admin.contact.create({ email: `nl-${Date.now()}-${Math.random().toString(36).slice(2)}@example.com`, firstname: 'Newsletter', lastname: 'Tester', - password: PASSWORD, }); createdContactIds.push(contact.id); @@ -50,8 +50,12 @@ describe('Contact newsletter groups API', () => { }); } - const client = new BeabeeClient({ host: api.host, path: api.path }); - await client.auth.login({ email: contact.email, password: PASSWORD }); + const client = new BeabeeClient({ + host: api.host, + path: api.path, + token: testUser.apiKey, + headers: { 'x-contact-id': contact.id }, + }); return { id: contact.id, client }; } From 09c3e387a8d32e515626ecb697ebe66f990ae4cb Mon Sep 17 00:00:00 2001 From: Will Franklin Date: Thu, 3 Sep 2026 15:49:03 +0200 Subject: [PATCH 4/4] ci: serve new frontend in test stack for browser tests The account subscriptions tab only exists in the new frontend, which the router serves behind the beabee_frontend cookie. Generated with AI Co-Authored-By: An LLM --- apps/browser-tests/src/tests/account-newsletter.spec.ts | 8 +++++++- docker-compose.test.yml | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/browser-tests/src/tests/account-newsletter.spec.ts b/apps/browser-tests/src/tests/account-newsletter.spec.ts index 8ec04d00f..0e1863f8c 100644 --- a/apps/browser-tests/src/tests/account-newsletter.spec.ts +++ b/apps/browser-tests/src/tests/account-newsletter.spec.ts @@ -12,7 +12,13 @@ test.use({ storageState: nonAdminAuthFile }); const KOMBUCHA = { id: "b8e4acb751", label: "Kombucha" }; const TEA = { id: "c0b1a133d1", label: "Tea" }; -test("Manage newsletter subscriptions", async ({ page, request }) => { +test("Manage newsletter subscriptions", async ({ page, request, baseURL }) => { + // The account page with the subscriptions tab only exists in the new + // frontend, which the router serves when this cookie is set + await page.context().addCookies([ + { name: "beabee_frontend", value: "new", url: baseURL! }, + ]); + await test.step("Give the member a known set of groups", async () => { // A full group update also pushes the groups to the newsletter provider, // which is what the unsubscribe below is applied against diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 612ca48a1..c6777e25f 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -58,6 +58,11 @@ services: file: docker-compose.yml service: frontend + frontend-new: + extends: + file: docker-compose.yml + service: frontend-new + app_router: extends: file: docker-compose.yml