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..0e1863f8c --- /dev/null +++ b/apps/browser-tests/src/tests/account-newsletter.spec.ts @@ -0,0 +1,93 @@ +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, 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 + 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..a1d87c8b1 --- /dev/null +++ b/apps/e2e-api-tests/src/tests/client/contact-newsletter.test.ts @@ -0,0 +1,223 @@ +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' }; + +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 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', + }); + 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, + token: testUser.apiKey, + headers: { 'x-contact-id': contact.id }, + }); + + 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); + }); + }); +}); 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 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