Skip to content

Commit b520391

Browse files
amritbejAmrit
andauthored
feat: Redis profile cache and offline QR session tokens (#46) (#354)
- Cache public profiles in Redis under profile:<username> with 5-min TTL - Return X-Cache: HIT on cache hit, X-Cache: MISS on DB fetch - Add Cache-Control: public, max-age=300, stale-while-revalidate=60 to all public profile and QR session responses - Add GET /api/public/:username/qr-session returning a signed 10-minute JWT snapshot for offline QR use cases (spec section 5.9) - Invalidate profile cache in PUT /api/profiles/me immediately after a successful update so stale data is never served - Register publicRoutes under /api/public in addition to /api/u - Tests: cache HIT skips DB, MISS queries DB and writes cache, Redis error falls through to DB, qr-session token shape, payload contents, cache back-fill from qr-session DB path Closes #46 Signed-off-by: amritbej.sh <amritbej750@gmail.com> Co-authored-by: Amrit <amrit@example.com>
1 parent ab0ab6a commit b520391

4 files changed

Lines changed: 478 additions & 56 deletions

File tree

apps/backend/src/__tests__/public.test.ts

Lines changed: 246 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect, beforeEach, vi } from 'vitest';
22
import Fastify from 'fastify';
3+
import jwt from '@fastify/jwt';
34
import { publicRoutes } from '../routes/public.js';
45
import type { PrismaClient } from '@prisma/client';
56

@@ -41,13 +42,23 @@ const mockPrisma = {
4142
card: {} as any,
4243
};
4344

