Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added assets/mapping/direction_arrow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 7 additions & 18 deletions jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,21 +268,10 @@ jest.mock('expo-video', () => ({
})),
}));

// Mock zod globally to avoid validation schema issues in tests
jest.mock('zod', () => ({
z: {
object: jest.fn(() => ({
parse: jest.fn((data) => data),
safeParse: jest.fn((data) => ({ success: true, data })),
})),
string: jest.fn(() => ({
min: jest.fn(() => ({
parse: jest.fn((data) => data),
safeParse: jest.fn((data) => ({ success: true, data })),
})),
parse: jest.fn((data) => data),
safeParse: jest.fn((data) => ({ success: true, data })),
})),
},
__esModule: true,
}));
// zod is deliberately NOT mocked. A global stub used to live here, returning a
// schema whose safeParse accepted everything and which had no parseAsync at all
// — the method zodResolver actually calls. Any form suite that loaded it either
// crashed on `import * as z` or silently validated nothing. Every schema in this
// app guards a form a responder depends on (login, SSO, call create, call edit),
// so those schemas must run for real in tests. zod is plain CJS and resolves
// under jest with no configuration.
93 changes: 93 additions & 0 deletions src/__tests__/no-self-mocking-suites.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import fs from 'fs';
import path from 'path';

/**
* Guards against test suites that mock away the very module they claim to cover.
*
* A file at `<dir>/__tests__/<name>.test.tsx` whose subject is `<dir>/<name>.tsx`
* must not call `jest.mock('../<name>')`. Doing so replaces the subject with a
* hand-written stand-in, so the suite asserts against the stand-in and executes
* none of the production code — it passes forever, including when the real
* component is broken or deleted.
*
* Mocking a subject's *dependencies* is normal and correct; only the subject
* itself is off limits. That is why the check is narrow: it fires solely when a
* test mocks the sibling module it is named after.
*
* A sweep on 2026-08-21 found eight such suites. Four covered production code
* that nothing else touched (login-form, call-images-modal,
* full-screen-image-modal, full-screen-location-picker) and were rewritten
* against the real components — which immediately surfaced six real bugs. The
* four below still have a sibling suite exercising the real code, so they are
* misleading rather than dangerous. They are listed here as known debt: the list
* may shrink, never grow.
*/
const KNOWN_SELF_MOCKING_SUITES = [
'src/components/notifications/__tests__/NotificationInbox.test.tsx',
'src/components/maps/__tests__/pin-detail-modal.test.tsx',
'src/components/calls/__tests__/call-notes-modal.test.tsx',
'src/components/calls/__tests__/call-detail-menu.test.tsx',
];

const SRC = path.join(__dirname, '..');
const SUBJECT_EXTENSIONS = ['.ts', '.tsx'];

const collectTestFiles = (dir: string, found: string[] = []): string[] => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
collectTestFiles(full, found);
} else if (/\.(test|spec)\.tsx?$/.test(entry.name)) {
found.push(full);
}
}
return found;
};

/**
* Strip comments so a suite that merely *describes* the anti-pattern in prose
* (as the rewritten login-form suite does) is not mistaken for one committing it.
*/
const stripComments = (source: string): string => source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1');

/** Returns the repo-relative path of a suite that mocks its own subject, or null. */
const findSelfMock = (testFile: string): string | null => {
const dir = path.dirname(testFile);
if (path.basename(dir) !== '__tests__') {
return null;
}

const subjectName = path.basename(testFile).replace(/\.(test|spec)\.tsx?$/, '');
const subjectDir = path.dirname(dir);
const subjectExists = SUBJECT_EXTENSIONS.some((ext) => fs.existsSync(path.join(subjectDir, `${subjectName}${ext}`)));
if (!subjectExists) {
return null;
}

const source = stripComments(fs.readFileSync(testFile, 'utf8'));
// Match jest.mock('../<subject>') / jest.doMock("../<subject>"), with or without a factory.
const selfMock = new RegExp(String.raw`jest\.(?:do)?[Mm]ock\(\s*['"\`]\.\./${subjectName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"\`]`);
return selfMock.test(source) ? path.relative(path.join(SRC, '..'), testFile) : null;
};

