From 9d6ff2ea61be3a1c511abd80ec0b2b04b616f1d5 Mon Sep 17 00:00:00 2001 From: spagero763 Date: Mon, 29 Jun 2026 16:30:48 +0100 Subject: [PATCH 1/5] fix(lint): use valid no-console rule form so ESLint 9 and the pre-commit hook run --- eslint.config.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 8e4d3e4..f86a3c1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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', }, }, -]); \ No newline at end of file +]); From f4381107180fefd45996d7063f00012c46b17e99 Mon Sep 17 00:00:00 2001 From: spagero763 Date: Mon, 29 Jun 2026 16:31:20 +0100 Subject: [PATCH 2/5] feat(cache): add per-endpoint TTL map and subscription/payment invalidation rules --- src/config/apiCacheConfig.ts | 103 ++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/src/config/apiCacheConfig.ts b/src/config/apiCacheConfig.ts index 5da8005..9009314 100644 --- a/src/config/apiCacheConfig.ts +++ b/src/config/apiCacheConfig.ts @@ -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 = { + '/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'], From f5b30c4cc839b7faf6f75819114c830854e60b2d Mon Sep 17 00:00:00 2001 From: spagero763 Date: Mon, 29 Jun 2026 16:31:27 +0100 Subject: [PATCH 3/5] feat(cache): add invalidatePattern method --- src/services/api/cache.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/services/api/cache.ts b/src/services/api/cache.ts index 5ee353a..193418f 100644 --- a/src/services/api/cache.ts +++ b/src/services/api/cache.ts @@ -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; From 196d049a06e9bb7512391f5f469e46da9f3d2dbe Mon Sep 17 00:00:00 2001 From: spagero763 Date: Mon, 29 Jun 2026 16:31:36 +0100 Subject: [PATCH 4/5] feat(api): apply per-endpoint TTL in apiService.get --- src/services/api/index.ts | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/services/api/index.ts b/src/services/api/index.ts index cd9a992..86f0348 100644 --- a/src/services/api/index.ts +++ b/src/services/api/index.ts @@ -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); @@ -23,16 +21,19 @@ export const apiService = { */ get: (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({ method: 'GET', url, params }, () => fetchWithSWR( cacheKey, () => apiClient.get(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, } ) ); @@ -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, From 098f30e877a28ce9b2dd0dceb7a2778df3d9bc4d Mon Sep 17 00:00:00 2001 From: spagero763 Date: Mon, 29 Jun 2026 16:31:48 +0100 Subject: [PATCH 5/5] test: per-endpoint TTL and pattern-based invalidation (#597) --- .../issues/fix_597_cache_ttl.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 src/__tests__/issues/fix_597_cache_ttl.test.ts diff --git a/src/__tests__/issues/fix_597_cache_ttl.test.ts b/src/__tests__/issues/fix_597_cache_ttl.test.ts new file mode 100644 index 0000000..0763e89 --- /dev/null +++ b/src/__tests__/issues/fix_597_cache_ttl.test.ts @@ -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(); + }); +});