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
64 changes: 36 additions & 28 deletions .github/workflows/react-native-cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -101,15 +101,17 @@ jobs:
node-version: '24'
cache: 'yarn'

- name: 📦 Setup yarn cache
# node_modules is post-patch-package state, so patches/ must be part of the key:
# restoring an already-patched tree built from a different patches/ revision makes
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
# back node_modules patched by some other revision. The yarn tarball cache is handled
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
# re-download.
- name: 📦 Setup node_modules cache
uses: actions/cache@v4
with:
path: |
~/.cache/yarn
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}

- name: 📦 Install dependencies
run: yarn install --frozen-lockfile
Expand Down Expand Up @@ -172,15 +174,17 @@ jobs:
eas-version: latest
token: ${{ secrets.EXPO_TOKEN }}

- name: 📦 Setup yarn cache
# node_modules is post-patch-package state, so patches/ must be part of the key:
# restoring an already-patched tree built from a different patches/ revision makes
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
# back node_modules patched by some other revision. The yarn tarball cache is handled
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
# re-download.
- name: 📦 Setup node_modules cache
uses: actions/cache@v4
with:
path: |
~/.cache/yarn
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}

- name: 📦 Install dependencies
run: |
Expand Down Expand Up @@ -407,15 +411,17 @@ jobs:
node-version: '24'
cache: 'yarn'

- name: 📦 Setup yarn cache
# node_modules is post-patch-package state, so patches/ must be part of the key:
# restoring an already-patched tree built from a different patches/ revision makes
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
# back node_modules patched by some other revision. The yarn tarball cache is handled
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
# re-download.
- name: 📦 Setup node_modules cache
uses: actions/cache@v4
with:
path: |
~/.cache/yarn
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}

- name: 📦 Install dependencies
run: yarn install --frozen-lockfile
Expand Down Expand Up @@ -531,15 +537,17 @@ jobs:
node-version: '24'
cache: 'yarn'

- name: 📦 Setup yarn cache
# node_modules is post-patch-package state, so patches/ must be part of the key:
# restoring an already-patched tree built from a different patches/ revision makes
# the re-apply fail. No restore-keys for the same reason — a prefix match would hand
# back node_modules patched by some other revision. The yarn tarball cache is handled
# by setup-node's `cache: yarn` above, so a key miss here is only a re-link, not a
# re-download.
- name: 📦 Setup node_modules cache
uses: actions/cache@v4
with:
path: |
~/.cache/yarn
node_modules
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
restore-keys: |
${{ runner.os }}-yarn-
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('yarn.lock', 'patches/**') }}

- name: 📦 Install dependencies
run: yarn install --frozen-lockfile
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"test:ci": "yarn run test --coverage",
"test:watch": "yarn run test --watch",
"install-maestro": "curl -Ls 'https://get.maestro.mobile.dev' | bash",
"e2e-test": "maestro test .maestro/ -e APP_ID=com.obytes.development",
"e2e-test": "maestro test .maestro/ -e APP_ID=com.resgrid.unit.development",
"web:build": "cross-env EXPO_NO_DOTENV=1 expo export --platform web",
"web:staging": "cross-env APP_ENV=staging yarn run web",
"web:production": "cross-env APP_ENV=production yarn run web",
Expand Down
2 changes: 1 addition & 1 deletion src/api/calls/callPriorities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type CallPrioritiesResult } from '@/models/v4/callPriorities/callPriori
import { createCachedApiEndpoint } from '../common/cached-client';

const callsPrioritesApi = createCachedApiEndpoint('/CallPriorities/GetAllCallPriorites', {
ttl: 60 * 1000 * 2880, // Cache for 2 days
ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely
enabled: true,
});

Expand Down
2 changes: 1 addition & 1 deletion src/api/calls/callTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type CallTypesResult } from '@/models/v4/callTypes/callTypesResult';
import { createCachedApiEndpoint } from '../common/cached-client';

const callsTypesApi = createCachedApiEndpoint('/CallTypes/GetAllCallTypes', {
ttl: 60 * 1000 * 2880, // Cache for 2 days
ttl: 6 * 60 * 60 * 1000, // Cache for 6 hours -- reference data, changes rarely
enabled: true,
});

