jest.mock('../src/lib/supabase', () => ({ supabase: { functions: { invoke: jest.fn() } }, })) import { FunctionsFetchError, FunctionsHttpError } from '@supabase/supabase-js' import { supabase } from '../src/lib/supabase' import { edgeFailureField, invokeEdgeFunction, readEdgeFunctionHttpFailure, type EdgeFunctionFailure, } from '../src/lib/edge-functions' import { accountDeletionFailureMessageKey, edgeAccountDeletionService, runAccountDeletion, type AccountDeletionService, } from '../src/features/account/account-deletion-service' const mockInvoke = (supabase as unknown as { functions: { invoke: jest.Mock } }).functions.invoke function jsonResponse(body: unknown, status: number): Response { return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }) } /** Mirrors functions-js 2.103: non-2xx → { data: null, error: FunctionsHttpError(response) }. */ function httpFailure(body: unknown, status: number): { data: null; error: FunctionsHttpError } { return { data: null, error: new FunctionsHttpError(jsonResponse(body, status)) } } beforeEach(() => { mockInvoke.mockReset() }) describe('invokeEdgeFunction adapter', () => { test('decodes the JSON body of a 403 FunctionsHttpError instead of relying on data', async () => { mockInvoke.mockResolvedValue(httpFailure({ error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED', }, 403)) const result = await invokeEdgeFunction('account-delete', { confirmation: 'x' }) expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'x' } }) expect(result.ok).toBe(false) const failure = result as EdgeFunctionFailure expect(failure.status).toBe(403) expect(failure.body).toEqual({ readable: true, payload: { error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED' }, }) expect(edgeFailureField(failure, 'code')).toBe('REAUTHENTICATION_REQUIRED') expect(edgeFailureField(failure, 'error')).toBe('Recent authentication is required') expect(failure.message).toBe('Edge Function returned a non-2xx status code') }) test('reports an unreadable body without inventing a code', async () => { mockInvoke.mockResolvedValue({ data: null, error: new FunctionsHttpError(new Response('bad gateway', { status: 502 })), }) const result = await invokeEdgeFunction('team-invite', {}) expect(result).toMatchObject({ ok: false, status: 502, body: { readable: false } }) expect(edgeFailureField(result as EdgeFunctionFailure, 'code')).toBeNull() }) test('keeps fetch errors and thrown values as failures without an HTTP status', async () => { mockInvoke.mockResolvedValueOnce({ data: null, error: new FunctionsFetchError('offline') }) await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({ ok: false, status: null, body: null, }) const thrown = new TypeError('Network request failed') mockInvoke.mockRejectedValueOnce(thrown) await expect(invokeEdgeFunction('iap-verify', {})).resolves.toMatchObject({ ok: false, status: null, body: null, message: 'Network request failed', cause: thrown, }) }) test('returns data on success', async () => { mockInvoke.mockResolvedValue({ data: { success: true }, error: null }) await expect(invokeEdgeFunction('account-delete', {})).resolves.toEqual({ ok: true, data: { success: true }, }) }) test('readEdgeFunctionHttpFailure ignores errors without a Response context', async () => { await expect(readEdgeFunctionHttpFailure(new Error('x'))).resolves.toBeNull() await expect(readEdgeFunctionHttpFailure({ context: 'nope' })).resolves.toBeNull() await expect(readEdgeFunctionHttpFailure(null)).resolves.toBeNull() }) }) describe('edgeAccountDeletionService', () => { test('recognises REAUTHENTICATION_REQUIRED from a 403 response body', async () => { mockInvoke.mockResolvedValue(httpFailure({ error: 'Recent authentication is required', code: 'REAUTHENTICATION_REQUIRED', }, 403)) await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED', message: 'Recent authentication is required', }) expect(mockInvoke).toHaveBeenCalledWith('account-delete', { body: { confirmation: 'DELETE_MY_ACCOUNT' }, }) }) test('recognises ACTIVE_SUBSCRIPTION from a 409 response body', async () => { mockInvoke.mockResolvedValue(httpFailure({ error: 'Cancel the active subscription before deleting the account', code: 'ACTIVE_SUBSCRIPTION', }, 409)) await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({ ok: false, code: 'ACTIVE_SUBSCRIPTION', }) }) test('maps a missing function (404) to SERVER_ENDPOINT_UNAVAILABLE', async () => { mockInvoke.mockResolvedValue(httpFailure({ code: 'NOT_FOUND', message: 'Requested function was not found' }, 404)) await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toMatchObject({ ok: false, code: 'SERVER_ENDPOINT_UNAVAILABLE', }) }) test('treats a 2xx without success=true as REQUEST_FAILED and succeeds only on success=true', async () => { mockInvoke.mockResolvedValueOnce({ data: { success: false, error: 'nope' }, error: null }) await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: false, code: 'REQUEST_FAILED', message: 'nope', }) mockInvoke.mockResolvedValueOnce({ data: { success: true }, error: null }) await expect(edgeAccountDeletionService.deleteCurrentAccount()).resolves.toEqual({ ok: true }) }) }) describe('accountDeletionFailureMessageKey', () => { test('always maps to translated copy, never raw server/SDK text', () => { expect(accountDeletionFailureMessageKey('REAUTHENTICATION_REQUIRED')) .toBe('mobile.account.reauthenticationRequired') expect(accountDeletionFailureMessageKey('SERVER_ENDPOINT_UNAVAILABLE')) .toBe('mobile.account.deleteUnavailable') expect(accountDeletionFailureMessageKey('ACTIVE_SUBSCRIPTION')).toBe('mobile.account.deleteFailed') expect(accountDeletionFailureMessageKey('REQUEST_FAILED')).toBe('mobile.account.deleteFailed') }) }) describe('runAccountDeletion ordering', () => { function service(result: Awaited>): AccountDeletionService { return { deleteCurrentAccount: jest.fn(async () => result) } } test('a refused deletion leaves push registration and the local session untouched', async () => { const detachPushRegistration = jest.fn(async () => undefined) const purgeLocalSession = jest.fn(async () => undefined) await expect(runAccountDeletion({ deletionService: service({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }), detachPushRegistration, purgeLocalSession, })).resolves.toEqual({ ok: false, code: 'REAUTHENTICATION_REQUIRED' }) expect(detachPushRegistration).not.toHaveBeenCalled() expect(purgeLocalSession).not.toHaveBeenCalled() }) test('detaches push after server confirmation and before the local purge', async () => { const calls: string[] = [] const deletionService: AccountDeletionService = { deleteCurrentAccount: jest.fn(async () => { calls.push('delete') return { ok: true } as const }), } await expect(runAccountDeletion({ deletionService, detachPushRegistration: async () => { calls.push('detach') }, purgeLocalSession: async () => { calls.push('purge') }, })).resolves.toEqual({ ok: true }) expect(calls).toEqual(['delete', 'detach', 'purge']) }) test('a push detach failure after deletion does not block the local purge', async () => { const purgeLocalSession = jest.fn(async () => undefined) await expect(runAccountDeletion({ deletionService: service({ ok: true }), detachPushRegistration: async () => { throw new Error('both push boundaries failed') }, purgeLocalSession, })).resolves.toEqual({ ok: true }) expect(purgeLocalSession).toHaveBeenCalledTimes(1) }) test('a local purge failure propagates to the caller', async () => { await expect(runAccountDeletion({ deletionService: service({ ok: true }), detachPushRegistration: async () => undefined, purgeLocalSession: async () => { throw new Error('purge failed') }, })).rejects.toThrow('purge failed') }) })