45+
// ── Redis mock ────────────────────────────────────────────────────────────────
46+
// Simulates ioredis behaviour: get returns null (MISS) by default.
47+
const mockRedis = {
48+
get: vi.fn().mockResolvedValue(null),
49+
set: vi.fn().mockResolvedValue('OK'),
50+
del: vi.fn().mockResolvedValue(1),
51+
};
52+
4453
async function buildApp() {
4554
const app = Fastify();
55+
// Register JWT so app.jwt.sign() is available for the qr-session route.
56+
// @fastify/jwt also adds request.jwtVerify(), which throws when no valid
57+
// Authorization header is present — matching the soft-auth pattern in the routes.
58+
await app.register(jwt, { secret: 'test-secret-for-unit-tests-only' });
4659
app.decorate('prisma', mockPrisma as unknown as PrismaClient);
47-
// Soft auth: jwtVerify rejects by default (unauthenticated visitor)
48-
app.decorateRequest('jwtVerify', async function () {
49-
throw new Error('no token');
50-
});
60+
// Decorate with the Redis mock so cache branches execute in tests.
61+
app.decorate('redis', mockRedis as any);
5162
app.register(publicRoutes, { prefix: '/api/public' });
5263
await app.ready();
5364
return app;
@@ -61,6 +72,8 @@ describe('GET /api/public/:username/qr — size validation', () => {
6172
// Re-attach default mock behaviour cleared by clearAllMocks
6273
(generateQRBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(Buffer.from('fake-png'));
6374
(generateQRSvg as ReturnType<typeof vi.fn>).mockResolvedValue('<svg>fake</svg>');
75+
mockRedis.get.mockResolvedValue(null);
76+
mockRedis.set.mockResolvedValue('OK');
6477
});
6578

6679
// ── Reject before DB touch ─────────────────────────────────────────────────
@@ -222,3 +235,232 @@ describe('GET /api/public/:username/qr — size validation', () => {
222235
expect(res.json().error).toBe('QR code generation failed');
223236
});
224237
});
238+
239+
// ─── Redis cache HIT / MISS behaviour ────────────────────────────────────────
240+
241+
describe('GET /api/public/:username — Redis cache', () => {
242+
beforeEach(() => {
243+
vi.clearAllMocks();
244+
mockRedis.get.mockResolvedValue(null);
245+
mockRedis.set.mockResolvedValue('OK');
246+
mockPrisma.followLog.findMany.mockResolvedValue([]);
247+
mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() });
248+
});
249+
250+
it('returns X-Cache: MISS and queries DB on first request', async () => {
251+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
252+
const app = await buildApp();
253+
254+
const res = await app.inject({
255+
method: 'GET',
256+
url: '/api/public/testuser',
257+
});
258+
259+
expect(res.statusCode).toBe(200);
260+
expect(res.headers['x-cache']).toBe('MISS');
261+
expect(res.headers['cache-control']).toBe('public, max-age=300, stale-while-revalidate=60');
262+
// DB was queried since Redis returned null
263+
expect(mockPrisma.user.findUnique).toHaveBeenCalledOnce();
264+
// Profile should be written to Redis after the DB fetch
265+
expect(mockRedis.set).toHaveBeenCalledWith(
266+
'profile:testuser',
267+
expect.any(String),
268+
'EX',
269+
300,
270+
);
271+
});
272+
273+
it('returns X-Cache: HIT and skips DB on cached request', async () => {
274+
// Simulate a warm cache entry
275+
const cached = JSON.stringify({
276+
_userId: 'user-123',
277+
username: 'testuser',
278+
displayName: 'Test User',
279+
bio: null,
280+
pronouns: null,
281+
role: null,
282+
company: null,
283+
avatarUrl: null,
284+
accentColor: '#ffffff',
285+
links: [],
286+
});
287+
mockRedis.get.mockResolvedValue(cached);
288+
289+
const app = await buildApp();
290+
const res = await app.inject({
291+
method: 'GET',
292+
url: '/api/public/testuser',
293+
});
294+
295+
expect(res.statusCode).toBe(200);
296+
expect(res.headers['x-cache']).toBe('HIT');
297+
// DB must NOT be queried when cache is warm
298+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
299+
});
300+
301+
it('response body on cache HIT matches the cached profile', async () => {
302+
const cached = JSON.stringify({
303+
_userId: 'user-123',
304+
username: 'testuser',
305+
displayName: 'Test User',
306+
bio: 'A bio',
307+
pronouns: null,
308+
role: 'Engineer',
309+
company: null,
310+
avatarUrl: null,
311+
accentColor: '#123456',
312+
links: [],
313+
});
314+
mockRedis.get.mockResolvedValue(cached);
315+
316+
const app = await buildApp();
317+
const res = await app.inject({ method: 'GET', url: '/api/public/testuser' });
318+
const body = res.json();
319+
320+
expect(body.username).toBe('testuser');
321+
expect(body.accentColor).toBe('#123456');
322+
// Internal _userId field must not leak into the HTTP response
323+
expect(body._userId).toBeUndefined();
324+
});
325+
326+
it('falls through to DB when Redis.get throws', async () => {
327+
mockRedis.get.mockRejectedValue(new Error('Redis down'));
328+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
329+
330+
const app = await buildApp();
331+
const res = await app.inject({ method: 'GET', url: '/api/public/testuser' });
332+
333+
expect(res.statusCode).toBe(200);
334+
// DB was reached despite the Redis failure
335+
expect(mockPrisma.user.findUnique).toHaveBeenCalledOnce();
336+
});
337+
338+
it('returns 404 when user does not exist (cache MISS)', async () => {
339+
mockPrisma.user.findUnique.mockResolvedValue(null);
340+
341+
const app = await buildApp();
342+
const res = await app.inject({ method: 'GET', url: '/api/public/nobody' });
343+
344+
expect(res.statusCode).toBe(404);
345+
expect(res.json().error).toBe('User not found');
346+
});
347+
});
348+
349+
// ─── QR session endpoint ──────────────────────────────────────────────────────
350+
351+
describe('GET /api/public/:username/qr-session', () => {
352+
beforeEach(() => {
353+
vi.clearAllMocks();
354+
mockRedis.get.mockResolvedValue(null);
355+
mockRedis.set.mockResolvedValue('OK');
356+
});
357+
358+
it('returns 404 when the user does not exist', async () => {
359+
mockPrisma.user.findUnique.mockResolvedValue(null);
360+
361+
const app = await buildApp();
362+
const res = await app.inject({
363+
method: 'GET',
364+
url: '/api/public/nobody/qr-session',
365+
});
366+
367+
expect(res.statusCode).toBe(404);
368+
expect(res.json().error).toBe('User not found');
369+
});
370+
371+
it('returns a JWT token with correct shape on DB fetch (cache MISS)', async () => {
372+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
373+
374+
const app = await buildApp();
375+
const res = await app.inject({
376+
method: 'GET',
377+
url: '/api/public/testuser/qr-session',
378+
});
379+
380+
expect(res.statusCode).toBe(200);
381+
const body = res.json();
382+
expect(typeof body.token).toBe('string');
383+
expect(body.tokenType).toBe('JWT');
384+
expect(body.expiresIn).toBe(600);
385+
expect(typeof body.expiresAt).toBe('string');
386+
// expiresAt must be a valid ISO 8601 date string
387+
expect(new Date(body.expiresAt).getTime()).toBeGreaterThan(Date.now());
388+
});
389+
390+
it('token payload encodes the public profile snapshot', async () => {
391+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
392+
393+
const app = await buildApp();
394+
const res = await app.inject({
395+
method: 'GET',
396+
url: '/api/public/testuser/qr-session',
397+
});
398+
399+
const { token } = res.json();
400+
// Decode without verifying so we can inspect the payload in the test
401+
const decoded = JSON.parse(
402+
Buffer.from(token.split('.')[1], 'base64url').toString(),
403+
);
404+
expect(decoded.sub).toBe('testuser');
405+
expect(decoded.profile.username).toBe('testuser');
406+
expect(decoded.profile.displayName).toBe('Test User');
407+
});
408+
409+
it('serves snapshot from Redis cache without querying DB', async () => {
410+
const cached = JSON.stringify({
411+
_userId: 'user-123',
412+
username: 'testuser',
413+
displayName: 'Cached User',
414+
bio: null,
415+
pronouns: null,
416+
role: null,
417+
company: null,
418+
avatarUrl: null,
419+
accentColor: '#ffffff',
420+
links: [],
421+
});
422+
mockRedis.get.mockResolvedValue(cached);
423+
424+
const app = await buildApp();
425+
const res = await app.inject({
426+
method: 'GET',
427+
url: '/api/public/testuser/qr-session',
428+
});
429+
430+
expect(res.statusCode).toBe(200);
431+
// DB must not be reached when the cache is warm
432+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
433+
434+
const { token } = res.json();
435+
const decoded = JSON.parse(
436+
Buffer.from(token.split('.')[1], 'base64url').toString(),
437+
);
438+
expect(decoded.profile.displayName).toBe('Cached User');
439+
});
440+
441+
it('includes Cache-Control header in qr-session response', async () => {
442+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
443+
444+
const app = await buildApp();
445+
const res = await app.inject({
446+
method: 'GET',
447+
url: '/api/public/testuser/qr-session',
448+
});
449+
450+
expect(res.headers['cache-control']).toBe('public, max-age=300, stale-while-revalidate=60');
451+
});
452+
453+
it('caches the profile in Redis when served from DB', async () => {
454+
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
455+
456+
const app = await buildApp();
457+
await app.inject({ method: 'GET', url: '/api/public/testuser/qr-session' });
458+
459+
expect(mockRedis.set).toHaveBeenCalledWith(
460+
'profile:testuser',
461+
expect.any(String),
462+
'EX',
463+
300,
464+
);
465+
});
466+
});