Expand Down
22 changes: 22 additions & 0 deletions src/api/calls/newCallFieldPolicy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { type NewCallFieldPolicyResultData } from '@/models/v4/calls/newCallFieldPolicyResultData';

import { createApiEndpoint } from '../common/client';

const getNewCallFieldPolicyApi = createApiEndpoint('/Calls/GetNewCallFieldPolicy');

interface NewCallFieldPolicyResult {
Data: NewCallFieldPolicyResultData | null;
}

/**
* Fetches the department's new-call field policy.
*
* An empty rule list means the stock form — every field visible, nothing extra required — which is
* also what a failure degrades to, since hiding fields a dispatcher needs is far worse than showing
* one they were told to hide. The server enforces the same policy on save regardless.
*/
export const getNewCallFieldPolicy = async (signal?: AbortSignal): Promise<NewCallFieldPolicyResultData> => {
const response = await getNewCallFieldPolicyApi.get<NewCallFieldPolicyResult>(undefined, signal);

return response.data?.Data ?? { Rules: [] };
};
135 changes: 135 additions & 0 deletions src/api/common/__tests__/cached-client-scope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { type AxiosResponse } from 'axios';

const mockGet = jest.fn();
const mockStorageSet = jest.fn();
const mockStorageGetString = jest.fn();
const mockStorageDelete = jest.fn();
const mockGetBaseApiUrl = jest.fn();
const mockGetCacheScopeKey = jest.fn();

jest.mock('@/api/common/client', () => ({
createApiEndpoint: jest.fn(() => ({
get: mockGet,
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
})),
}));

jest.mock('@/lib/storage', () => ({
storage: {
set: mockStorageSet,
getString: mockStorageGetString,
delete: mockStorageDelete,
getAllKeys: jest.fn(() => []),
},
}));

jest.mock('@/lib/storage/app', () => ({
getBaseApiUrl: mockGetBaseApiUrl,
}));

jest.mock('@/lib/cache/cache-scope', () => ({
getCacheScopeKey: mockGetCacheScopeKey,
}));

type CachedEndpoint = {
get: <T>(params?: Record<string, unknown>, options?: { forceRefresh?: boolean }) => Promise<AxiosResponse<T>>;
};

let createCachedApiEndpoint: (endpoint: string, cacheConfig?: { ttl?: number; enabled?: boolean }) => CachedEndpoint;

const ENDPOINT = '/Units/GetAllUnits';
const USER_ONE_KEY = `api_cache_https://api.test_7_user-1_${ENDPOINT}`;
const USER_TWO_KEY = `api_cache_https://api.test_9_user-2_${ENDPOINT}`;

/** Resolves the pending api.get by hand, so the scope can move while the request is in flight. */
const pendingResponse = <T>() => {
let settle: (value: { data: T }) => void = () => undefined;
mockGet.mockReturnValue(
new Promise<{ data: T }>((resolve) => {
settle = resolve;
})
);

return { settle: (data: T) => settle({ data }) };
};

