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
8 changes: 5 additions & 3 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,10 @@ module.exports = defineConfig([
'jsx-a11y/aria-unsupported-elements': 'warn',

// Enforce structured logging — use src/utils/logger instead of console.*
// Allowlist: logger internals may reference console internally (excluded via ignores above)
'no-console': ['error', { allow: [] }],
// Logger internals may reference console internally (excluded via ignores above).
// Note: `{ allow: [] }` is rejected by ESLint 9's rule schema, so use the
// bare 'error' form, which disallows every console method.
'no-console': 'error',
},
},
]);
]);
94 changes: 94 additions & 0 deletions src/__tests__/issues/fix_597_cache_ttl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* Unit tests for Issue #597 — per-endpoint SWR cache TTL + pattern invalidation.
*/

import {
DEFAULT_ENDPOINT_TTL,
ENDPOINT_TTL_MAP,
MUTATION_INVALIDATION_MAP,
resolveEndpointTtl,
} from '../../config/apiCacheConfig';
import { clearCache, getCache, invalidatePattern, setCache } from '../../services/api/cache';

jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn().mockResolvedValue(null),
setItem: jest.fn().mockResolvedValue(undefined),
removeItem: jest.fn().mockResolvedValue(undefined),
getAllKeys: jest.fn().mockResolvedValue([]),
}));

jest.mock('../../services/mobileAnalytics', () => ({
mobileAnalyticsService: { trackEvent: jest.fn() },
}));

const THIRTY_SECONDS = 30_000;
const FIVE_MINUTES = 5 * 60_000;

describe('Issue #597 — per-endpoint TTL configuration', () => {
it('applies a 30s TTL to critical endpoints', () => {
for (const url of ['/subscriptions', '/auth/me', '/payments']) {
expect(resolveEndpointTtl(url).ttl).toBe(THIRTY_SECONDS);
}
});

it('applies a 5min TTL to static endpoints', () => {
for (const url of ['/courses', '/categories']) {
expect(resolveEndpointTtl(url).ttl).toBe(FIVE_MINUTES);
}
});

it('confirms different TTL values are applied per endpoint', () => {
const subscriptions = resolveEndpointTtl('/subscriptions').ttl;
const courses = resolveEndpointTtl('/courses').ttl;

expect(subscriptions).toBe(THIRTY_SECONDS);
expect(courses).toBe(FIVE_MINUTES);
expect(subscriptions).not.toBe(courses);
});

it('never serves critical data past its TTL (staleTtl === ttl)', () => {
const { ttl, staleTtl } = ENDPOINT_TTL_MAP['/subscriptions'];
expect(staleTtl).toBe(ttl);
});

it('falls back to the global default for unconfigured endpoints', () => {
expect(resolveEndpointTtl('/quizzes/123/answers')).toEqual(DEFAULT_ENDPOINT_TTL);
});

it('normalizes URLs (api prefix, query string, nested paths)', () => {
expect(resolveEndpointTtl('/api/subscriptions?plan=pro').ttl).toBe(THIRTY_SECONDS);
expect(resolveEndpointTtl('api:/subscriptions/123').ttl).toBe(THIRTY_SECONDS);
expect(resolveEndpointTtl('https://x.test/api/courses').ttl).toBe(FIVE_MINUTES);
});
});