apps/backend/src/app.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export async function buildApp():Promise<FastifyInstance> {
101101
await app.register(profileRoutes, { prefix: '/api/profiles' });
102102
await app.register(cardRoutes, { prefix: '/api/cards' });
103103
await app.register(publicRoutes, { prefix: '/api/u' });
104+
await app.register(publicRoutes, { prefix: '/api/public' });
104105
await app.register(followRoutes, { prefix: '/api/follow' });
105106
await app.register(connectRoutes, { prefix: '/api/connect' });
106107
await app.register(analyticsRoutes, { prefix: '/api/analytics' });

apps/backend/src/routes/profiles.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
createLinkSchema,
66
reorderLinksSchema,
77
} from '../utils/validators.js';
8+
import { getErrorMessage } from '../utils/error.util.js';
89

910
// ── Response types ────────────────────────────────────────────────────────────
1011
// Declared explicitly so the API contract is visible without tracing through
@@ -84,6 +85,13 @@ export async function profileRoutes(app: FastifyInstance) {
8485
}
8586
}
8687

88+
// Fetch current username before the update so we can invalidate the correct
89+
// Redis cache key even if the username is being changed in this request.
90+
const currentUser = await app.prisma.user.findUnique({
91+
where: { id: userId },
92+
select: { username: true },
93+
});
94+
8795
try {
8896
const response: ProfileUpdateResponse = await app.prisma.user.update({
8997
where: { id: userId },
@@ -102,6 +110,17 @@ export async function profileRoutes(app: FastifyInstance) {
102110
},
103111
});
104112

113+
// Invalidate the public profile cache so stale data is not served after
114+
// an update. Fire-and-forget — a cache miss on the next request is
115+
// preferable to blocking the response on a Redis round-trip.
116+
if (app.redis && currentUser) {
117+
app.redis
118+
.del(`profile:${currentUser.username}`)
119+
.catch((err: unknown) =>
120+
app.log.warn(`Failed to invalidate profile cache: ${getErrorMessage(err)}`)
121+
);
122+
}
123+
105124
return response;
106125
} catch (error: any) {
107126
// Unique constraint violation — two concurrent requests raced through the

0 commit comments

Comments
 (0)