describe('createCachedApiEndpoint cache scope changes mid-request', () => {
beforeAll(() => {
// Required lazily so the mock factories above see initialized mock functions.
createCachedApiEndpoint = require('@/api/common/cached-client').createCachedApiEndpoint;
});

beforeEach(() => {
jest.clearAllMocks();
mockStorageGetString.mockReturnValue(undefined);
mockGetBaseApiUrl.mockReturnValue('https://api.test');
mockGetCacheScopeKey.mockReturnValue('7_user-1');
});

it('does not store a response under the new identity when the signed-in user changes while it is pending', async () => {
const rows = [{ UnitId: '1' }];
const request = pendingResponse<typeof rows>();

const api = createCachedApiEndpoint(ENDPOINT);
const pending = api.get<typeof rows>();

// The first user signs out and a second signs in before the answer lands.
mockGetCacheScopeKey.mockReturnValue('9_user-2');
request.settle(rows);

const response = await pending;

// The caller that asked still gets its answer...
expect(response.data).toEqual(rows);
// ...but nothing is written to the second user's namespace, nor to the first user's.
expect(mockStorageSet).not.toHaveBeenCalled();
expect(mockStorageDelete).not.toHaveBeenCalledWith(USER_TWO_KEY);
});

it('does not evict the new identity entry when an empty response lands after a scope change', async () => {
const request = pendingResponse<unknown[]>();

const api = createCachedApiEndpoint(ENDPOINT);
const pending = api.get<unknown[]>();

mockGetCacheScopeKey.mockReturnValue('9_user-2');
request.settle([]);

await pending;

expect(mockStorageDelete).not.toHaveBeenCalledWith(USER_TWO_KEY);
expect(mockStorageSet).not.toHaveBeenCalled();
});

it('does not store a response under the new server when the base URL changes while it is pending', async () => {
const rows = [{ UnitId: '1' }];
const request = pendingResponse<typeof rows>();

const api = createCachedApiEndpoint(ENDPOINT);
const pending = api.get<typeof rows>();

mockGetBaseApiUrl.mockReturnValue('https://other.test');
request.settle(rows);

await pending;

expect(mockStorageSet).not.toHaveBeenCalled();
});

it('stores the response under the requesting identity when the scope holds still', async () => {
const rows = [{ UnitId: '1' }];
const request = pendingResponse<typeof rows>();

const api = createCachedApiEndpoint(ENDPOINT, { enabled: true, ttl: 1000 });
const pending = api.get<typeof rows>();

request.settle(rows);
await pending;

expect(mockStorageSet).toHaveBeenCalledTimes(1);
expect(mockStorageSet.mock.calls[0][0]).toBe(USER_ONE_KEY);
expect(JSON.parse(mockStorageSet.mock.calls[0][1] as string)).toMatchObject({ data: rows, expiresIn: 1000 });
});
});
75 changes: 75 additions & 0 deletions src/api/common/__tests__/cached-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { type AxiosResponse } from 'axios';

const mockGet = jest.fn();
const mockCacheGet = jest.fn();
const mockCacheSet = jest.fn();
const mockCacheRemove = jest.fn();
const mockCacheScopeIdentity = jest.fn(() => 'https://api.test_7_user-1');

jest.mock('@/api/common/client', () => ({
createApiEndpoint: jest.fn(() => ({
get: mockGet,
post: jest.fn(),
put: jest.fn(),
delete: jest.fn(),
})),
}));

jest.mock('@/lib/cache/cache-manager', () => ({
cacheManager: {
get: mockCacheGet,
set: mockCacheSet,
remove: mockCacheRemove,
getScopeIdentity: mockCacheScopeIdentity,
},
}));

type CachedEndpoint = {
get: <T>(params?: Record<string, unknown>, options?: { forceRefresh?: boolean }) => Promise<AxiosResponse<T>>;
};

let createCachedApiEndpoint: (endpoint: string, cacheConfig?: { ttl?: number; enabled?: boolean }) => CachedEndpoint;

describe('createCachedApiEndpoint empty payload handling', () => {
beforeAll(() => {
// Required lazily so the mock factories above see initialized mock functions.
createCachedApiEndpoint = require('@/api/common/cached-client').createCachedApiEndpoint;
});

beforeEach(() => {
jest.clearAllMocks();
mockCacheGet.mockReturnValue(null);
});

it('does not cache a response whose body is a direct empty array', async () => {
mockGet.mockResolvedValue({ data: [] });

const api = createCachedApiEndpoint('/Units/GetAllUnits');
const response = await api.get();

expect(response.data).toEqual([]);
expect(mockCacheSet).not.toHaveBeenCalled();
expect(mockCacheRemove).toHaveBeenCalledWith('/Units/GetAllUnits', undefined);
});

it('evicts a previously cached non-empty answer when a direct empty array comes back', async () => {
mockGet.mockResolvedValue({ data: [] });

const api = createCachedApiEndpoint('/Units/GetAllUnits');
await api.get({ departmentId: '7' }, { forceRefresh: true });

expect(mockCacheSet).not.toHaveBeenCalled();
expect(mockCacheRemove).toHaveBeenCalledWith('/Units/GetAllUnits', { departmentId: '7' });
});

it('caches a response whose body is a direct non-empty array', async () => {
const rows = [{ UnitId: '1' }];
mockGet.mockResolvedValue({ data: rows });

const api = createCachedApiEndpoint('/Units/GetAllUnits', { enabled: true, ttl: 1000 });
await api.get();

expect(mockCacheRemove).not.toHaveBeenCalled();
expect(mockCacheSet).toHaveBeenCalledWith('/Units/GetAllUnits', rows, undefined, 1000);
});
});
Loading
Loading