describe('Issue #597 — pattern-based invalidation', () => {
beforeEach(() => {
clearCache();
});

it('invalidatePattern removes only matching cache entries', () => {
setCache('api:/subscriptions', { plan: 'pro' }, THIRTY_SECONDS, THIRTY_SECONDS);
setCache('api:/courses', [{ id: '1' }], FIVE_MINUTES, FIVE_MINUTES);

const removed = invalidatePattern(/\/subscriptions/);

expect(removed).toBe(1);
expect(getCache('api:/subscriptions')).toBeNull();
expect(getCache('api:/courses')).not.toBeNull();
});

it('a POST to /subscriptions invalidates the /subscriptions GET cache', () => {
setCache('api:/subscriptions', { plan: 'free' }, THIRTY_SECONDS, THIRTY_SECONDS);

const rule = MUTATION_INVALIDATION_MAP.find(
r => r.methods.includes('POST') && r.urlPattern.test('/api/subscriptions')
);
expect(rule).toBeDefined();

// Mirror the axios response interceptor: run every invalidate pattern.
rule!.invalidatePatterns.forEach(pattern => invalidatePattern(pattern));

expect(getCache('api:/subscriptions')).toBeNull();
});
});
103 changes: 101 additions & 2 deletions src/config/apiCacheConfig.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,111 @@
/**
* Per-endpoint stale-while-revalidate TTL configuration (Issue #597).
*
* A single global TTL meant volatile data (balance, subscription status, active
* quiz sessions) shared the same freshness window as static catalog data, so a
* user could see a stale "Free" subscription status for minutes after upgrading.
*
* `ttl` is the freshness window (ms); `staleTtl` is the stale-while-revalidate
* window (ms) after which the entry is evicted entirely. For critical endpoints
* `staleTtl === ttl` so nothing older than `ttl` is ever served.
*/
export interface EndpointTtl {
/** Time (ms) before the entry is considered stale. */
ttl: number;
/** Time (ms) before the entry is evicted (stale-while-revalidate window). */
staleTtl: number;
}

const SECONDS = 1_000;
const MINUTES = 60 * SECONDS;

/** Global fallback used when an endpoint is not listed in {@link ENDPOINT_TTL_MAP}. */
export const DEFAULT_ENDPOINT_TTL: EndpointTtl = {
ttl: 60 * SECONDS,
staleTtl: 5 * MINUTES,
};

/** Critical, fast-changing endpoints: 30 s, with no stale window. */
const CRITICAL_TTL: EndpointTtl = { ttl: 30 * SECONDS, staleTtl: 30 * SECONDS };

/** Static, slow-changing endpoints: 5 min fresh, 10 min stale window. */
const STATIC_TTL: EndpointTtl = { ttl: 5 * MINUTES, staleTtl: 10 * MINUTES };

/**
* Maps a normalized endpoint path (prefix) to its TTL configuration.
* The most specific (longest) matching key wins; see {@link resolveEndpointTtl}.
*/
export const ENDPOINT_TTL_MAP: Record<string, EndpointTtl> = {
'/auth/me': CRITICAL_TTL,
'/subscriptions': CRITICAL_TTL,
'/payments': CRITICAL_TTL,
'/courses': STATIC_TTL,
'/categories': STATIC_TTL,
};

/**
* Normalize a request URL or cache key to a leading-slash path:
* strips an `api:` cache-key prefix, the origin, a query string, and an
* `/api` segment, so `api:/api/subscriptions?x=1` -> `/subscriptions`.
*/
function normalizeEndpointPath(urlOrKey: string): string {
let value = urlOrKey.replace(/^api:/, '');
// Drop query string or cache-key param suffix.
value = value.split('?')[0];

let path = value;
try {
path = new URL(value, 'https://teachlink.local').pathname;
} catch {
/* value was already a bare path */
}

path = path.replace(/^\/api(?=\/|$)/, '');
if (!path.startsWith('/')) {
path = `/${path}`;
}
return path.replace(/\/+$/, '') || '/';
}

/**
* Resolve the TTL configuration for a request URL (or cache key), falling back
* to {@link DEFAULT_ENDPOINT_TTL} when the endpoint is not configured.
*
* Matching is by longest path prefix, so `/subscriptions/123` inherits the
* `/subscriptions` configuration.
*/
export function resolveEndpointTtl(urlOrKey: string): EndpointTtl {
const path = normalizeEndpointPath(urlOrKey);

const match = Object.keys(ENDPOINT_TTL_MAP)
.filter(key => path === key || path.startsWith(`${key}/`))
.sort((a, b) => b.length - a.length)[0];

return match ? ENDPOINT_TTL_MAP[match] : DEFAULT_ENDPOINT_TTL;
}

