diff --git a/README.md b/README.md
index e8426fb..5db6097 100644
--- a/README.md
+++ b/README.md
@@ -41,6 +41,7 @@ projects in the DNS/deployment configuration. The `handbook.` subdomain is unaff
## Roadmap (v2+)
- `/confirm-aktionariat` — guided Aktionariat address confirmation (calls `api.dfx.swiss`)
+- `/account-merge` — confirms adding a wallet address to the existing account (calls the public DFX API)
- Legal pages — rendered from the app's `assets/legal/*.md` (build-time fetch, single source)
- Universal Links / App Links (`/.well-known/*`)
diff --git a/public/account-merge/index.html b/public/account-merge/index.html
new file mode 100644
index 0000000..d1eab5c
--- /dev/null
+++ b/public/account-merge/index.html
@@ -0,0 +1,152 @@
+
+
+
+
+
+ RealUnit — Adresse hinzufügen
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Adresse wird hinzugefügt…
+
Einen Moment, wir fügen die Wallet-Adresse Ihrem Konto hinzu.
+
+
+
+
+
+
Adresse hinzugefügt
+
+
Die Wallet-Adresse ist Ihrem Konto hinzugefügt. Kehren Sie auf Ihrem Smartphone zur RealUnit-App zurück.
+
+
Die Wallet-Adresse ist Ihrem Konto hinzugefügt.
+
+
+
+
+
+
+
Adresse bereits hinzugefügt
+
Diese Wallet-Adresse ist bereits auf Ihrem Konto. Kehren Sie auf Ihrem Smartphone zur RealUnit-App zurück.
+
Diese Wallet-Adresse ist bereits auf Ihrem Konto.
+
+
+
+
+
+
+
Link ungültig oder abgelaufen
+
Dieser Link ist ungültig oder bereits abgelaufen. Bitte fordern Sie in der App eine neue Bestätigung an.
+
+
+
+
+
+
Dienst vorübergehend nicht erreichbar
+
Wir konnten die Adresse gerade nicht hinzufügen. Bitte versuchen Sie es in ein paar Minuten erneut.
+
+
+
+
+
+
+
+
+
+
diff --git a/public/account-merge/merge.js b/public/account-merge/merge.js
new file mode 100644
index 0000000..670c5bb
--- /dev/null
+++ b/public/account-merge/merge.js
@@ -0,0 +1,253 @@
+/* DOM + network glue for the account-merge confirmation page. The pure,
+ testable logic (language resolution, host/API-base derivation, response → state
+ mapping, and the i18n copy) lives in js/lib/merge-core.js, loaded before this
+ file; everything here touches the DOM/network and is covered by the Playwright
+ functional suite. */
+(function () {
+ 'use strict';
+
+ var core = window.RealUnitMerge;
+ var params = new URLSearchParams(window.location.search);
+ var host = window.location.hostname;
+
+ var lang = core.resolveLang({
+ urlLang: params.get('lang'),
+ navigatorLang: navigator.language,
+ supported: core.SUPPORTED_LANGS,
+ defaultLang: 'de',
+ });
+ document.documentElement.lang = lang;
+ var t = core.I18N[lang];
+
+ // Apply translations: text content, alt text, aria-label, and document meta.
+ document.querySelectorAll('[data-i18n]').forEach(function (el) {
+ var v = t[el.getAttribute('data-i18n')];
+ if (v) el.textContent = v;
+ });
+ document.querySelectorAll('[data-i18n-alt]').forEach(function (el) {
+ var v = t[el.getAttribute('data-i18n-alt')];
+ if (v) el.setAttribute('alt', v);
+ });
+ document.querySelectorAll('[data-i18n-aria]').forEach(function (el) {
+ var v = t[el.getAttribute('data-i18n-aria')];
+ if (v) el.setAttribute('aria-label', v);
+ });
+ if (t['doc.title']) document.title = t['doc.title'];
+ var descEl = document.querySelector('meta[name="description"]');
+ if (descEl && t['doc.desc']) descEl.setAttribute('content', t['doc.desc']);
+
+ // The return-to-app hand-off uses a fixed custom URL scheme,
+ // realunit-wallet://open, hard-coded on the button in the markup — no host
+ // derivation needed. realunit.app claims no Universal/App Link, so the
+ // merge email always reaches this web page; the app registers the scheme
+ // to re-open itself after confirmation. This page never redirects.
+
+ var STATES = ['loading', 'confirmed', 'already-completed', 'invalid', 'unavailable'];
+ function show(state) {
+ STATES.forEach(function (s) {
+ document.getElementById('state-' + s).hidden = s !== state;
+ });
+ }
+
+ // The confirmed / already-completed copy is chosen purely in CSS from
+ // html[data-platform] (set by platform.js before first paint): a phone gets
+ // the "back to the app" button (the realunit-wallet:// scheme only resolves
+ // on the device), while a desktop — where the scheme opens nothing — is told
+ // to return on its phone.
+ function render(status) {
+ if (status === 'confirmed') {
+ show('confirmed');
+ } else if (status === 'already-completed') {
+ show('already-completed');
+ } else if (status === 'invalid') {
+ show('invalid');
+ } else if (status === 'unavailable') {
+ show('unavailable');
+ } else {
+ show('unavailable');
+ }
+ }
+
+ // Parse JSON; on failure still return the HTTP status with an empty body so
+ // mapHttpToState can decide from the status alone.
+ function parseJsonBody(res) {
+ return res
+ .json()
+ .then(function (body) {
+ return { status: res.status, body: body };
+ })
+ .catch(function () {
+ return { status: res.status, body: {} };
+ });
+ }
+
+ // Poll a DFX async job until it is terminal or the budget expires. Keeps the
+ // loading state visible; there is no state-job UI.
+ // First GET runs immediately so expectedSeconds <= 1 can still observe Complete;
+ // the 1s interval applies only between subsequent polls.
+ function pollJob(base, jobBody, otp) {
+ // Already-terminal ticket: re-confirm or fail immediately; never poll.
+ var initialStatus = jobBody && jobBody.status;
+ if (core.isJobTerminal(initialStatus)) {
+ if (initialStatus === 'Complete') {
+ fetchConfirm(base, otp, false);
+ } else {
+ render('unavailable');
+ }
+ return;
+ }
+
+ var uid = jobBody.uid;
+ var budgetSec =
+ typeof jobBody.expectedSeconds === 'number' && jobBody.expectedSeconds > 0
+ ? jobBody.expectedSeconds
+ : 60;
+ var budgetMs = budgetSec * 1000;
+ var started = Date.now();
+
+ function scheduleNext() {
+ setTimeout(function () {
+ if (Date.now() - started >= budgetMs) {
+ render('unavailable');
+ return;
+ }
+ pollOnce();
+ }, 1000);
+ }
+
+ function pollOnce() {
+ var controller = new AbortController();
+ var timeoutId = setTimeout(function () {
+ controller.abort();
+ }, 15000);
+
+ fetch(core.buildJobUrl(base, uid), {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ credentials: 'omit',
+ signal: controller.signal,
+ })
+ .then(function (res) {
+ return res
+ .json()
+ .then(function (body) {
+ return { status: res.status, body: body };
+ })
+ .catch(function () {
+ // JSON failure on a poll is treated as unavailable.
+ throw new Error('job-json');
+ });
+ })
+ .then(function (r) {
+ clearTimeout(timeoutId);
+ // Non-2xx job GET is unavailable; do not read body.status as in-flight.
+ if (!(r.status >= 200 && r.status < 300)) {
+ render('unavailable');
+ return;
+ }
+ var status = r.body && r.body.status;
+ if (core.isJobTerminal(status)) {
+ if (status === 'Complete') {
+ // Job finished: re-confirm with the same OTP; a second job is an error.
+ fetchConfirm(base, otp, false);
+ } else {
+ // Failed or DeadLetter.
+ render('unavailable');
+ }
+ } else {
+ // Pending / Processing / Retry / unknown — keep polling.
+ if (Date.now() - started >= budgetMs) {
+ render('unavailable');
+ } else {
+ scheduleNext();
+ }
+ }
+ })
+ .catch(function (err) {
+ clearTimeout(timeoutId);
+ // Abort: keep polling while budget remains; other failures → unavailable.
+ if (err && err.name === 'AbortError') {
+ if (Date.now() - started < budgetMs) {
+ scheduleNext();
+ } else {
+ render('unavailable');
+ }
+ } else {
+ render('unavailable');
+ }
+ });
+ }
+
+ pollOnce();
+ }
+
+ // allowJob: true on the first confirm (and retry); false on re-confirm after
+ // job Complete — a second job ticket is an error, not a new poll budget.
+ function fetchConfirm(base, otp, allowJob) {
+ // Abort a stalled request so the spinner can never hang forever.
+ var controller = new AbortController();
+ var timeoutId = setTimeout(function () {
+ controller.abort();
+ }, 15000);
+
+ fetch(core.buildConfirmUrl(base, otp), {
+ method: 'GET',
+ headers: { Accept: 'application/json' },
+ credentials: 'omit',
+ signal: controller.signal,
+ })
+ .then(function (res) {
+ return parseJsonBody(res);
+ })
+ .then(function (r) {
+ clearTimeout(timeoutId);
+ var state = core.mapHttpToState(r.status, r.body);
+ if (state === 'job' && allowJob) {
+ // Keep showing loading while the job is polled; no state-job UI.
+ pollJob(base, r.body, otp);
+ } else if (state === 'job' && !allowJob) {
+ render('unavailable');
+ } else {
+ render(state);
+ }
+ })
+ .catch(function () {
+ clearTimeout(timeoutId);
+ // Network error / timeout (abort) → retryable. Never call mapHttpToState
+ // without an HTTP status.
+ render('unavailable');
+ });
+ }
+
+ function confirm() {
+ show('loading');
+
+ // Mock hook for LOCAL preview only (?mock=confirmed|already-completed|
+ // invalid|unavailable|loading). Never honored on the real realunit.app /
+ // dev.realunit.app hosts, so a shared prod link cannot render a spoofed
+ // confirmation screen.
+ var mock = params.get('mock');
+ if (mock && !core.isRealUnitHost(host)) {
+ setTimeout(function () {
+ if (mock === 'loading') {
+ // Stay on the loading state after the short delay.
+ return;
+ }
+ render(mock);
+ }, 400);
+ return;
+ }
+
+ var otp = params.get('otp');
+ if (!core.hasOtp(otp)) {
+ show('invalid');
+ return;
+ }
+
+ var base = core.apiBase({ host: host, paramApi: params.get('api') });
+ fetchConfirm(base, otp, true);
+ }
+
+ document.getElementById('retry').addEventListener('click', confirm);
+ confirm();
+})();
diff --git a/public/js/lib/merge-core.js b/public/js/lib/merge-core.js
new file mode 100644
index 0000000..7028d4d
--- /dev/null
+++ b/public/js/lib/merge-core.js
@@ -0,0 +1,181 @@
+/**
+ * Pure, side-effect-free helpers + copy shared by
+ * account-merge/merge.js.
+ *
+ * Loaded as a classic script *before* merge.js so window.RealUnitMerge exists
+ * when merge.js runs. Kept free of DOM/network access so it can be unit-tested
+ * in isolation with 100% coverage (see test/merge-core.test.mjs); the DOM and
+ * fetch glue stays in merge.js and is covered by the Playwright functional
+ * suite.
+ */
+(function (global) {
+ 'use strict';
+
+ var SUPPORTED_LANGS = ['de', 'en'];
+
+ // The host names realunit.app is served under. On these the local-preview mock
+ // hook is refused and the API base is fixed, so a shared production link can
+ // neither render a spoofed confirmation nor be pointed at an arbitrary API.
+ var REALUNIT_HOSTS = ['realunit.app', 'www.realunit.app', 'dev.realunit.app'];
+
+ // Copy for every state, German (authored) + English. Both languages carry the
+ // exact same keys — test/merge-core.test.mjs enforces parity and that every
+ // data-i18n key used in the page is present here.
+ var I18N = {
+ de: {
+ 'doc.title': 'RealUnit — Adresse hinzufügen',
+ 'doc.desc': 'Bestätigung, dass eine Wallet-Adresse Ihrem Konto hinzugefügt wird.',
+ 'loading.title': 'Adresse wird hinzugefügt…',
+ 'loading.body': 'Einen Moment, wir fügen die Wallet-Adresse Ihrem Konto hinzu.',
+ 'confirmed.title': 'Adresse hinzugefügt',
+ 'confirmed.desktop':
+ 'Die Wallet-Adresse ist Ihrem Konto hinzugefügt. Kehren Sie auf Ihrem Smartphone zur RealUnit-App zurück.',
+ 'confirmed.mobile': 'Die Wallet-Adresse ist Ihrem Konto hinzugefügt.',
+ 'confirmed.cta': 'Zurück zur App',
+ 'already-completed.title': 'Adresse bereits hinzugefügt',
+ 'already-completed.desktop':
+ 'Diese Wallet-Adresse ist bereits auf Ihrem Konto. Kehren Sie auf Ihrem Smartphone zur RealUnit-App zurück.',
+ 'already-completed.mobile': 'Diese Wallet-Adresse ist bereits auf Ihrem Konto.',
+ 'already-completed.cta': 'Zurück zur App',
+ 'invalid.title': 'Link ungültig oder abgelaufen',
+ 'invalid.body':
+ 'Dieser Link ist ungültig oder bereits abgelaufen. Bitte fordern Sie in der App eine neue Bestätigung an.',
+ 'unavailable.title': 'Dienst vorübergehend nicht erreichbar',
+ 'unavailable.body':
+ 'Wir konnten die Adresse gerade nicht hinzufügen. Bitte versuchen Sie es in ein paar Minuten erneut.',
+ 'unavailable.cta': 'Erneut versuchen',
+ },
+ en: {
+ 'doc.title': 'RealUnit — Add address',
+ 'doc.desc': 'Confirm adding a wallet address to your account.',
+ 'loading.title': 'Adding address…',
+ 'loading.body': 'One moment — we’re adding the wallet address to your account.',
+ 'confirmed.title': 'Address added',
+ 'confirmed.desktop':
+ 'The wallet address has been added to your account. Return to the RealUnit app on your phone.',
+ 'confirmed.mobile': 'The wallet address has been added to your account.',
+ 'confirmed.cta': 'Back to the app',
+ 'already-completed.title': 'Address already added',
+ 'already-completed.desktop':
+ 'This wallet address is already on your account. Return to the RealUnit app on your phone.',
+ 'already-completed.mobile': 'This wallet address is already on your account.',
+ 'already-completed.cta': 'Back to the app',
+ 'invalid.title': 'Link invalid or expired',
+ 'invalid.body':
+ 'This link is invalid or has already expired. Please request a new confirmation in the app.',
+ 'unavailable.title': 'Service temporarily unavailable',
+ 'unavailable.body':
+ 'We couldn’t add the address right now. Please try again in a few minutes.',
+ 'unavailable.cta': 'Try again',
+ },
+ };
+
+ // Two-letter, lower-cased language tag; '' for a missing/non-string value.
+ function normalizeLang(value) {
+ if (typeof value !== 'string') {
+ return '';
+ }
+ return value.slice(0, 2).toLowerCase();
+ }
+
+ // Resolve the active language. A present ?lang= is authoritative: it is
+ // validated and, if unsupported, falls back to the default WITHOUT consulting
+ // the browser language — the browser is only a fallback when no ?lang= is given.
+ // This mirrors the original short-circuit `(urlLang || navigatorLang ||
+ // default)` + supported check. Both urlLang and navigatorLang may be
+ // null/undefined (no param / no navigator.language) — treated as absent, and an
+ // empty ?lang= (`?lang=`) falls through to the browser just like the original.
+ function resolveLang(options) {
+ var supported = options.supported;
+ var fromUrl = normalizeLang(options.urlLang);
+ if (fromUrl) {
+ return supported.indexOf(fromUrl) !== -1 ? fromUrl : options.defaultLang;
+ }
+ var fromNavigator = normalizeLang(options.navigatorLang);
+ if (supported.indexOf(fromNavigator) !== -1) {
+ return fromNavigator;
+ }
+ return options.defaultLang;
+ }
+
+ function isRealUnitHost(host) {
+ return REALUNIT_HOSTS.indexOf(host) !== -1;
+ }
+
+ // Resolve the DFX API base for a host. Production hosts are fixed; on a local
+ // preview / unknown host an explicit ?api= override wins, else DEV. There is no
+ // silent production default — an unknown host is deliberately pointed at DEV.
+ function apiBase(options) {
+ var host = options.host;
+ if (host === 'realunit.app' || host === 'www.realunit.app') {
+ return 'https://api.dfx.swiss';
+ }
+ if (host === 'dev.realunit.app') {
+ return 'https://dev.api.dfx.swiss';
+ }
+ if (options.paramApi) {
+ return options.paramApi;
+ }
+ return 'https://dev.api.dfx.swiss';
+ }
+
+ // True only when the mail-link OTP is present and non-empty.
+ function hasOtp(otp) {
+ return typeof otp === 'string' && otp.length > 0;
+ }
+
+ // Build the mail-confirm endpoint URL. The OTP is an opaque token and is
+ // percent-encoded for the query string.
+ function buildConfirmUrl(base, otp) {
+ return base + '/v1/auth/mail/confirm?code=' + encodeURIComponent(otp);
+ }
+
+ // Build the job-status endpoint URL. The uid is percent-encoded for the path.
+ function buildJobUrl(base, uid) {
+ return base + '/v1/job/' + encodeURIComponent(uid);
+ }
+
+ // True when the body looks like a DFX async job response (uid + status strings).
+ function isJobResponse(body) {
+ return Boolean(body) && typeof body.uid === 'string' && typeof body.status === 'string';
+ }
+
+ // Terminal job statuses that end the poll loop. Non-strings and in-flight
+ // statuses (Pending, Processing, Retry, unknown) are not terminal.
+ function isJobTerminal(status) {
+ return status === 'Complete' || status === 'Failed' || status === 'DeadLetter';
+ }
+
+ // Map an HTTP status + body to a UI state. Order is significant: 409 is
+ // already-completed, 400/404 are invalid, a 2xx job-shaped body wins over a
+ // 2xx kycHash body, and everything else is unavailable.
+ function mapHttpToState(statusCode, body) {
+ if (statusCode === 409) {
+ return 'already-completed';
+ }
+ if (statusCode === 400 || statusCode === 404) {
+ return 'invalid';
+ }
+ if (statusCode >= 200 && statusCode < 300 && isJobResponse(body)) {
+ return 'job';
+ }
+ if (statusCode >= 200 && statusCode < 300 && body && typeof body.kycHash === 'string') {
+ return 'confirmed';
+ }
+ return 'unavailable';
+ }
+
+ global.RealUnitMerge = {
+ SUPPORTED_LANGS: SUPPORTED_LANGS,
+ I18N: I18N,
+ resolveLang: resolveLang,
+ isRealUnitHost: isRealUnitHost,
+ apiBase: apiBase,
+ hasOtp: hasOtp,
+ buildConfirmUrl: buildConfirmUrl,
+ buildJobUrl: buildJobUrl,
+ isJobResponse: isJobResponse,
+ isJobTerminal: isJobTerminal,
+ mapHttpToState: mapHttpToState,
+ };
+})(window);
diff --git a/scripts/check-site.mjs b/scripts/check-site.mjs
index 3423efd..ad8692e 100644
--- a/scripts/check-site.mjs
+++ b/scripts/check-site.mjs
@@ -9,7 +9,8 @@
* public/ (the same resolution the dev server and Cloudflare Pages use)
* - index.html without an https og:url to anchor the site origin
* - a page that loads a glue script without first loading the js/lib core it
- * depends on (platform.js → platform-core.js, confirm.js → confirm-core.js)
+ * depends on (platform.js → platform-core.js, confirm.js → confirm-core.js,
+ * merge.js → merge-core.js)
*
* i18n key parity (de/en) and the data-i18n coverage of the confirm page live in
* the unit test (test/confirm-core.test.mjs), which can import the copy directly.
@@ -133,6 +134,7 @@ for (const file of htmlFiles) {
checkScriptOrder(label, html, '/platform.js', '/js/lib/platform-core.js');
checkScriptOrder(label, html, '/confirm-aktionariat/confirm.js', '/js/lib/confirm-core.js');
+ checkScriptOrder(label, html, '/account-merge/merge.js', '/js/lib/merge-core.js');
}
if (errors.length > 0) {
diff --git a/test/merge-core.test.mjs b/test/merge-core.test.mjs
new file mode 100644
index 0000000..dd3be58
--- /dev/null
+++ b/test/merge-core.test.mjs
@@ -0,0 +1,223 @@
+import { readFileSync } from 'node:fs';
+import { describe, expect, test } from 'vitest';
+
+// Importing the classic script runs it against the jsdom window and exposes the
+// helpers + copy on window.RealUnitMerge without any side effects.
+import '../public/js/lib/merge-core.js';
+
+const core = window.RealUnitMerge;
+const {
+ SUPPORTED_LANGS,
+ I18N,
+ resolveLang,
+ isRealUnitHost,
+ apiBase,
+ hasOtp,
+ buildConfirmUrl,
+ buildJobUrl,
+ isJobResponse,
+ isJobTerminal,
+ mapHttpToState,
+} = core;
+
+function resolve(overrides) {
+ return resolveLang({
+ urlLang: null,
+ navigatorLang: null,
+ supported: SUPPORTED_LANGS,
+ defaultLang: 'de',
+ ...overrides,
+ });
+}
+
+describe('resolveLang', () => {
+ test('prefers a supported ?lang= over the browser language', () => {
+ expect(resolve({ urlLang: 'en', navigatorLang: 'de-DE' })).toBe('en');
+ });
+
+ test('normalizes a region-tagged ?lang= (EN-us → en)', () => {
+ expect(resolve({ urlLang: 'EN-us' })).toBe('en');
+ });
+
+ test('a present but unsupported ?lang= falls back to the default (browser not consulted)', () => {
+ expect(resolve({ urlLang: 'pt', navigatorLang: 'en-US' })).toBe('de');
+ });
+
+ test('uses the browser language when there is no ?lang=', () => {
+ expect(resolve({ navigatorLang: 'en-GB' })).toBe('en');
+ });
+
+ test('falls back to the explicit default for an unsupported browser language', () => {
+ expect(resolve({ navigatorLang: 'fr-FR' })).toBe('de');
+ });
+
+ test('falls back to the default when both inputs are absent (null)', () => {
+ expect(resolve({ urlLang: null, navigatorLang: null })).toBe('de');
+ });
+
+ test('treats a non-string value as absent', () => {
+ expect(resolve({ urlLang: 123, navigatorLang: undefined })).toBe('de');
+ });
+});
+
+describe('isRealUnitHost', () => {
+ test('true for the production and dev hosts', () => {
+ expect(isRealUnitHost('realunit.app')).toBe(true);
+ expect(isRealUnitHost('www.realunit.app')).toBe(true);
+ expect(isRealUnitHost('dev.realunit.app')).toBe(true);
+ });
+
+ test('false for any other host', () => {
+ expect(isRealUnitHost('localhost')).toBe(false);
+ expect(isRealUnitHost('127.0.0.1')).toBe(false);
+ });
+});
+
+describe('apiBase', () => {
+ test('production hosts map to the production API', () => {
+ expect(apiBase({ host: 'realunit.app' })).toBe('https://api.dfx.swiss');
+ expect(apiBase({ host: 'www.realunit.app' })).toBe('https://api.dfx.swiss');
+ });
+
+ test('the dev host maps to the dev API', () => {
+ expect(apiBase({ host: 'dev.realunit.app' })).toBe('https://dev.api.dfx.swiss');
+ });
+
+ test('an unknown host uses an explicit ?api= override when present', () => {
+ expect(apiBase({ host: 'localhost', paramApi: 'https://api.example.test' })).toBe(
+ 'https://api.example.test',
+ );
+ });
+
+ test('an unknown host without an override falls back to the dev API', () => {
+ expect(apiBase({ host: 'localhost', paramApi: null })).toBe('https://dev.api.dfx.swiss');
+ });
+});
+
+describe('hasOtp', () => {
+ test('true for a non-empty string', () => {
+ expect(hasOtp('abc')).toBe(true);
+ });
+
+ test('false for empty, null, undefined, or non-string', () => {
+ expect(hasOtp('')).toBe(false);
+ expect(hasOtp(null)).toBe(false);
+ expect(hasOtp(undefined)).toBe(false);
+ expect(hasOtp(123)).toBe(false);
+ });
+});
+
+describe('buildConfirmUrl', () => {
+ test('appends the endpoint and encodes the otp', () => {
+ expect(buildConfirmUrl('https://dev.api.dfx.swiss', 'x y')).toBe(
+ 'https://dev.api.dfx.swiss/v1/auth/mail/confirm?code=x%20y',
+ );
+ });
+
+ test('encodes slashes and unicode in the otp', () => {
+ expect(buildConfirmUrl('https://x', 'a/b')).toContain('code=a%2Fb');
+ expect(buildConfirmUrl('https://x', 'ü')).toContain('code=%C3%BC');
+ });
+});
+
+describe('buildJobUrl', () => {
+ test('appends the job path and encodes the uid', () => {
+ expect(buildJobUrl('https://dev.api.dfx.swiss', 'job-1')).toBe(
+ 'https://dev.api.dfx.swiss/v1/job/job-1',
+ );
+ expect(buildJobUrl('https://x', 'a/b')).toBe('https://x/v1/job/a%2Fb');
+ });
+});
+
+describe('isJobResponse', () => {
+ test('true only when both uid and status are strings', () => {
+ expect(isJobResponse({ uid: 'j1', status: 'Pending' })).toBe(true);
+ });
+
+ test('false for null, undefined, empty object, numeric uid, or missing status', () => {
+ expect(isJobResponse(null)).toBe(false);
+ expect(isJobResponse(undefined)).toBe(false);
+ expect(isJobResponse({})).toBe(false);
+ expect(isJobResponse({ uid: 1, status: 'Pending' })).toBe(false);
+ expect(isJobResponse({ uid: 'j1' })).toBe(false);
+ expect(isJobResponse({ status: 'Pending' })).toBe(false);
+ });
+});
+
+describe('isJobTerminal', () => {
+ test('true for Complete, Failed, and DeadLetter', () => {
+ expect(isJobTerminal('Complete')).toBe(true);
+ expect(isJobTerminal('Failed')).toBe(true);
+ expect(isJobTerminal('DeadLetter')).toBe(true);
+ });
+
+ test('false for in-flight, unknown, or non-string statuses', () => {
+ expect(isJobTerminal('Retry')).toBe(false);
+ expect(isJobTerminal('Pending')).toBe(false);
+ expect(isJobTerminal('Processing')).toBe(false);
+ expect(isJobTerminal('unknown')).toBe(false);
+ expect(isJobTerminal(null)).toBe(false);
+ expect(isJobTerminal(undefined)).toBe(false);
+ expect(isJobTerminal(1)).toBe(false);
+ });
+});
+
+describe('mapHttpToState', () => {
+ test('409 maps to already-completed for any body', () => {
+ expect(mapHttpToState(409, null)).toBe('already-completed');
+ expect(mapHttpToState(409, { kycHash: 'x' })).toBe('already-completed');
+ });
+
+ test('400 and 404 map to invalid', () => {
+ expect(mapHttpToState(400, {})).toBe('invalid');
+ expect(mapHttpToState(404, {})).toBe('invalid');
+ });
+
+ test('2xx with a job-shaped body maps to job (200 and 202)', () => {
+ expect(mapHttpToState(200, { uid: 'j1', status: 'Pending' })).toBe('job');
+ expect(mapHttpToState(202, { uid: 'j1', status: 'Pending' })).toBe('job');
+ });
+
+ test('2xx with a kycHash string maps to confirmed', () => {
+ expect(mapHttpToState(200, { kycHash: 'x' })).toBe('confirmed');
+ });
+
+ test('2xx with both job shape and kycHash prefers job', () => {
+ expect(mapHttpToState(200, { uid: 'j1', status: 'Pending', kycHash: 'x' })).toBe('job');
+ });
+
+ test('2xx with empty or null body maps to unavailable', () => {
+ expect(mapHttpToState(200, {})).toBe('unavailable');
+ expect(mapHttpToState(200, null)).toBe('unavailable');
+ });
+
+ test('2xx with a non-string kycHash maps to unavailable', () => {
+ expect(mapHttpToState(200, { kycHash: 1 })).toBe('unavailable');
+ });
+
+ test('5xx, 429, and other codes map to unavailable', () => {
+ expect(mapHttpToState(500, {})).toBe('unavailable');
+ expect(mapHttpToState(503, {})).toBe('unavailable');
+ expect(mapHttpToState(429, {})).toBe('unavailable');
+ expect(mapHttpToState(418, {})).toBe('unavailable');
+ });
+});
+
+describe('i18n copy', () => {
+ test('de and en carry the exact same keys', () => {
+ expect(Object.keys(I18N.en).sort()).toEqual(Object.keys(I18N.de).sort());
+ });
+
+ test('every data-i18n* key used in the merge page exists in both languages', () => {
+ const html = readFileSync('public/account-merge/index.html', 'utf8');
+ const keys = new Set();
+ for (const match of html.matchAll(/data-i18n(?:-alt|-aria)?=["']([^"']+)["']/g)) {
+ keys.add(match[1]);
+ }
+ expect(keys.size).toBeGreaterThan(0);
+ for (const key of keys) {
+ expect(I18N.de).toHaveProperty([key]);
+ expect(I18N.en).toHaveProperty([key]);
+ }
+ });
+});
diff --git a/tests/__screenshots__/desktop-chromium/merge-already-completed-en.png b/tests/__screenshots__/desktop-chromium/merge-already-completed-en.png
new file mode 100644
index 0000000..149c19e
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-already-completed-en.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-already-completed.png b/tests/__screenshots__/desktop-chromium/merge-already-completed.png
new file mode 100644
index 0000000..5f75254
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-already-completed.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-confirmed-en.png b/tests/__screenshots__/desktop-chromium/merge-confirmed-en.png
new file mode 100644
index 0000000..7c22718
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-confirmed-en.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-confirmed.png b/tests/__screenshots__/desktop-chromium/merge-confirmed.png
new file mode 100644
index 0000000..f21dc93
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-confirmed.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-invalid-en.png b/tests/__screenshots__/desktop-chromium/merge-invalid-en.png
new file mode 100644
index 0000000..b2ba190
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-invalid-en.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-invalid.png b/tests/__screenshots__/desktop-chromium/merge-invalid.png
new file mode 100644
index 0000000..c472f1c
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-invalid.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-unavailable-en.png b/tests/__screenshots__/desktop-chromium/merge-unavailable-en.png
new file mode 100644
index 0000000..f286c19
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-unavailable-en.png differ
diff --git a/tests/__screenshots__/desktop-chromium/merge-unavailable.png b/tests/__screenshots__/desktop-chromium/merge-unavailable.png
new file mode 100644
index 0000000..f221ed5
Binary files /dev/null and b/tests/__screenshots__/desktop-chromium/merge-unavailable.png differ
diff --git a/tests/__screenshots__/mobile-safari/merge-already-completed.png b/tests/__screenshots__/mobile-safari/merge-already-completed.png
new file mode 100644
index 0000000..9d0f3e2
Binary files /dev/null and b/tests/__screenshots__/mobile-safari/merge-already-completed.png differ
diff --git a/tests/__screenshots__/mobile-safari/merge-confirmed-mobile.png b/tests/__screenshots__/mobile-safari/merge-confirmed-mobile.png
new file mode 100644
index 0000000..3a09ddd
Binary files /dev/null and b/tests/__screenshots__/mobile-safari/merge-confirmed-mobile.png differ
diff --git a/tests/__screenshots__/mobile-safari/merge-invalid.png b/tests/__screenshots__/mobile-safari/merge-invalid.png
new file mode 100644
index 0000000..ba52e59
Binary files /dev/null and b/tests/__screenshots__/mobile-safari/merge-invalid.png differ
diff --git a/tests/__screenshots__/mobile-safari/merge-unavailable.png b/tests/__screenshots__/mobile-safari/merge-unavailable.png
new file mode 100644
index 0000000..e082915
Binary files /dev/null and b/tests/__screenshots__/mobile-safari/merge-unavailable.png differ
diff --git a/tests/__screenshots__/tablet-chromium/merge-confirmed.png b/tests/__screenshots__/tablet-chromium/merge-confirmed.png
new file mode 100644
index 0000000..72f71c1
Binary files /dev/null and b/tests/__screenshots__/tablet-chromium/merge-confirmed.png differ
diff --git a/tests/__screenshots__/tablet-chromium/merge-invalid.png b/tests/__screenshots__/tablet-chromium/merge-invalid.png
new file mode 100644
index 0000000..1cff110
Binary files /dev/null and b/tests/__screenshots__/tablet-chromium/merge-invalid.png differ
diff --git a/tests/behavior.spec.mjs b/tests/behavior.spec.mjs
index 49a891d..6248c0f 100644
--- a/tests/behavior.spec.mjs
+++ b/tests/behavior.spec.mjs
@@ -248,3 +248,328 @@ test.describe('confirm-aktionariat flow', () => {
expect(requestedUrl).toContain('https://api.example.test/v1/realunit/confirm-aktionariat');
});
});
+
+const MERGE_CONFIRM_ENDPOINT = '**/v1/auth/mail/confirm**';
+const MERGE_JOB_ENDPOINT = '**/v1/job/**';
+
+test.describe('account-merge flow', () => {
+ // The merge logic is device-agnostic; run it once on desktop.
+ test.beforeEach(async ({ page }, testInfo) => {
+ test.skip(testInfo.project.name !== 'desktop-chromium', 'desktop-only merge-flow checks');
+ });
+
+ test('a link without otp shows the invalid state and makes no confirm request', async ({
+ page,
+ }) => {
+ const confirmCalls = [];
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ confirmCalls.push(route.request().url());
+ route.fulfill({ status: 200, contentType: 'application/json', body: '{}' });
+ });
+ await page.goto('/account-merge/');
+ await expect(page.locator('#state-invalid')).toBeVisible();
+ await expect(page.locator('#state-loading')).toBeHidden();
+ expect(confirmCalls).toEqual([]);
+ });
+
+ for (const state of ['confirmed', 'already-completed', 'invalid', 'unavailable']) {
+ test(`?mock=${state} renders the ${state} state`, async ({ page }) => {
+ await page.goto(`/account-merge/?mock=${state}`);
+ await expect(page.locator(`#state-${state}`)).toBeVisible();
+ });
+ }
+
+ test('a valid otp confirmed by the API shows the confirmed state and calls the DEV base', async ({
+ page,
+ }) => {
+ let requestedUrl = null;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ requestedUrl = route.request().url();
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ kycHash: 'x' }),
+ });
+ });
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-confirmed')).toBeVisible();
+ expect(requestedUrl).toContain('https://dev.api.dfx.swiss/v1/auth/mail/confirm');
+ expect(requestedUrl).toContain('code=abc');
+ });
+
+ test('a 409 response shows the already-completed state', async ({ page }) => {
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) =>
+ route.fulfill({ status: 409, contentType: 'application/json', body: '{}' }),
+ );
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-already-completed')).toBeVisible();
+ });
+
+ test('a 400 response shows the invalid state', async ({ page }) => {
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) =>
+ route.fulfill({ status: 400, contentType: 'application/json', body: '{}' }),
+ );
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-invalid')).toBeVisible();
+ });
+
+ test('a 202 job that completes then re-confirms shows the confirmed state', async ({ page }) => {
+ let confirmCalls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ confirmCalls += 1;
+ if (confirmCalls === 1) {
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-1',
+ status: 'Pending',
+ expectedSeconds: 2,
+ }),
+ });
+ } else {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ kycHash: 'x' }),
+ });
+ }
+ });
+ await page.route(MERGE_JOB_ENDPOINT, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ uid: 'job-1', status: 'Complete' }),
+ }),
+ );
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-confirmed')).toBeVisible({ timeout: 10000 });
+ expect(confirmCalls).toBe(2);
+ });
+
+ test('a 202 ticket already Complete skips job polling and re-confirms', async ({ page }) => {
+ let confirmCalls = 0;
+ let jobCalls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ confirmCalls += 1;
+ if (confirmCalls === 1) {
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-1',
+ status: 'Complete',
+ expectedSeconds: 2,
+ }),
+ });
+ } else {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ kycHash: 'x' }),
+ });
+ }
+ });
+ await page.route(MERGE_JOB_ENDPOINT, (route) => {
+ jobCalls += 1;
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ uid: 'job-1', status: 'Complete' }),
+ });
+ });
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-confirmed')).toBeVisible({ timeout: 10000 });
+ expect(confirmCalls).toBe(2);
+ expect(jobCalls).toBe(0);
+ });
+
+ test('a 202 ticket already Failed skips job polling and shows unavailable', async ({ page }) => {
+ let confirmCalls = 0;
+ let jobCalls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ confirmCalls += 1;
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-1',
+ status: 'Failed',
+ expectedSeconds: 2,
+ }),
+ });
+ });
+ await page.route(MERGE_JOB_ENDPOINT, (route) => {
+ jobCalls += 1;
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ uid: 'job-1', status: 'Failed' }),
+ });
+ });
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-unavailable')).toBeVisible({ timeout: 10000 });
+ expect(confirmCalls).toBe(1);
+ expect(jobCalls).toBe(0);
+ });
+
+ test('a 202 job with expectedSeconds 1 that is Complete on the first job GET still reaches confirmed', async ({
+ page,
+ }) => {
+ let confirmCalls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ confirmCalls += 1;
+ if (confirmCalls === 1) {
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-1',
+ status: 'Pending',
+ expectedSeconds: 1,
+ }),
+ });
+ } else {
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ kycHash: 'x' }),
+ });
+ }
+ });
+ await page.route(MERGE_JOB_ENDPOINT, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ uid: 'job-1', status: 'Complete' }),
+ }),
+ );
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-confirmed')).toBeVisible({ timeout: 10000 });
+ expect(confirmCalls).toBe(2);
+ });
+
+ test('after Complete, a second confirm that returns another job-shaped 202 shows unavailable', async ({
+ page,
+ }) => {
+ let confirmCalls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ confirmCalls += 1;
+ if (confirmCalls === 1) {
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-1',
+ status: 'Pending',
+ expectedSeconds: 2,
+ }),
+ });
+ } else {
+ // Re-confirm after Complete still returns a job — that is an error, not a new budget.
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-2',
+ status: 'Pending',
+ expectedSeconds: 60,
+ }),
+ });
+ }
+ });
+ await page.route(MERGE_JOB_ENDPOINT, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ uid: 'job-1', status: 'Complete' }),
+ }),
+ );
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-unavailable')).toBeVisible({ timeout: 10000 });
+ expect(confirmCalls).toBe(2);
+ });
+
+ test('a job GET that returns 404 JSON without status shows unavailable without re-polling', async ({
+ page,
+ }) => {
+ let jobCalls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) =>
+ route.fulfill({
+ status: 202,
+ contentType: 'application/json',
+ body: JSON.stringify({
+ uid: 'job-1',
+ status: 'Pending',
+ expectedSeconds: 60,
+ }),
+ }),
+ );
+ await page.route(MERGE_JOB_ENDPOINT, (route) => {
+ jobCalls += 1;
+ route.fulfill({
+ status: 404,
+ contentType: 'application/json',
+ body: JSON.stringify({ message: 'not found' }),
+ });
+ });
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-unavailable')).toBeVisible({ timeout: 10000 });
+ expect(jobCalls).toBe(1);
+ });
+
+ test('a network error shows the unavailable state', async ({ page }) => {
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => route.abort());
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-unavailable')).toBeVisible();
+ });
+
+ test('a 503 response shows the unavailable state', async ({ page }) => {
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) =>
+ route.fulfill({ status: 503, contentType: 'application/json', body: '{}' }),
+ );
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-unavailable')).toBeVisible();
+ });
+
+ test('the retry button re-runs the confirmation', async ({ page }) => {
+ let calls = 0;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ calls += 1;
+ const ok = calls > 1; // first attempt fails, the retry succeeds
+ route.fulfill({
+ status: ok ? 200 : 503,
+ contentType: 'application/json',
+ body: JSON.stringify(ok ? { kycHash: 'x' } : {}),
+ });
+ });
+ await page.goto('/account-merge/?otp=abc');
+ await expect(page.locator('#state-unavailable')).toBeVisible();
+ await page.locator('#retry').click();
+ await expect(page.locator('#state-confirmed')).toBeVisible();
+ expect(calls).toBe(2);
+ });
+
+ test('?lang=en renders English copy and sets ', async ({ page }) => {
+ await page.goto('/account-merge/?mock=invalid&lang=en');
+ await expect(page.locator('html')).toHaveAttribute('lang', 'en');
+ const expected = await page.evaluate(() => window.RealUnitMerge.I18N.en['invalid.title']);
+ await expect(page.locator('#state-invalid h1')).toHaveText(expected);
+ });
+
+ test('an ?api= override sends the confirmation to that API base', async ({ page }) => {
+ let requestedUrl = null;
+ await page.route(MERGE_CONFIRM_ENDPOINT, (route) => {
+ requestedUrl = route.request().url();
+ route.fulfill({
+ status: 200,
+ contentType: 'application/json',
+ body: JSON.stringify({ kycHash: 'x' }),
+ });
+ });
+ await page.goto('/account-merge/?otp=abc&api=https%3A%2F%2Fapi.example.test');
+ await expect(page.locator('#state-confirmed')).toBeVisible();
+ expect(requestedUrl).toContain('https://api.example.test/v1/auth/mail/confirm');
+ expect(requestedUrl).toContain('code=abc');
+ });
+});
diff --git a/tests/pages.mjs b/tests/pages.mjs
index 976a6a2..c40dd9e 100644
--- a/tests/pages.mjs
+++ b/tests/pages.mjs
@@ -5,10 +5,10 @@
export const PORT = 4173;
-// Every public HTML page, used by the smoke spec. `/confirm-aktionariat/` loads
-// with no query params, so it renders the "invalid link" state without making a
-// network request.
-export const PAGES = ['/', '/confirm-aktionariat/', '/404.html'];
+// Every public HTML page, used by the smoke spec. `/confirm-aktionariat/` and
+// `/account-merge/` load with no query params, so they render the "invalid link"
+// state without making a network request.
+export const PAGES = ['/', '/confirm-aktionariat/', '/account-merge/', '/404.html'];
// Viewports the visual suite renders: desktop, a real tablet width, and a phone.
export const PROJECTS = ['desktop-chromium', 'tablet-chromium', 'mobile-safari'];
@@ -19,15 +19,16 @@ export const PROJECTS = ['desktop-chromium', 'tablet-chromium', 'mobile-safari']
// platform — optional forced platform ('ios' | 'android'); applied via a UA
// override before the page scripts run, so platform.js sets
// html[data-platform] deterministically regardless of the device
-// waitFor — optional confirm-page state ('confirmed' | 'invalid' |
-// 'no-registration' | 'unavailable') to wait for before the shot (the
-// ?mock hook renders it after a short delay)
+// waitFor — optional confirm/merge-page state ('confirmed' | 'already-completed' |
+// 'invalid' | 'no-registration' | 'unavailable') to wait for before the
+// shot (the ?mock hook renders it after a short delay)
// projects — the viewports this view applies to
//
// Coverage: the landing page in both its equal-badge (desktop/tablet) and
// platform-matched (iOS/Android phone) layouts, every confirm-page end state in
-// both languages and both the desktop and phone confirmed variants, and the 404
-// page — each on the viewports where it differs.
+// both languages and both the desktop and phone confirmed variants, every
+// account-merge end state (same pattern), and the 404 page — each on the
+// viewports where it differs.
export const VIEWS = [
// Landing — equal-badge layout (desktop/tablet get no data-platform).
{ slug: 'home', path: '/', projects: ['desktop-chromium', 'tablet-chromium'] },
@@ -101,6 +102,68 @@ export const VIEWS = [
projects: ['desktop-chromium'],
},
+ // Account-merge — confirmed state, German, desktop copy ("return on your phone").
+ {
+ slug: 'merge-confirmed',
+ path: '/account-merge/?mock=confirmed&lang=de',
+ waitFor: 'confirmed',
+ projects: ['desktop-chromium', 'tablet-chromium'],
+ },
+ // Account-merge — confirmed state on a phone: the "back to the app" button appears.
+ {
+ slug: 'merge-confirmed-mobile',
+ path: '/account-merge/?mock=confirmed&lang=de',
+ platform: 'ios',
+ waitFor: 'confirmed',
+ projects: ['mobile-safari'],
+ },
+ // Account-merge — confirmed state, English copy.
+ {
+ slug: 'merge-confirmed-en',
+ path: '/account-merge/?mock=confirmed&lang=en',
+ waitFor: 'confirmed',
+ projects: ['desktop-chromium'],
+ },
+ // Account-merge — already-completed state (HTTP 409 / already merged).
+ {
+ slug: 'merge-already-completed',
+ path: '/account-merge/?mock=already-completed&lang=de',
+ waitFor: 'already-completed',
+ projects: ['desktop-chromium', 'mobile-safari'],
+ },
+ {
+ slug: 'merge-already-completed-en',
+ path: '/account-merge/?mock=already-completed&lang=en',
+ waitFor: 'already-completed',
+ projects: ['desktop-chromium'],
+ },
+ // Account-merge — invalid state (bad/expired link).
+ {
+ slug: 'merge-invalid',
+ path: '/account-merge/?mock=invalid&lang=de',
+ waitFor: 'invalid',
+ projects: ['desktop-chromium', 'tablet-chromium', 'mobile-safari'],
+ },
+ {
+ slug: 'merge-invalid-en',
+ path: '/account-merge/?mock=invalid&lang=en',
+ waitFor: 'invalid',
+ projects: ['desktop-chromium'],
+ },
+ // Account-merge — service unavailable (the retry button is shown).
+ {
+ slug: 'merge-unavailable',
+ path: '/account-merge/?mock=unavailable&lang=de',
+ waitFor: 'unavailable',
+ projects: ['desktop-chromium', 'mobile-safari'],
+ },
+ {
+ slug: 'merge-unavailable-en',
+ path: '/account-merge/?mock=unavailable&lang=en',
+ waitFor: 'unavailable',
+ projects: ['desktop-chromium'],
+ },
+
// Custom 404 page.
{
slug: 'notfound',