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.

+
+ + + + + + + + + + + + +
+
+ + + + + + 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',