/**
* Defines which cache keys to invalidate after a successful mutation.
* Keys are matched against the request URL using the provided RegExp patterns.
*/
export const MUTATION_INVALIDATION_MAP: Array<{
export const MUTATION_INVALIDATION_MAP: {
urlPattern: RegExp;
methods: string[];
invalidatePatterns: RegExp[];
}> = [
}[] = [
{
// A subscription change must immediately refresh subscription status and the
// /auth/me snapshot that embeds it (Issue #597).
urlPattern: /\/subscriptions(\/[^/]+)?$/,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
invalidatePatterns: [/\/subscriptions/, /\/auth\/me/],
},
{
// Payments can change subscription / entitlement state.
urlPattern: /\/payments(\/[^/]+)?$/,
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
invalidatePatterns: [/\/payments/, /\/subscriptions/, /\/auth\/me/],
},
{
urlPattern: /\/api\/courses\/[^/]+$/,
methods: ['PUT', 'PATCH', 'DELETE'],
Expand Down
13 changes: 13 additions & 0 deletions src/services/api/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,19 @@ export function invalidateByPattern(pattern: RegExp): number {
return removed;
}

/**
* Invalidate every cache entry whose key matches `urlPattern` (Issue #597).
*
* Public alias of {@link invalidateByPattern}, used by the axios response
* interceptor to drop related GET caches immediately after a successful
* mutation so critical data (e.g. subscription status) is never served stale.
*
* @returns the number of entries removed.
*/
export function invalidatePattern(urlPattern: RegExp): number {
return invalidateByPattern(urlPattern);
}

export function invalidateCacheByPrefix(prefix: string): number {
let removed = 0;

Expand Down
20 changes: 14 additions & 6 deletions src/services/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import apiClient from './axios.config';
import { fetchWithSWR } from './cache';
import { requestDeduplicator } from './requestDeduplicator';

const DEFAULT_TTL_MS = 60_000;
const DEFAULT_STALE_TTL_MS = 5 * 60_000;
import { resolveEndpointTtl } from '../../config/apiCacheConfig';

function buildRequestCacheKey(url: string, params?: unknown): string {
const serializedParams = params == null ? '' : JSON.stringify(params);
Expand All @@ -23,16 +21,19 @@ export const apiService = {
*/
get: <T = unknown>(url: string, params?: any) => {
const cacheKey = buildRequestCacheKey(url, params);
// Issue #597 — resolve the per-endpoint TTL (critical vs static), falling
// back to the global default for unconfigured endpoints.
const { ttl, staleTtl } = resolveEndpointTtl(url);
return requestDeduplicator.deduplicate<T>({ method: 'GET', url, params }, () =>
fetchWithSWR<T>(
cacheKey,
() => apiClient.get<T>(url, { params }).then(response => response.data as T),
DEFAULT_TTL_MS,
DEFAULT_STALE_TTL_MS,
ttl,
staleTtl,
{
dataType: 'api-read',
tags: [buildResourceTag(url)],
critical: false,
critical: ttl <= 30_000,
}
)
);
Expand All @@ -52,12 +53,19 @@ export {
getCacheStatus,
getRevalidatingCacheKeys,
invalidateCache,
invalidateByPattern,
invalidateCacheByPrefix,
invalidateCacheByTags,
invalidateCacheForBatchRequests,
invalidateCacheForMutation,
invalidatePattern,
resetCacheStats,
} from './cache';
export {
DEFAULT_ENDPOINT_TTL,
ENDPOINT_TTL_MAP,
resolveEndpointTtl,
} from '../../config/apiCacheConfig';
export { courseApi } from './courseApi';
export {
buildCursor,
Expand Down
Loading