describe('test suites cover their real subject', () => {
const testFiles = collectTestFiles(SRC);

it('finds test files to check', () => {
expect(testFiles.length).toBeGreaterThan(100);
});

it('has no self-mocking suite outside the known-debt list', () => {
const offenders = testFiles.map(findSelfMock).filter((entry): entry is string => entry !== null);
const unexpected = offenders.filter((entry) => !KNOWN_SELF_MOCKING_SUITES.includes(entry));

expect(unexpected).toEqual([]);
});

it('keeps the known-debt list honest — entries that no longer self-mock must be removed', () => {
const offenders = new Set(testFiles.map(findSelfMock).filter((entry): entry is string => entry !== null));
const staleEntries = KNOWN_SELF_MOCKING_SUITES.filter((entry) => !offenders.has(entry));

expect(staleEntries).toEqual([]);
});
});
3 changes: 2 additions & 1 deletion src/api/calls/callFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import axios, { type AxiosProgressEvent, type AxiosRequestConfig, type AxiosResp
import { Platform } from 'react-native';

import { createApiEndpoint } from '@/api/common/client';
import { logger } from '@/lib/logging';
import { type CallFilesResult } from '@/models/v4/callFiles/callFilesResult';
import { type SaveCallFileResult } from '@/models/v4/callFiles/saveCallFileResult';

Expand Down Expand Up @@ -83,7 +84,7 @@ export const getCallAttachmentFile = async (url: string, options: DownloadOption
// Callers should check the return value and fall back to expo-file-system / expo-sharing on native.
export const saveBlobAsFile = (blob: Blob, fileName: string): boolean => {
if (Platform.OS !== 'web') {
console.warn('saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.');
logger.warn({ message: 'saveBlobAsFile is not supported on native platforms. Use expo-file-system and expo-sharing instead.' });
return false;
}

Expand Down
17 changes: 9 additions & 8 deletions src/api/calls/calls.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { cacheManager } from '@/lib/cache/cache-manager';
import { logger } from '@/lib/logging';
import { type ActiveCallsResult } from '@/models/v4/calls/activeCallsResult';
import { type CallExtraDataResult } from '@/models/v4/calls/callExtraDataResult';
import { type CallResult } from '@/models/v4/calls/callResult';
Expand All @@ -17,8 +18,8 @@ const createCallApi = createApiEndpoint('/Calls/SaveCall');
const updateCallApi = createApiEndpoint('/Calls/EditCall');
const closeCallApi = createApiEndpoint('/Calls/CloseCall');

export const getCalls = async () => {
const response = await callsApi.get<ActiveCallsResult>();
export const getCalls = async (forceRefresh = false) => {
const response = await callsApi.get<ActiveCallsResult>(undefined, { forceRefresh });
return response.data;
};

Expand Down Expand Up @@ -136,8 +137,8 @@ export const createCall = async (callData: CreateCallRequest) => {
try {
cacheManager.remove('/Calls/GetActiveCalls');
} catch (error) {
// Silently handle cache removal errors
console.warn('Failed to invalidate calls cache:', error);
// Cache removal failures are non-fatal
logger.warn({ message: 'Failed to invalidate calls cache', context: { error } });
}

return response.data;
Expand Down Expand Up @@ -169,8 +170,8 @@ export const updateCall = async (callData: UpdateCallRequest) => {
try {
cacheManager.remove('/Calls/GetActiveCalls');
} catch (error) {
// Silently handle cache removal errors
console.warn('Failed to invalidate calls cache:', error);
// Cache removal failures are non-fatal
logger.warn({ message: 'Failed to invalidate calls cache', context: { error } });
}

return response.data;
Expand All @@ -189,8 +190,8 @@ export const closeCall = async (callData: CloseCallRequest) => {
try {
cacheManager.remove('/Calls/GetActiveCalls');
} catch (error) {
// Silently handle cache removal errors
console.warn('Failed to invalidate calls cache:', error);
// Cache removal failures are non-fatal
logger.warn({ message: 'Failed to invalidate calls cache', context: { error } });
}

return response.data;
Expand Down
30 changes: 30 additions & 0 deletions src/api/common/__tests__/api-provider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
jest.mock('@dev-plugins/react-query', () => ({
useReactQueryDevTools: jest.fn(),
}));

import { queryClient } from '../api-provider';

describe('shared QueryClient defaults', () => {
const defaults = queryClient.getDefaultOptions().queries;

it('applies a modest stale time instead of refetching on every mount', () => {
expect(defaults?.staleTime).toBe(30 * 1000);
});

it('retries failed queries a bounded number of times', () => {
expect(defaults?.retry).toBe(2);
});

it('does not refetch on window focus', () => {
// On web/Electron every tab focus would otherwise re-run every mounted query.
expect(defaults?.refetchOnWindowFocus).toBe(false);
});

it('leaves per-query options free to override the defaults', () => {
// Consumers pass their own enabled/queryFn/staleTime; defaults must not be
// frozen into the client in a way that blocks that.
const observer = queryClient.defaultQueryOptions({ queryKey: ['x'], staleTime: 0, retry: false });
expect(observer.staleTime).toBe(0);
expect(observer.retry).toBe(false);
});
});
15 changes: 14 additions & 1 deletion src/api/common/__tests__/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,17 @@ const mockAxiosInstance = Object.assign(jest.fn(), {
},
});

// Records the config the client passes to axios.create, so the assertions below
// survive both jest.isolateModules and the beforeEach clearAllMocks().
const mockCreateConfigs: Record<string, unknown>[] = [];

jest.mock('axios', () => ({
__esModule: true,
default: {
create: jest.fn(() => mockAxiosInstance),
create: jest.fn((config: Record<string, unknown>) => {
mockCreateConfigs.push(config);
return mockAxiosInstance;
}),
},
}));

Expand Down Expand Up @@ -87,4 +94,10 @@ describe('API client token refresh logging', () => {
});
expect(getHeader).toHaveBeenCalledWith('x-trace-id');
});

it('configures a request timeout so a hung request cannot stall the refresh queue', () => {
// Axios defaults to no timeout: a hung request would hold the single-flight
// refresh promise and every 401-queued request behind it, indefinitely.
expect(mockCreateConfigs[0]).toMatchObject({ timeout: 30000 });
});
});
10 changes: 9 additions & 1 deletion src/api/common/api-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,15 @@ import { useReactQueryDevTools } from '@dev-plugins/react-query';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import * as React from 'react';

export const queryClient = new QueryClient();
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30 * 1000,
retry: 2,
refetchOnWindowFocus: false,
},
},
});

export function APIProvider({ children }: { children: React.ReactNode }) {
useReactQueryDevTools(queryClient);
Expand Down
3 changes: 3 additions & 0 deletions src/api/common/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import useAuthStore from '@/stores/auth/store';
// Create axios instance with default config
const axiosInstance: AxiosInstance = axios.create({
baseURL: getBaseApiUrl(),
// Axios defaults to no timeout — a hung request would otherwise hold the
// single-flight refresh (and every 401-queued request behind it) forever.
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
Expand Down
4 changes: 2 additions & 2 deletions src/api/notes/notes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ export const saveNote = async (data: SaveNoteInput) => {
const response = await saveNoteApi.post<SaveNoteResult>({
...data,
});
// The notes list is cached for 2 days — invalidate so the new/updated note
// is visible immediately instead of after cache expiry.
// The notes list is cached for 15 minutes — invalidate so the new/updated
// note is visible immediately instead of after cache expiry.
cacheManager.remove('/Notes/GetAllNotes');
return response.data;
};
Loading
Loading