,
+}));
+
+jest.mock('src/contexts/settings.context', () => ({
+ useSettingsContext: () => ({ translate: (_key: string, text: string) => text }),
+}));
+
+jest.mock('src/hooks/navigation.hook', () => ({
+ useNavigation: () => ({ navigate: mockNavigate }),
+}));
+
+jest.mock('src/hooks/layout-config.hook', () => ({
+ useLayoutOptions: jest.fn(),
+}));
+
+let mockUrlParams: URLSearchParams;
+jest.mock('react-router-dom', () => ({
+ useSearchParams: () => [mockUrlParams, jest.fn()],
+}));
+
+import AccountMerge from '../screens/account-merge.screen';
+import { JobStatus } from '../util/job';
+
+const MERGE_URL = 'auth/mail/confirm';
+const JOB = { uid: 'job-uid', expectedSeconds: 65 };
+// The screen polls once a second, so anything waiting on a poll has to outlast one full interval —
+// the default 1000 ms of findBy/waitFor expires exactly as the first poll fires.
+const POLL_TIMEOUT = 3000;
+
+/** Answers the merge endpoint and the job endpoint from two independent queues, by URL. */
+function respondWith({ merge = [] as any[], jobs = [] as any[] } = {}) {
+ const mergeQueue = [...merge];
+ const jobQueue = [...jobs];
+
+ mockCall.mockImplementation(({ url }: { url: string }) => {
+ if (url.startsWith(MERGE_URL)) {
+ const next = mergeQueue.shift();
+ return next instanceof Error ? Promise.reject(next) : Promise.resolve(next);
+ }
+ if (url.startsWith('job/')) {
+ const next = jobQueue.shift();
+ return next ? Promise.resolve(next) : new Promise(() => undefined);
+ }
+ throw new Error(`unexpected url: ${url}`);
+ });
+}
+
+function mergeCalls(): number {
+ return mockCall.mock.calls.filter(([{ url }]) => url.startsWith(MERGE_URL)).length;
+}
+
+beforeEach(() => {
+ jest.clearAllMocks();
+ mockUrlParams = new URLSearchParams('otp=the-otp');
+});
+
+describe('AccountMerge', () => {
+ it('shows the result directly when the merge finishes inside the wait window', async () => {
+ respondWith({ merge: [{ kycHash: 'hash', accessToken: 'token' }] });
+
+ render();
+
+ expect(await screen.findByText('Account merged successfully!')).toBeInTheDocument();
+ expect(mockSetAuthToken).toHaveBeenCalledWith('token');
+ expect(mergeCalls()).toBe(1);
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ // The regression from the issue: a 202 used to leave the screen on the spinner forever.
+ it('polls the job and asks the merge endpoint again once it completes', async () => {
+ respondWith({
+ merge: [
+ { ...JOB, status: JobStatus.PENDING },
+ { kycHash: 'hash', accessToken: 'token' },
+ ],
+ jobs: [{ ...JOB, status: JobStatus.COMPLETE }],
+ });
+
+ render();
+
+ expect(await screen.findByText('Account merged successfully!', {}, { timeout: POLL_TIMEOUT })).toBeInTheDocument();
+ expect(mockCall).toHaveBeenCalledWith({ url: 'job/job-uid', method: 'GET', token: false });
+ // The access token is only issued in the HTTP context, so the result has to be fetched again.
+ expect(mergeCalls()).toBe(2);
+ expect(mockSetAuthToken).toHaveBeenCalledWith('token');
+ });
+
+ // The job belongs to the merge's master while the caller is still signed in as the slave, so
+ // sending the session token makes the API reject its own caller with a 404. The merge call itself
+ // must keep its token — that is what the fresh access token is issued from.
+ it('polls the job unauthenticated but keeps the session token on the merge call', async () => {
+ respondWith({
+ merge: [
+ { ...JOB, status: JobStatus.PENDING },
+ { kycHash: 'hash', accessToken: 'token' },
+ ],
+ jobs: [{ ...JOB, status: JobStatus.COMPLETE }],
+ });
+
+ render();
+ await screen.findByText('Account merged successfully!', {}, { timeout: POLL_TIMEOUT });
+
+ const [jobCall] = mockCall.mock.calls.map(([c]) => c).filter((c) => c.url.startsWith('job/'));
+ const mergeCallConfigs = mockCall.mock.calls.map(([c]) => c).filter((c) => c.url.startsWith(MERGE_URL));
+
+ expect(jobCall.token).toBe(false);
+ expect(mergeCallConfigs).toHaveLength(2);
+ mergeCallConfigs.forEach((c) => expect(c.token).toBeUndefined());
+ });
+
+ it('skips polling when the 202 ticket is already complete', async () => {
+ respondWith({
+ merge: [{ ...JOB, status: JobStatus.COMPLETE }, { kycHash: 'hash' }],
+ });
+
+ render();
+
+ expect(await screen.findByText('Account merged successfully!')).toBeInTheDocument();
+ expect(mockCall).not.toHaveBeenCalledWith(expect.objectContaining({ url: 'job/job-uid' }));
+ expect(mergeCalls()).toBe(2);
+ });
+
+ it('keeps showing the waiting state while the job is still running', async () => {
+ respondWith({ merge: [{ ...JOB, status: JobStatus.PENDING }] });
+
+ render();
+
+ await waitFor(() => expect(mockCall).toHaveBeenCalledWith({ url: 'job/job-uid', method: 'GET', token: false }), {
+ timeout: POLL_TIMEOUT,
+ });
+ expect(screen.getByText('Merging your accounts...')).toBeInTheDocument();
+ expect(screen.queryByText('Account merged successfully!')).not.toBeInTheDocument();
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ // Budget spent while the job is still running: it may yet succeed, so the user is told to come
+ // back rather than that the merge failed. expectedSeconds 0 exhausts the budget immediately.
+ it('tells the user to come back later when the job outlasts its budget', async () => {
+ respondWith({ merge: [{ ...JOB, expectedSeconds: 0, status: JobStatus.PENDING }] });
+
+ render();
+
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith({
+ pathname: '/error',
+ search: 'msg=Merging your accounts is taking longer than expected. Please try again later.',
+ }),
+ );
+ });
+
+ it('sends the user to their account with the kycHash', async () => {
+ respondWith({ merge: [{ kycHash: 'hash', accessToken: 'token' }] });
+
+ render();
+ fireEvent.click(await screen.findByRole('button', { name: 'My account' }));
+
+ expect(mockNavigate).toHaveBeenCalledWith('/account?code=hash');
+ });
+
+ it('reports a failed job with the message the API supplied', async () => {
+ respondWith({
+ merge: [{ ...JOB, status: JobStatus.FAILED, error: 'Job job-uid failed, contact support if this persists.' }],
+ });
+
+ render();
+
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith({
+ pathname: '/error',
+ search: 'msg=Job job-uid failed, contact support if this persists.',
+ }),
+ );
+ expect(mergeCalls()).toBe(1);
+ });
+
+ it('falls back to a translated message when a dead-lettered job carries none', async () => {
+ respondWith({ merge: [{ ...JOB, status: JobStatus.DEAD_LETTER }] });
+
+ render();
+
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/error', search: 'msg=Account merge failed' }),
+ );
+ });
+
+ it('still maps the synchronous error codes', async () => {
+ respondWith({ merge: [Object.assign(new Error('nope'), { statusCode: 400 })] });
+
+ render();
+
+ await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/error', search: 'msg=Invalid link' }));
+ });
+
+ it('maps a 409 to the already-completed message', async () => {
+ respondWith({ merge: [Object.assign(new Error('nope'), { statusCode: 409 })] });
+
+ render();
+
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/error', search: 'msg=Merge is already completed' }),
+ );
+ });
+
+ it('passes through an error it has no mapping for', async () => {
+ respondWith({ merge: [Object.assign(new Error('Network error: down'), { statusCode: 500 })] });
+
+ render();
+
+ await waitFor(() =>
+ expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/error', search: 'msg=Network error: down' }),
+ );
+ });
+
+ // The result call answering with another ticket would mean the merge un-completed itself; report
+ // it rather than handing the render an object with no kycHash.
+ it('reports a failure when the result call returns another ticket', async () => {
+ respondWith({
+ merge: [
+ { ...JOB, status: JobStatus.PENDING },
+ { ...JOB, status: JobStatus.FAILED, error: 'Job job-uid failed' },
+ ],
+ jobs: [{ ...JOB, status: JobStatus.COMPLETE }],
+ });
+
+ render();
+
+ await waitFor(
+ () => expect(mockNavigate).toHaveBeenCalledWith({ pathname: '/error', search: 'msg=Job job-uid failed' }),
+ { timeout: POLL_TIMEOUT },
+ );
+ });
+
+ it('stays silent when the screen unmounts before the merge fails', async () => {
+ let rejectMerge!: (error: unknown) => void;
+ mockCall.mockImplementation(() => new Promise((_, reject) => (rejectMerge = reject)));
+
+ const { unmount } = render();
+ unmount();
+
+ await act(async () => {
+ rejectMerge(Object.assign(new Error('nope'), { statusCode: 400 }));
+ });
+
+ expect(mockNavigate).not.toHaveBeenCalled();
+ });
+
+ // setAuthToken writes global auth context, not component state, so a merge that lands after the
+ // user has navigated away has to be dropped rather than silently signing them in.
+ it('discards the result when the screen unmounts before the merge lands', async () => {
+ let resolveMerge!: (result: unknown) => void;
+ mockCall.mockImplementation(() => new Promise((resolve) => (resolveMerge = resolve)));
+
+ const { unmount } = render();
+ unmount();
+
+ await act(async () => {
+ resolveMerge({ kycHash: 'hash', accessToken: 'token' });
+ });
+
+ expect(mockSetAuthToken).not.toHaveBeenCalled();
+ });
+
+ it('redirects to KYC without an otp', () => {
+ mockUrlParams = new URLSearchParams();
+
+ render();
+
+ expect(mockNavigate).toHaveBeenCalledWith('/kyc');
+ expect(mockCall).not.toHaveBeenCalled();
+ });
+
+ // StrictMode runs the effect, its cleanup, then the effect again on the same instance — so the
+ // cancellation flag has to be re-armed, or the second run would discard its own result and strand
+ // the user on the spinner, which is the very failure this screen is meant to stop doing.
+ it('still completes when the effect is invoked twice on the same instance', async () => {
+ respondWith({ merge: [{ kycHash: 'hash', accessToken: 'token' }, { kycHash: 'hash', accessToken: 'token' }] });
+
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText('Account merged successfully!')).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/job.test.ts b/src/__tests__/job.test.ts
new file mode 100644
index 000000000..07bde11f6
--- /dev/null
+++ b/src/__tests__/job.test.ts
@@ -0,0 +1,163 @@
+// Mock @dfx.swiss/react to avoid ES module issues (src/util/job imports delay from src/util/utils)
+jest.mock('@dfx.swiss/react', () => ({}));
+jest.mock('src/dto/safe.dto', () => ({}));
+
+import { JobResponse, JobStatus, isJobResponse, isJobTerminal, pollJobUntilTerminal } from '../util/job';
+
+function job(status: JobStatus, overrides: Partial = {}): JobResponse {
+ return { uid: 'job-uid', status, expectedSeconds: 65, ...overrides };
+}
+
+afterEach(() => {
+ jest.restoreAllMocks();
+});
+
+describe('isJobResponse', () => {
+ it('detects a 202 job ticket', () => {
+ expect(isJobResponse(job(JobStatus.PENDING))).toBe(true);
+ });
+
+ it('rejects the 200 merge result', () => {
+ expect(isJobResponse({ kycHash: 'hash', accessToken: 'token' })).toBe(false);
+ });
+
+ it('rejects a merge result that carries no access token', () => {
+ expect(isJobResponse({ kycHash: 'hash' })).toBe(false);
+ });
+
+ it('rejects a body that is not an object', () => {
+ expect(isJobResponse(null)).toBe(false);
+ expect(isJobResponse(undefined)).toBe(false);
+ expect(isJobResponse('Complete')).toBe(false);
+ expect(isJobResponse({})).toBe(false);
+ });
+});
+
+describe('isJobTerminal', () => {
+ it.each([[JobStatus.COMPLETE], [JobStatus.FAILED], [JobStatus.DEAD_LETTER]])('treats %s as terminal', (status) => {
+ expect(isJobTerminal(status)).toBe(true);
+ });
+
+ // Retry means the last attempt failed but the job will run again — polling must not stop there.
+ it.each([[JobStatus.PENDING], [JobStatus.PROCESSING], [JobStatus.RETRY]])('treats %s as still running', (status) => {
+ expect(isJobTerminal(status)).toBe(false);
+ });
+});
+
+describe('pollJobUntilTerminal', () => {
+ it('returns an already terminal ticket without asking the API', async () => {
+ const fetchJob = jest.fn();
+
+ const result = await pollJobUntilTerminal(job(JobStatus.COMPLETE), fetchJob, { intervalSeconds: 0 });
+
+ expect(result.status).toBe(JobStatus.COMPLETE);
+ expect(fetchJob).not.toHaveBeenCalled();
+ });
+
+ it('polls until the job completes', async () => {
+ const fetchJob = jest
+ .fn()
+ .mockResolvedValueOnce(job(JobStatus.PROCESSING))
+ .mockResolvedValueOnce(job(JobStatus.COMPLETE));
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING), fetchJob, { intervalSeconds: 0 });
+
+ expect(result.status).toBe(JobStatus.COMPLETE);
+ expect(fetchJob).toHaveBeenCalledTimes(2);
+ expect(fetchJob).toHaveBeenCalledWith('job-uid');
+ });
+
+ it('keeps polling while the job is retrying', async () => {
+ const fetchJob = jest
+ .fn()
+ .mockResolvedValueOnce(job(JobStatus.RETRY))
+ .mockResolvedValueOnce(job(JobStatus.COMPLETE));
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PROCESSING), fetchJob, { intervalSeconds: 0 });
+
+ expect(result.status).toBe(JobStatus.COMPLETE);
+ expect(fetchJob).toHaveBeenCalledTimes(2);
+ });
+
+ it('stops on a failed job and keeps its error', async () => {
+ const fetchJob = jest.fn().mockResolvedValue(job(JobStatus.FAILED, { error: 'Job job-uid failed' }));
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING), fetchJob, { intervalSeconds: 0 });
+
+ expect(result.status).toBe(JobStatus.FAILED);
+ expect(result.error).toBe('Job job-uid failed');
+ expect(fetchJob).toHaveBeenCalledTimes(1);
+ });
+
+ it('gives up, still running, once the expectedSeconds budget is spent', async () => {
+ // Deterministic clock: the first reading fixes the deadline, the last one is past it.
+ jest
+ .spyOn(Date, 'now')
+ .mockReturnValueOnce(0)
+ .mockReturnValueOnce(1_000)
+ .mockReturnValueOnce(2_000)
+ .mockReturnValue(99_000);
+ const fetchJob = jest.fn().mockResolvedValue(job(JobStatus.PROCESSING));
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING, { expectedSeconds: 10 }), fetchJob, {
+ intervalSeconds: 0,
+ });
+
+ expect(result.status).toBe(JobStatus.PROCESSING);
+ expect(fetchJob).toHaveBeenCalledTimes(2);
+ });
+
+ it('stops asking once the caller has cancelled', async () => {
+ let cancelled = false;
+ const fetchJob = jest.fn().mockImplementation(() => {
+ cancelled = true;
+ return Promise.resolve(job(JobStatus.PROCESSING));
+ });
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING), fetchJob, {
+ intervalSeconds: 0,
+ isCancelled: () => cancelled,
+ });
+
+ expect(result.status).toBe(JobStatus.PROCESSING);
+ expect(fetchJob).toHaveBeenCalledTimes(1);
+ });
+
+ // A screen unmounted before the first interval elapses must not keep a timer alive for a whole
+ // interval: the long interval here would stall the test if the loop started waiting regardless.
+ it('returns straight away when it is cancelled before the first poll', async () => {
+ const fetchJob = jest.fn();
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING), fetchJob, {
+ intervalSeconds: 30,
+ isCancelled: () => true,
+ });
+
+ expect(result.status).toBe(JobStatus.PENDING);
+ expect(fetchJob).not.toHaveBeenCalled();
+ });
+
+ // Cancellation that lands while the interval is being waited out must stop the next request,
+ // not just the next loop pass.
+ it('stops before fetching when cancellation lands during the wait', async () => {
+ const fetchJob = jest.fn();
+ let checks = 0;
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING), fetchJob, {
+ intervalSeconds: 0,
+ isCancelled: () => checks++ > 0,
+ });
+
+ expect(result.status).toBe(JobStatus.PENDING);
+ expect(fetchJob).not.toHaveBeenCalled();
+ });
+
+ it('falls back to its own interval and cancellation defaults when given no options', async () => {
+ const fetchJob = jest.fn().mockResolvedValue(job(JobStatus.COMPLETE));
+
+ const result = await pollJobUntilTerminal(job(JobStatus.PENDING), fetchJob);
+
+ expect(result.status).toBe(JobStatus.COMPLETE);
+ expect(fetchJob).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/src/screens/account-merge.screen.tsx b/src/screens/account-merge.screen.tsx
index ec2ffbf43..df446c825 100644
--- a/src/screens/account-merge.screen.tsx
+++ b/src/screens/account-merge.screen.tsx
@@ -1,16 +1,21 @@
import { ApiError, useApi, useAuthContext } from '@dfx.swiss/react';
import { SpinnerSize, StyledButton, StyledLoadingSpinner, StyledVerticalStack } from '@dfx.swiss/react-components';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { useSettingsContext } from 'src/contexts/settings.context';
import { useLayoutOptions } from 'src/hooks/layout-config.hook';
import { useNavigation } from 'src/hooks/navigation.hook';
+import { JobResponse, JobStatus, isJobResponse, isJobTerminal, pollJobUntilTerminal } from 'src/util/job';
interface MergeRedirect {
kycHash: string;
accessToken?: string;
}
+// The merge ran as a job and did not end in a usable result. Carries an already user-facing message,
+// which is what tells it apart from an ApiError in the catch below.
+class MergeJobError extends Error {}
+
export default function AccountMerge() {
const { translate } = useSettingsContext();
const { setAuthToken } = useAuthContext();
@@ -19,37 +24,94 @@ export default function AccountMerge() {
const [urlParams, setUrlParams] = useSearchParams();
const [kycHash, setKycHash] = useState();
+ const isCancelled = useRef(false);
const otp = urlParams.get('otp');
useEffect(() => {
- if (otp) {
- urlParams.delete('otp');
- setUrlParams(urlParams);
+ // The cleanup below cancels the run; re-arm here so a second invocation on the same instance
+ // (StrictMode double-invokes effects) starts a live run instead of one that discards its result.
+ isCancelled.current = false;
- call({
- url: `auth/mail/confirm?code=${otp}`,
- method: 'GET',
- })
- .then(({ kycHash, accessToken }: MergeRedirect) => {
- setAuthToken(accessToken);
- setKycHash(kycHash);
- })
- .catch((error: ApiError) => {
- const errorMessage =
- error.statusCode === 400
- ? translate('screens/error', 'Invalid link')
- : error.statusCode === 409
- ? translate('screens/error', 'Merge is already completed')
- : error.message;
-
- navigate({ pathname: '/error', search: `msg=${errorMessage}` });
- });
- } else {
+ if (!otp) {
navigate('/kyc');
+ return;
}
+
+ urlParams.delete('otp');
+ setUrlParams(urlParams);
+
+ mergeAccounts(otp)
+ .then(({ kycHash, accessToken }: MergeRedirect) => {
+ if (isCancelled.current) return;
+
+ setAuthToken(accessToken);
+ setKycHash(kycHash);
+ })
+ .catch((error: ApiError | MergeJobError) => {
+ if (isCancelled.current) return;
+
+ const errorMessage =
+ error instanceof MergeJobError
+ ? error.message
+ : error.statusCode === 400
+ ? translate('screens/error', 'Invalid link')
+ : error.statusCode === 409
+ ? translate('screens/error', 'Merge is already completed')
+ : error.message;
+
+ navigate({ pathname: '/error', search: `msg=${errorMessage}` });
+ });
+
+ return () => {
+ isCancelled.current = true;
+ };
}, []);
+ function confirmMerge(otp: string): Promise {
+ return call({ url: `auth/mail/confirm?code=${otp}`, method: 'GET' });
+ }
+
+ async function mergeAccounts(otp: string): Promise {
+ const response = await confirmMerge(otp);
+ if (!isJobResponse(response)) return response;
+
+ // 202: the merge outran the endpoint's wait window and continues as a job. A ticket that already
+ // carries a terminal status is returned unpolled, so this covers that case too.
+ //
+ // Polled with `token: false`, i.e. deliberately unauthenticated. The job belongs to the merge's
+ // master account, while whoever follows the confirmation link is still signed in as the slave —
+ // sending that session token makes the API's ownership check reject its own caller with a 404.
+ // The uid is the proof of ownership here, the same trust level as the link that created the job.
+ const job = await pollJobUntilTerminal(
+ response,
+ (uid) => call({ url: `job/${uid}`, method: 'GET', token: false }),
+ { isCancelled: () => isCancelled.current },
+ );
+
+ if (job.status !== JobStatus.COMPLETE) throw mergeJobError(job);
+
+ // The access token is issued in the HTTP context and never stored in the job, so the result has to
+ // come from the merge endpoint itself. The same otp maps to the same job, so this returns the
+ // finished merge instead of starting a second one.
+ const result = await confirmMerge(otp);
+ if (isJobResponse(result)) throw mergeJobError(result);
+
+ return result;
+ }
+
+ function mergeJobError(job: JobResponse): MergeJobError {
+ // Still running: the job may yet succeed, so this is a "come back later", not a failure.
+ if (!isJobTerminal(job.status))
+ return new MergeJobError(
+ translate('screens/error', 'Merging your accounts is taking longer than expected. Please try again later.'),
+ );
+
+ // Failed carries a generic support hint from the API, DeadLetter a domain reason — both are meant
+ // for the user, so they are shown as-is instead of being mapped to a status code.
+ return new MergeJobError(job.error ?? translate('screens/error', 'Account merge failed'));
+ }
+
useLayoutOptions({});
return (
diff --git a/src/translations/languages/de.json b/src/translations/languages/de.json
index ef9bfec00..fa63ca973 100644
--- a/src/translations/languages/de.json
+++ b/src/translations/languages/de.json
@@ -485,6 +485,8 @@
"Invalid link": "Ungültiger Link",
"Merge is already completed": "Zusammenführung ist bereits abgeschlossen",
+ "Merging your accounts is taking longer than expected. Please try again later.": "Die Zusammenführung Deiner Konten dauert länger als erwartet. Bitte versuche es später erneut.",
+ "Account merge failed": "Zusammenführung der Konten fehlgeschlagen",
"Please return to the previous page. If this problem persists, please contact our support.": "Bitte kehre zur vorherigen Seite zurück. Wenn dieses Problem weiterhin besteht, wende Dich bitte an unseren Support.",
"Please reload this page. If this problem persists, please contact our support.": "Bitte lade diese Seite neu. Wenn dieses Problem weiterhin besteht, wende Dich bitte an unseren Support."
},
diff --git a/src/translations/languages/fr.json b/src/translations/languages/fr.json
index 3324e6ca0..17fd2472c 100644
--- a/src/translations/languages/fr.json
+++ b/src/translations/languages/fr.json
@@ -485,6 +485,8 @@
"Invalid link": "Lien invalide",
"Merge is already completed": "La fusion est déjà terminée",
+ "Merging your accounts is taking longer than expected. Please try again later.": "La fusion de vos comptes prend plus de temps que prévu. Veuillez réessayer plus tard.",
+ "Account merge failed": "Échec de la fusion des comptes",
"Please return to the previous page. If this problem persists, please contact our support.": "Veuillez revenir à la page précédente. Si le problème persiste, veuillez contacter notre service d'assistance.",
"Please reload this page. If this problem persists, please contact our support.": "Veuillez recharger cette page. Si le problème persiste, veuillez contacter notre service d'assistance."
},
diff --git a/src/translations/languages/it.json b/src/translations/languages/it.json
index 33ab045d6..1be23c2d2 100644
--- a/src/translations/languages/it.json
+++ b/src/translations/languages/it.json
@@ -485,6 +485,8 @@
"Invalid link": "Link non valido",
"Merge is already completed": "La fusione è già completata",
+ "Merging your accounts is taking longer than expected. Please try again later.": "La fusione dei tuoi account sta richiedendo più tempo del previsto. Riprova più tardi.",
+ "Account merge failed": "Fusione degli account non riuscita",
"Please return to the previous page. If this problem persists, please contact our support.": "Tornare alla pagina precedente. Se il problema persiste, contattare l'assistenza.",
"Please reload this page. If this problem persists, please contact our support.": "Ricaricare questa pagina. Se il problema persiste, contattare l'assistenza."
},
diff --git a/src/util/job.ts b/src/util/job.ts
new file mode 100644
index 000000000..69babde3e
--- /dev/null
+++ b/src/util/job.ts
@@ -0,0 +1,71 @@
+import { delay } from './utils';
+
+/**
+ * Async job contract of the API (DFXswiss/api#4496): an endpoint whose work outruns its short wait
+ * window answers HTTP 202 with a job ticket instead of the result. The client polls `GET /job/:uid`
+ * until the job is terminal and then asks the originating endpoint for the result again — the
+ * result is deliberately not carried in the job.
+ */
+export enum JobStatus {
+ PENDING = 'Pending',
+ PROCESSING = 'Processing',
+ COMPLETE = 'Complete',
+ RETRY = 'Retry',
+ FAILED = 'Failed',
+ DEAD_LETTER = 'DeadLetter',
+}
+
+export interface JobResponse {
+ uid: string;
+ status: JobStatus;
+ expectedSeconds: number;
+ error?: string;
+}
+
+// Retry is deliberately not terminal: the attempt failed but attempts remain and the job is waiting
+// to run again.
+const terminalStatus: JobStatus[] = [JobStatus.COMPLETE, JobStatus.FAILED, JobStatus.DEAD_LETTER];
+
+export function isJobTerminal(status: JobStatus): boolean {
+ return terminalStatus.includes(status);
+}
+
+/**
+ * Tells a 202 job ticket apart from the 200 result body. `useApi().call` resolves both through
+ * `response.ok` and never exposes the status code, so the body itself has to be the discriminator.
+ */
+export function isJobResponse(response: unknown): response is JobResponse {
+ const job = response as JobResponse | null | undefined;
+ return typeof job?.uid === 'string' && typeof job?.status === 'string';
+}
+
+interface PollJobOptions {
+ intervalSeconds?: number;
+ isCancelled?: () => boolean;
+}
+
+/**
+ * Polls until the job reaches a terminal state, or until its own time budget is spent.
+ *
+ * The budget is `expectedSeconds` — the group's queue time plus run time — so the client follows the
+ * API's configuration instead of a second constant that would drift from it. A job that is still
+ * running when the budget is spent, or one abandoned through `isCancelled`, is returned as-is and
+ * therefore non-terminal; the caller decides how to present that.
+ */
+export async function pollJobUntilTerminal(
+ job: JobResponse,
+ fetchJob: (uid: string) => Promise,
+ { intervalSeconds = 1, isCancelled = () => false }: PollJobOptions = {},
+): Promise {
+ const deadline = Date.now() + job.expectedSeconds * 1000;
+ let current = job;
+
+ while (!isJobTerminal(current.status) && Date.now() < deadline && !isCancelled()) {
+ await delay(intervalSeconds);
+ if (isCancelled()) break;
+
+ current = await fetchJob(current.uid);
+ }
+
+ return current;
+}