diff --git a/apps/backend/src/__tests__/public-profile-cache.test.ts b/apps/backend/src/__tests__/public-profile-cache.test.ts new file mode 100644 index 00000000..288a345e --- /dev/null +++ b/apps/backend/src/__tests__/public-profile-cache.test.ts @@ -0,0 +1,671 @@ +/** + * public-profile-cache.test.ts + * + * Verifies that the public profile caching layer correctly separates shared + * profile data (safe to cache) from viewer-specific follow state (computed + * per-request). The core invariant: + * + * cache entry = shared data only, never contains `followed` state + * response = shared data + viewer-specific `followed` merged in at read time + * + * Test categories: + * - Cache population (MISS → DB → Redis write) + * - Cache hit behaviour (HIT → Redis read → follow state computed fresh) + * - Viewer isolation (Viewer A ≠ Viewer B) + * - Anonymous requests (viewerId = null) + * - Cache integrity (stored bytes never contain viewer data) + * - Follow state correctness after cache warm-up + * - Regression: existing profile/cache/follow paths still work + */ + +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { describe, it, expect, beforeEach, vi } from 'vitest' + +import { publicRoutes } from '../routes/public.js' + +import type { PrismaClient } from '@prisma/client' + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const VIEWER_A = 'viewer-aaa' +const VIEWER_B = 'viewer-bbb' +const PROFILE_OWNER = 'owner-111' + +const LINK_GITHUB = { + id: 'link-gh', + platform: 'github', + username: 'octocat', + url: 'https://github.com/octocat', + displayOrder: 0, +} + +const LINK_TWITTER = { + id: 'link-tw', + platform: 'twitter', + username: 'octocat', + url: 'https://twitter.com/octocat', + displayOrder: 1, +} + +const mockUser = { + id: PROFILE_OWNER, + username: 'octocat', + displayName: 'The Octocat', + bio: 'GitHub mascot', + pronouns: null, + role: 'Mascot', + company: 'GitHub', + avatarUrl: 'https://github.com/images/mona-loading-default.gif', + accentColor: '#24292e', + platformLinks: [LINK_GITHUB, LINK_TWITTER], +} + +const mockUserNoLinks = { ...mockUser, platformLinks: [] } + +// Shared profile data as it should appear in the Redis cache. +// Note: links have NO `followed` field. +const expectedCacheEntry = { + _userId: PROFILE_OWNER, + username: 'octocat', + displayName: 'The Octocat', + bio: 'GitHub mascot', + pronouns: null, + role: 'Mascot', + company: 'GitHub', + avatarUrl: 'https://github.com/images/mona-loading-default.gif', + accentColor: '#24292e', + links: [ + { id: 'link-gh', platform: 'github', username: 'octocat', url: 'https://github.com/octocat', displayOrder: 0 }, + { id: 'link-tw', platform: 'twitter', username: 'octocat', url: 'https://twitter.com/octocat', displayOrder: 1 }, + ], +} + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockPrisma = { + user: { findUnique: vi.fn() }, + platformLink: {} as any, + cardView: { create: vi.fn().mockReturnValue({ catch: vi.fn() }) }, + followLog: { findMany: vi.fn().mockResolvedValue([]) }, + card: {} as any, +} + +const mockRedis = { + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue('OK'), + del: vi.fn().mockResolvedValue(1), +} + +async function buildApp() { + const app = Fastify() + await app.register(jwt, { secret: 'test-secret-unit' }) + app.decorate('prisma', mockPrisma as unknown as PrismaClient) + app.decorate('redis', mockRedis as any) + app.register(publicRoutes, { prefix: '/api/public' }) + await app.ready() + return app +} + +// Helper: sign a JWT for a given userId so inject() can carry auth headers +function makeAuthHeader(app: Awaited>, userId: string) { + const token = app.jwt.sign({ id: userId }) + return { authorization: `Bearer ${token}` } +} + +// Helper: encode a warm-cache entry as a JSON string the way Redis stores it +function warmCache(entry: object) { + mockRedis.get.mockResolvedValue(JSON.stringify(entry)) +} + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Cache population — MISS path writes shared-only data +// ───────────────────────────────────────────────────────────────────────────── + +describe('Cache population (MISS → DB → Redis write)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.get.mockResolvedValue(null) + mockRedis.set.mockResolvedValue('OK') + mockPrisma.followLog.findMany.mockResolvedValue([]) + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('writes the profile to Redis on cache MISS', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(mockRedis.set).toHaveBeenCalledWith('profile:octocat', expect.any(String), 'EX', 300) + }) + + it('cache entry contains no `followed` field on any link', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + + const [, rawJson] = mockRedis.set.mock.calls[0] + const stored = JSON.parse(rawJson) + for (const link of stored.links) { + expect(link).not.toHaveProperty('followed') + } + }) + + it('cache entry contains viewer-independent link shape', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + + // Even when a viewer who follows github is logged in, the cache must not + // contain their follow state. + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const headers = makeAuthHeader(app, VIEWER_A) + await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const [, rawJson] = mockRedis.set.mock.calls[0] + const stored = JSON.parse(rawJson) + for (const link of stored.links) { + expect(link).not.toHaveProperty('followed') + } + }) + + it('returns X-Cache: MISS on first request', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.headers['x-cache']).toBe('MISS') + }) + + it('queries DB on cache MISS', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(mockPrisma.user.findUnique).toHaveBeenCalledOnce() + }) + + it('stores _userId in cache for view-tracking but never exposes it in HTTP response', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + + // _userId must be in cache + const [, rawJson] = mockRedis.set.mock.calls[0] + const stored = JSON.parse(rawJson) + expect(stored._userId).toBe(PROFILE_OWNER) + + // _userId must NOT appear in the HTTP response + expect(res.json()).not.toHaveProperty('_userId') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Cache hit behaviour — HIT path computes follow state fresh +// ───────────────────────────────────────────────────────────────────────────── + +describe('Cache hit behaviour (HIT → Redis read → follow state computed fresh)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.set.mockResolvedValue('OK') + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('returns X-Cache: HIT when the cache is warm', async () => { + warmCache(expectedCacheEntry) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.headers['x-cache']).toBe('HIT') + }) + + it('does not query the DB on cache HIT', async () => { + warmCache(expectedCacheEntry) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled() + }) + + it('queries followLog for authenticated viewer even on cache HIT', async () => { + warmCache(expectedCacheEntry) + mockPrisma.followLog.findMany.mockResolvedValue([]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + expect(mockPrisma.followLog.findMany).toHaveBeenCalledOnce() + }) + + it('does not query followLog for anonymous request on cache HIT', async () => { + warmCache(expectedCacheEntry) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(mockPrisma.followLog.findMany).not.toHaveBeenCalled() + }) + + it('_userId is stripped from cache HIT HTTP response', async () => { + warmCache(expectedCacheEntry) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.json()).not.toHaveProperty('_userId') + }) + + it('profile fields from cache are returned correctly on HIT', async () => { + warmCache(expectedCacheEntry) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + const body = res.json() + expect(body.username).toBe('octocat') + expect(body.displayName).toBe('The Octocat') + expect(body.accentColor).toBe('#24292e') + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Viewer isolation — same cache, different follow state per viewer +// ───────────────────────────────────────────────────────────────────────────── + +describe('Viewer isolation — same cached profile, viewer-specific follow state', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.set.mockResolvedValue('OK') + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('Viewer A (follows github) sees followed=true for github link', async () => { + warmCache(expectedCacheEntry) + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + const githubLink = links.find((l) => l.id === 'link-gh') + const twitterLink = links.find((l) => l.id === 'link-tw') + expect(githubLink?.followed).toBe(true) + expect(twitterLink?.followed).toBe(false) + }) + + it('Viewer B (follows nothing) sees followed=false for all links', async () => { + warmCache(expectedCacheEntry) + mockPrisma.followLog.findMany.mockResolvedValue([]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_B) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + expect(links.every((l) => l.followed === false)).toBe(true) + }) + + it('Viewer A and Viewer B receive different follow states from the same cached entry', async () => { + // Both requests share the same warm cache entry (set once before both requests). + warmCache(expectedCacheEntry) + const app = await buildApp() + + // Viewer A follows github + mockPrisma.followLog.findMany.mockResolvedValueOnce([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const resA = await app.inject({ + method: 'GET', + url: '/api/public/octocat', + headers: makeAuthHeader(app, VIEWER_A), + }) + + // Viewer B follows nothing + mockPrisma.followLog.findMany.mockResolvedValueOnce([]) + const resB = await app.inject({ + method: 'GET', + url: '/api/public/octocat', + headers: makeAuthHeader(app, VIEWER_B), + }) + + // DB must not have been consulted for the profile on either request + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled() + + const linksA = resA.json().links as Array<{ id: string; followed: boolean }> + const linksB = resB.json().links as Array<{ id: string; followed: boolean }> + + const ghA = linksA.find((l) => l.id === 'link-gh') + const ghB = linksB.find((l) => l.id === 'link-gh') + + expect(ghA?.followed).toBe(true) + expect(ghB?.followed).toBe(false) + }) + + it('Viewer A following both links sees followed=true for both', async () => { + warmCache(expectedCacheEntry) + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + { platform: 'twitter', targetUsername: 'octocat' }, + ]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + expect(links.every((l) => l.followed === true)).toBe(true) + }) + + it('follow state is case-insensitive on targetUsername comparison', async () => { + warmCache(expectedCacheEntry) + // followLog stores the username with different casing + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'OCTOCAT' }, + ]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + const ghLink = links.find((l) => l.id === 'link-gh') + expect(ghLink?.followed).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Anonymous requests +// ───────────────────────────────────────────────────────────────────────────── + +describe('Anonymous requests (no authentication)', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.set.mockResolvedValue('OK') + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('anonymous request on cache MISS returns followed=false for all links', async () => { + mockRedis.get.mockResolvedValue(null) + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + + const links = res.json().links as Array<{ followed: boolean }> + expect(links.every((l) => l.followed === false)).toBe(true) + }) + + it('anonymous request on cache HIT returns followed=false for all links', async () => { + warmCache(expectedCacheEntry) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + + const links = res.json().links as Array<{ followed: boolean }> + expect(links.every((l) => l.followed === false)).toBe(true) + }) + + it('anonymous request does not call followLog', async () => { + mockRedis.get.mockResolvedValue(null) + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(mockPrisma.followLog.findMany).not.toHaveBeenCalled() + }) + + it('anonymous request still populates cache', async () => { + mockRedis.get.mockResolvedValue(null) + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(mockRedis.set).toHaveBeenCalledWith('profile:octocat', expect.any(String), 'EX', 300) + }) + + it('anonymous request on profile with no links returns empty links array', async () => { + mockRedis.get.mockResolvedValue(null) + mockPrisma.user.findUnique.mockResolvedValue(mockUserNoLinks) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.json().links).toEqual([]) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Cache integrity — stored bytes never contain viewer data +// ───────────────────────────────────────────────────────────────────────────── + +describe('Cache integrity — stored bytes never contain viewer state', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.get.mockResolvedValue(null) + mockRedis.set.mockResolvedValue('OK') + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('cache entry is identical regardless of which viewer triggered population', async () => { + // First request from Viewer A who follows github + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const appA = await buildApp() + const headersA = makeAuthHeader(appA, VIEWER_A) + await appA.inject({ method: 'GET', url: '/api/public/octocat', headers: headersA }) + const storedByA = mockRedis.set.mock.calls[0][1] + + vi.clearAllMocks() + mockRedis.get.mockResolvedValue(null) + mockRedis.set.mockResolvedValue('OK') + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + + // Second request from Viewer B who follows nothing + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + mockPrisma.followLog.findMany.mockResolvedValue([]) + const appB = await buildApp() + const headersB = makeAuthHeader(appB, VIEWER_B) + await appB.inject({ method: 'GET', url: '/api/public/octocat', headers: headersB }) + const storedByB = mockRedis.set.mock.calls[0][1] + + // The raw JSON bytes written to Redis must be identical. + expect(storedByA).toBe(storedByB) + }) + + it('cache entry populated by authenticated viewer has no `followed` field', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const [, rawJson] = mockRedis.set.mock.calls[0] + const stored = JSON.parse(rawJson) + for (const link of stored.links) { + expect(link).not.toHaveProperty('followed') + } + }) + + it('cache entry populated by anonymous request has no `followed` field', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + + const [, rawJson] = mockRedis.set.mock.calls[0] + const stored = JSON.parse(rawJson) + for (const link of stored.links) { + expect(link).not.toHaveProperty('followed') + } + }) + + it('cache entry link shape contains only shared fields', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + await app.inject({ method: 'GET', url: '/api/public/octocat' }) + + const [, rawJson] = mockRedis.set.mock.calls[0] + const stored = JSON.parse(rawJson) + const sharedKeys = new Set(['id', 'platform', 'username', 'url', 'displayOrder']) + for (const link of stored.links) { + const keys = new Set(Object.keys(link)) + for (const k of keys) { + expect(sharedKeys.has(k)).toBe(true) + } + } + }) + + it('Viewer A follow state does not bleed into subsequent Viewer B response', async () => { + // Step 1: Viewer A warms the cache (follows github) + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + mockPrisma.followLog.findMany.mockResolvedValueOnce([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const app = await buildApp() + const headersA = makeAuthHeader(app, VIEWER_A) + await app.inject({ method: 'GET', url: '/api/public/octocat', headers: headersA }) + + // Step 2: Viewer B requests same profile — cache is now warm + const storedJson = mockRedis.set.mock.calls[0][1] + mockRedis.get.mockResolvedValue(storedJson) + mockPrisma.followLog.findMany.mockResolvedValueOnce([]) // Viewer B follows nothing + + const headersB = makeAuthHeader(app, VIEWER_B) + const resB = await app.inject({ method: 'GET', url: '/api/public/octocat', headers: headersB }) + + const linksB = resB.json().links as Array<{ id: string; followed: boolean }> + expect(linksB.every((l) => l.followed === false)).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Follow state correctness after cache warm-up +// ───────────────────────────────────────────────────────────────────────────── + +describe('Follow state correctness after cache warm-up', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.set.mockResolvedValue('OK') + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('returns correct follow state for viewer who just followed while cache was warm', async () => { + // Warm cache was populated while viewer had NOT yet followed + warmCache(expectedCacheEntry) + // Now viewer has followed github — followLog returns it + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + ]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + const gh = links.find((l) => l.id === 'link-gh') + expect(gh?.followed).toBe(true) + // DB profile query was skipped (cache HIT) + expect(mockPrisma.user.findUnique).not.toHaveBeenCalled() + }) + + it('returns correct follow state for viewer who just unfollowed while cache was warm', async () => { + warmCache(expectedCacheEntry) + // Viewer has unfollowed — followLog returns empty + mockPrisma.followLog.findMany.mockResolvedValue([]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + expect(links.every((l) => l.followed === false)).toBe(true) + }) + + it('cache HIT with follows returns all followed links correctly', async () => { + warmCache(expectedCacheEntry) + mockPrisma.followLog.findMany.mockResolvedValue([ + { platform: 'github', targetUsername: 'octocat' }, + { platform: 'twitter', targetUsername: 'octocat' }, + ]) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + const res = await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + + const links = res.json().links as Array<{ id: string; followed: boolean }> + expect(links.find((l) => l.id === 'link-gh')?.followed).toBe(true) + expect(links.find((l) => l.id === 'link-tw')?.followed).toBe(true) + }) +}) + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Regression — existing profile / cache / follow behaviour +// ───────────────────────────────────────────────────────────────────────────── + +describe('Regression — existing public profile behaviour', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRedis.get.mockResolvedValue(null) + mockRedis.set.mockResolvedValue('OK') + mockPrisma.followLog.findMany.mockResolvedValue([]) + mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() }) + }) + + it('returns 404 for unknown user on cache MISS', async () => { + mockPrisma.user.findUnique.mockResolvedValue(null) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/nobody' }) + expect(res.statusCode).toBe(404) + expect(res.json().error).toBe('User not found') + }) + + it('returns 200 with correct profile shape on cache MISS', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.statusCode).toBe(200) + const body = res.json() + expect(body.username).toBe('octocat') + expect(body.displayName).toBe('The Octocat') + expect(Array.isArray(body.links)).toBe(true) + expect(body.links).toHaveLength(2) + }) + + it('does not set Cache-Control: private on public profile responses', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.headers['cache-control']).toContain('public') + }) + + it('falls through to DB when Redis.get throws', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis unavailable')) + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.statusCode).toBe(200) + expect(mockPrisma.user.findUnique).toHaveBeenCalledOnce() + }) + + it('profile with no links returns empty links array', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUserNoLinks) + const app = await buildApp() + const res = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(res.json().links).toEqual([]) + }) + + it('profile with no links skips followLog query on cache MISS', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUserNoLinks) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + expect(mockPrisma.followLog.findMany).not.toHaveBeenCalled() + }) + + it('profile with no links skips followLog query on cache HIT', async () => { + const emptyLinkEntry = { ...expectedCacheEntry, links: [] } + warmCache(emptyLinkEntry) + const app = await buildApp() + const headers = makeAuthHeader(app, VIEWER_A) + await app.inject({ method: 'GET', url: '/api/public/octocat', headers }) + expect(mockPrisma.followLog.findMany).not.toHaveBeenCalled() + }) + + it('X-Cache header is MISS on first request and HIT after cache is populated', async () => { + mockPrisma.user.findUnique.mockResolvedValue(mockUser) + const app = await buildApp() + + const firstRes = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(firstRes.headers['x-cache']).toBe('MISS') + + // Simulate cache now warm + const stored = mockRedis.set.mock.calls[0][1] + mockRedis.get.mockResolvedValue(stored) + mockPrisma.followLog.findMany.mockResolvedValue([]) + + const secondRes = await app.inject({ method: 'GET', url: '/api/public/octocat' }) + expect(secondRes.headers['x-cache']).toBe('HIT') + }) +}) diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..423bfe79 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,67 +1,279 @@ -import type { FastifyInstance } from 'fastify' import { getErrorMessage } from '../utils/error.util.js' +import type { FastifyInstance } from 'fastify' + const PROFILE_CACHE_TTL = 300 const CACHE_CONTROL_HEADER = 'public, max-age=300, stale-while-revalidate=60' -export async function getPublicProfile(app: FastifyInstance, username: string, viewerId: string | null, request: any) { +// ── Cache shape ──────────────────────────────────────────────────────────────── +// The cache stores ONLY viewer-independent data. The `followed` field is +// intentionally absent from cached links — it is computed fresh for every +// request after the cache is read. `_userId` is stored internally so that +// background view-tracking can fire on cache-HIT requests without an extra +// DB round-trip. + +type CachedLink = { + id: string + platform: string + username: string + url: string + displayOrder: number +} + +type CachedProfileEntry = { + _userId: string + username: string + displayName: string + bio: string | null + pronouns: string | null + role: string | null + company: string | null + avatarUrl: string | null + accentColor: string + links: CachedLink[] +} + +// ── Helper: viewer-specific follow state ────────────────────────────────────── +// Queries which of the given links have been followed by `viewerId`. +// Returns an empty array for anonymous requests (viewerId === null) and when +// the profile has no links so the DB round-trip can be skipped entirely. + +async function getFollowedLinkIds( + app: FastifyInstance, + links: CachedLink[], + viewerId: string, +): Promise { + if (links.length === 0) { return [] } + + const successfulFollows = await app.prisma.followLog.findMany({ + where: { + followerId: viewerId, + status: 'success', + OR: links.map((link) => ({ + platform: link.platform, + targetUsername: link.username, + })), + }, + select: { platform: true, targetUsername: true }, + }) + + return links + .filter((link) => + successfulFollows.some( + (f: any) => + f.platform === link.platform && + f.targetUsername.toLowerCase() === link.username.toLowerCase(), + ), + ) + .map((link) => link.id) +} + +// ── Public: getPublicProfile ────────────────────────────────────────────────── +// Architecture: +// 1. Read shared profile data from Redis (or DB on cache miss). +// 2. Write shared-only data to Redis on cache miss. +// 3. Compute viewer-specific follow state AFTER reading from cache. +// 4. Merge follow state into the response before returning. +// +// This ensures the cached entry is identical regardless of which viewer +// triggered the cache population, and that every viewer always receives +// accurate follow status even when the cache is warm. + +export async function getPublicProfile( + app: FastifyInstance, + username: string, + viewerId: string | null, + request: any, +) { const cacheKey = `profile:${username}` + // ── Step 1: try cache ────────────────────────────────────────────────────── + let cachedEntry: CachedProfileEntry | null = null + if (app.redis) { try { - const cached = await app.redis.get(cacheKey) - if (cached) { - const { _userId, ...profileData } = JSON.parse(cached) - if (viewerId && viewerId !== _userId) { - app.prisma.cardView.create({ data: { ownerId: _userId, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)) - } - return { cached: true, data: profileData, cacheKey } + const raw = await app.redis.get(cacheKey) + if (raw) { + cachedEntry = JSON.parse(raw) as CachedProfileEntry } } catch (err) { app.log.warn(`Redis cache read failed for ${cacheKey}: ${getErrorMessage(err)}`) } } - const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + // ── Cache HIT ────────────────────────────────────────────────────────────── + if (cachedEntry !== null) { + const { _userId, ...sharedData } = cachedEntry - if (viewerId && viewerId !== user.id) { - app.prisma.cardView.create({ data: { ownerId: user.id, cardId: null, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) + // Background view tracking — still fires on cache hit without a DB read. + if (viewerId && viewerId !== _userId) { + app.prisma.cardView + .create({ + data: { + ownerId: _userId, + cardId: null, + viewerId, + viewerIp: request.ip ?? null, + viewerAgent: request.headers['user-agent'] ?? null, + source: (request.query as any)?.source ?? 'link', + }, + }) + .catch((err: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(err)}`)) + } + + // Compute viewer-specific follow state against the cached link list. + const followedLinkIds = + viewerId ? await getFollowedLinkIds(app, sharedData.links, viewerId) : [] + + const response = { + ...sharedData, + links: sharedData.links.map((link) => ({ + ...link, + followed: followedLinkIds.includes(link.id), + })), + } + + return { cached: true, data: response, cacheKey } } - let followedLinkIds: string[] = [] - if (viewerId && user.platformLinks.length > 0) { - const successfulFollows = await app.prisma.followLog.findMany({ where: { followerId: viewerId, status: 'success', OR: user.platformLinks.map((link: any) => ({ platform: link.platform, targetUsername: link.username })) }, select: { platform: true, targetUsername: true } }) - followedLinkIds = user.platformLinks.filter((link: any) => successfulFollows.some((f: any) => f.platform === link.platform && f.targetUsername.toLowerCase() === link.username.toLowerCase())).map((l: any) => l.id) + // ── Cache MISS: fetch from DB ────────────────────────────────────────────── + const user = await app.prisma.user.findUnique({ + where: { username }, + include: { platformLinks: { orderBy: { displayOrder: 'asc' } } }, + }) + if (!user) { return null } + + if (viewerId && viewerId !== user.id) { + app.prisma.cardView + .create({ + data: { + ownerId: user.id, + cardId: null, + viewerId, + viewerIp: request.ip ?? null, + viewerAgent: request.headers['user-agent'] ?? null, + source: (request.query as any)?.source ?? 'link', + }, + }) + .catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) } - const baseLinks = user.platformLinks.map((link: any) => ({ id: link.id, platform: link.platform, username: link.username, url: link.url, displayOrder: link.displayOrder, followed: false })) + // Build viewer-independent link list — NO `followed` field. + const sharedLinks: CachedLink[] = user.platformLinks.map((link: any) => ({ + id: link.id, + platform: link.platform, + username: link.username, + url: link.url, + displayOrder: link.displayOrder, + })) + // ── Step 2: populate cache with shared-only data ─────────────────────────── if (app.redis) { - const entry = { _userId: user.id, username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor, links: baseLinks } - app.redis.set(cacheKey, JSON.stringify(entry), 'EX', PROFILE_CACHE_TTL).catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)) + const entry: CachedProfileEntry = { + _userId: user.id, + username: user.username, + displayName: user.displayName, + bio: user.bio, + pronouns: user.pronouns, + role: user.role, + company: user.company, + avatarUrl: user.avatarUrl, + accentColor: user.accentColor, + links: sharedLinks, + } + app.redis + .set(cacheKey, JSON.stringify(entry), 'EX', PROFILE_CACHE_TTL) + .catch((err: unknown) => + app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`), + ) } - const response = { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor, links: baseLinks.map((link) => ({ ...link, followed: followedLinkIds.includes(link.id) })) } + // ── Step 3: compute viewer-specific follow state ─────────────────────────── + const followedLinkIds = + viewerId ? await getFollowedLinkIds(app, sharedLinks, viewerId) : [] + + const response = { + username: user.username, + displayName: user.displayName, + bio: user.bio, + pronouns: user.pronouns, + role: user.role, + company: user.company, + avatarUrl: user.avatarUrl, + accentColor: user.accentColor, + links: sharedLinks.map((link) => ({ + ...link, + followed: followedLinkIds.includes(link.id), + })), + } return { cached: false, data: response, cacheKey } } export async function getCardById(app: FastifyInstance, cardId: string) { - const card = await app.prisma.card.findUnique({ where: { id: cardId }, include: { user: true, cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) + const card = await app.prisma.card.findUnique({ + where: { id: cardId }, + include: { + user: true, + cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } }, + }, + }) return card } -export async function getUserCard(app: FastifyInstance, username: string, cardId: string, viewerId: string | null, request: any) { +export async function getUserCard( + app: FastifyInstance, + username: string, + cardId: string, + viewerId: string | null, + request: any, +) { const user = await app.prisma.user.findUnique({ where: { username } }) - if (!user) return { notFound: true } - const card = await app.prisma.card.findFirst({ where: { id: cardId, userId: user.id }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } } }) - if (!card) return { notFound: true } + if (!user) { return { notFound: true } } + const card = await app.prisma.card.findFirst({ + where: { id: cardId, userId: user.id }, + include: { + cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } }, + }, + }) + if (!card) { return { notFound: true } } if (viewerId && viewerId !== user.id) { - app.prisma.cardView.create({ data: { ownerId: user.id, cardId: card.id, viewerId, viewerIp: request.ip || null, viewerAgent: request.headers['user-agent'] || null, source: request.query?.source || 'qr' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) + app.prisma.cardView + .create({ + data: { + ownerId: user.id, + cardId: card.id, + viewerId, + viewerIp: request.ip ?? null, + viewerAgent: request.headers['user-agent'] ?? null, + source: (request.query as any)?.source ?? 'qr', + }, + }) + .catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) } - const response = { title: card.title, owner: { username: user.username, displayName: user.displayName, bio: user.bio, pronouns: user.pronouns, role: user.role, company: user.company, avatarUrl: user.avatarUrl, accentColor: user.accentColor }, links: card.cardLinks.map((cl: any) => ({ id: cl.platformLink.id, platform: cl.platformLink.platform, username: cl.platformLink.username, url: cl.platformLink.url, displayOrder: cl.displayOrder })) } + const response = { + title: card.title, + owner: { + username: user.username, + displayName: user.displayName, + bio: user.bio, + pronouns: user.pronouns, + role: user.role, + company: user.company, + avatarUrl: user.avatarUrl, + accentColor: user.accentColor, + }, + links: card.cardLinks.map((cl: any) => ({ + id: cl.platformLink.id, + platform: cl.platformLink.platform, + username: cl.platformLink.username, + url: cl.platformLink.url, + displayOrder: cl.displayOrder, + })), + } return { notFound: false, data: response } } + +export { CACHE_CONTROL_HEADER }