diff --git a/backend/src/db-kysely/schema.ts b/backend/src/db-kysely/schema.ts index 0b290fd..566b255 100644 --- a/backend/src/db-kysely/schema.ts +++ b/backend/src/db-kysely/schema.ts @@ -72,6 +72,9 @@ export interface Checkouts { } export interface Customers { + analytics_consent: Generated; + analytics_consent_at: Timestamp | null; + analytics_consent_text: string | null; created_at: Generated; disabled_at: Timestamp | null; email: string; diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx index 092a93e..b3e028a 100755 --- a/frontend/src/customer/Account.tsx +++ b/frontend/src/customer/Account.tsx @@ -141,7 +141,7 @@ export default function Account({ onClose }: Props) { - + Receive emails about new items @@ -149,7 +149,7 @@ export default function Account({ onClose }: Props) { customer can hold one without the other. */}
- + Email me when an item I favorited is sold
@@ -161,7 +161,7 @@ export default function Account({ onClose }: Props) { control that has to make withdrawal as easy as consenting. */}
- + Share what I browse and buy with Brevo, to make emails relevant
diff --git a/frontend/tests/e2e/account-modal.spec.ts b/frontend/tests/e2e/account-modal.spec.ts index 34327ba..c528312 100644 --- a/frontend/tests/e2e/account-modal.spec.ts +++ b/frontend/tests/e2e/account-modal.spec.ts @@ -78,7 +78,28 @@ test.describe('My Account opens as a modal', () => { await expect(accountModal.orderHistoryButton).toBeVisible(); // Scoped to the modal: the storefront behind it has a theme switch of its // own, so an unscoped switch locator would be ambiguous. - await expect(accountModal.themeSwitches).toHaveCount(2); + // + // Three since #56 added analytics consent: marketing email, favourite + // alerts, analytics. The count is a guard against one appearing or + // vanishing unnoticed, so it is asserted alongside naming each of them — + // a count alone would pass if two were swapped for each other. + await expect(accountModal.themeSwitches).toHaveCount(3); + await expect(accountModal.marketingConsentSwitch).toBeVisible(); + await expect(accountModal.favoriteAlertsSwitch).toBeVisible(); + await expect(accountModal.analyticsConsentSwitch).toBeVisible(); + }); + + // Quebec's Law 25 s.8.1 requires profiling to be off until the person turns + // it on. The server defaults the column to false and the integration suite + // asserts that; this is the half of the promise the customer can actually + // see, and the two have to agree. + test('analytics consent is off until the customer turns it on', async ({ + customer, + accountModal + }) => { + await accountModal.open(); + + await expect(accountModal.analyticsConsentSwitch).not.toBeChecked(); }); test('deleting the account does not leave the page behind it looking signed in', async ({ diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index 8529b90..934fa0d 100755 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -20,6 +20,15 @@ test.describe('Customer accounts', () => { await expect(authModal.marketingConsent).not.toBeChecked(); }); + // A second, separate consent (#56). Unchecked for the same reason as the one + // above, and asserted separately because the two must be independently + // refusable — a single control covering both is the bundling GDPR treats as + // invalid, and Law 25 requires this one to start off. + test('analytics consent checkbox is unchecked by default', async ({ authModal }) => { + await authModal.gotoRegister(); + await expect(authModal.analyticsConsent).not.toBeChecked(); + }); + test('the consent label is the exact wording the server records', async ({ authModal }) => { await authModal.gotoRegister(); diff --git a/frontend/tests/e2e/pages/AccountModal.ts b/frontend/tests/e2e/pages/AccountModal.ts index 0e2bdca..a8fff7e 100644 --- a/frontend/tests/e2e/pages/AccountModal.ts +++ b/frontend/tests/e2e/pages/AccountModal.ts @@ -16,9 +16,15 @@ export class AccountModal { readonly orderHistoryButton: Locator; readonly closeButton: Locator; readonly resendVerificationButton: Locator; + /** + * Every switch in the modal. Kept for the count assertion that guards against + * a control appearing or vanishing unnoticed — the individual switches below + * are addressed by name, not by position. + */ readonly themeSwitches: Locator; - /** The second switch in the modal; the first is the theme. */ + readonly marketingConsentSwitch: Locator; readonly favoriteAlertsSwitch: Locator; + readonly analyticsConsentSwitch: Locator; readonly notVerifiedNotice: Locator; readonly firstName: Locator; @@ -46,7 +52,25 @@ export class AccountModal { this.closeButton = this.dialog.getByRole('button', { name: 'Close' }); this.resendVerificationButton = this.dialog.getByRole('button', { name: 'Send it again' }); this.themeSwitches = this.dialog.getByRole('switch'); - this.favoriteAlertsSwitch = this.dialog.getByRole('switch').last(); + // Addressed by accessible name rather than by position. `favoriteAlertsSwitch` + // used to be `.last()`, which silently retargeted the moment the analytics + // consent switch was added below it (#56): the test that meant to turn + // favourite alerts off toggled analytics consent on instead, and failed on + // the message rather than on the switch, which said nothing about why. + // + // antd's Switch renders a bare `role="switch"` with no accessible name — the + // adjacent Text is a sibling, not a label — so each one carries an explicit + // aria-label in Account.tsx. That is what makes these addressable, and it is + // also what a screen reader needed. + this.marketingConsentSwitch = this.dialog.getByRole('switch', { + name: 'Receive emails about new items' + }); + this.favoriteAlertsSwitch = this.dialog.getByRole('switch', { + name: 'Email me when an item I favorited is sold' + }); + this.analyticsConsentSwitch = this.dialog.getByRole('switch', { + name: 'Share what I browse and buy with Brevo' + }); this.notVerifiedNotice = this.dialog.getByText('Email not verified'); this.firstName = this.dialog.getByLabel('First name', { exact: true }); diff --git a/frontend/tests/e2e/pages/AuthModal.ts b/frontend/tests/e2e/pages/AuthModal.ts index 9963545..0a73e5d 100644 --- a/frontend/tests/e2e/pages/AuthModal.ts +++ b/frontend/tests/e2e/pages/AuthModal.ts @@ -22,6 +22,7 @@ export class AuthModal { readonly lastName: Locator; readonly password: Locator; readonly marketingConsent: Locator; + readonly analyticsConsent: Locator; readonly createAccountButton: Locator; readonly createAccountTab: Locator; readonly logInTab: Locator; @@ -34,7 +35,17 @@ export class AuthModal { this.firstName = page.getByRole('textbox', { name: 'First name' }); this.lastName = page.getByRole('textbox', { name: 'Last name' }); this.password = page.getByLabel('Password'); - this.marketingConsent = page.getByRole('checkbox'); + // Named rather than "the checkbox on the form". There are two consents now + // and they are deliberately separate (#56), so an unscoped checkbox locator + // is ambiguous in strict mode — which is how this broke. Matched on the + // opening words of each sentence so the locator survives the wording being + // revised, which it has been once already. + this.marketingConsent = page.getByRole('checkbox', { + name: /^I want to receive occasional emails/ + }); + this.analyticsConsent = page.getByRole('checkbox', { + name: /^I agree that what I browse and buy/ + }); this.createAccountButton = page.getByRole('button', { name: 'Create account' }); this.createAccountTab = page.getByRole('tab', { name: 'Create Account' }); this.logInTab = page.getByRole('tab', { name: 'Log In' });