From 82a256cd726e9f8572064cf06efa2643c9b773e0 Mon Sep 17 00:00:00 2001 From: SOMAPURAM UDAY Date: Fri, 12 Jun 2026 13:21:35 +0530 Subject: [PATCH 1/3] feat: implement GitHub platform auto-discovery from GitHub API (#506) * feat: add GitHub platform autodiscovery * fix: resolve lint and type issues in autodiscovery * fix: restore reply parameter in github autodiscovery route * fix: resolve unused reply lint issue --- apps/backend/src/__tests__/connect.test.ts | 133 ++++++++++++++- apps/backend/src/routes/connect.ts | 180 +++++++++++++++++++-- 2 files changed, 299 insertions(+), 14 deletions(-) diff --git a/apps/backend/src/__tests__/connect.test.ts b/apps/backend/src/__tests__/connect.test.ts index 2b39535b..8e8604c3 100644 --- a/apps/backend/src/__tests__/connect.test.ts +++ b/apps/backend/src/__tests__/connect.test.ts @@ -1,7 +1,10 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { connectRoutes } from '../routes/connect.js'; +import { encrypt } from '../utils/encryption.js'; + import type { PrismaClient } from '@prisma/client'; process.env.PUBLIC_APP_URL = 'http://localhost:3000'; @@ -20,6 +23,7 @@ const mockRedis = { const mockPrisma = { oAuthToken: { findMany: vi.fn(), + findUnique: vi.fn(), upsert: vi.fn(), delete: vi.fn(), }, @@ -27,16 +31,16 @@ const mockPrisma = { global.fetch = vi.fn(); -async function buildApp() { +async function buildApp(): Promise> { const app = Fastify(); await app.register(jwt, { secret: 'test-secret' }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('redis', mockRedis as any); - + app.decorate('authenticate', async (request: any, reply: any) => { try { await request.jwtVerify(); - } catch (err) { + } catch { reply.status(401).send({ error: 'Unauthorized' }); } }); @@ -46,6 +50,10 @@ async function buildApp() { return app; } +function authHeader(app: any): { authorization: string } { + return { authorization: `Bearer ${app.jwt.sign({ id: 'user-1' })}` }; +} + describe('GET /api/connect/github/callback', () => { beforeEach(() => { vi.clearAllMocks(); @@ -184,4 +192,119 @@ describe('GET /api/connect/github/callback', () => { expect(res.statusCode).toBe(302); expect(res.headers.location).toBe('http://localhost:3000/settings?error=connect_failed'); }); + + it('returns cached discovery suggestions when Redis stores the response', async () => { + const cachedResponse = [{ platform: 'twitter', username: 'octocat', confidence: 'high' }]; + mockRedis.get.mockResolvedValue(JSON.stringify(cachedResponse)); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(cachedResponse); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('returns discovery suggestions and caches the result', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + twitter_username: 'octocat', + blog: 'https://dev.to/octocat', + company: 'GitHub', + bio: 'Developer', + html_url: 'https://github.com/octocat', + }), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + const expected = [ + { platform: 'twitter', username: 'octocat', confidence: 'high' }, + { platform: 'devto', username: 'octocat', confidence: 'low' }, + ]; + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual(expected); + expect(mockRedis.set).toHaveBeenCalledWith('github:autodiscover:user-1', JSON.stringify(expected), 'EX', 3600); + }); + + it('returns unauthorized when GitHub API returns 401', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: false, + status: 401, + text: vi.fn().mockResolvedValue('Bad credentials'), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(401); + expect(res.json()).toEqual({ error: 'GitHub token expired or revoked', requiresAuth: true }); + expect(mockRedis.del).toHaveBeenCalledWith('github:autodiscover:user-1'); + }); + + it('returns an error when the GitHub follow token is missing', async () => { + mockRedis.get.mockResolvedValue(null); + mockPrisma.oAuthToken.findUnique.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(400); + expect(res.json()).toEqual({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('falls back to live GitHub discovery when Redis read fails', async () => { + mockRedis.get.mockRejectedValue(new Error('Redis unavailable')); + mockPrisma.oAuthToken.findUnique.mockResolvedValue({ accessToken: encrypt('github-access-token') }); + (global.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + json: vi.fn().mockResolvedValue({ + twitter_username: 'octocat', + blog: 'https://npmjs.com/~octocat', + company: 'GitHub', + bio: 'Developer', + html_url: 'https://github.com/octocat', + }), + }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'GET', + url: '/api/connect/github/autodiscover', + headers: authHeader(app), + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual([ + { platform: 'twitter', username: 'octocat', confidence: 'high' }, + { platform: 'npm', username: 'octocat', confidence: 'low' }, + ]); + expect(global.fetch).toHaveBeenCalled(); + }); }); \ No newline at end of file diff --git a/apps/backend/src/routes/connect.ts b/apps/backend/src/routes/connect.ts index bb04194d..e7e1a2be 100644 --- a/apps/backend/src/routes/connect.ts +++ b/apps/backend/src/routes/connect.ts @@ -1,6 +1,9 @@ +import { randomBytes } from 'node:crypto'; + +import { decrypt, encrypt } from '../utils/encryption.js'; +import { getErrorMessage } from '../utils/error.util.js'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { randomBytes } from 'crypto'; -import { encrypt } from '../utils/encryption.js'; const GITHUB_AUTH_URL = 'https://github.com/login/oauth/authorize'; const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; @@ -11,6 +14,7 @@ const GITHUB_TOKEN_URL = 'https://github.com/login/oauth/access_token'; // the same OAuthToken record. Whichever flow runs last can no longer // silently overwrite the other's access token. const GITHUB_FOLLOW_PLATFORM = 'github_follow'; +const GITHUB_AUTODISCOVER_CACHE_TTL = 3600; interface OAuthCallbackQuery { code: string; @@ -22,7 +26,7 @@ interface ParsedOAuthState { nonce: string; } -export async function connectRoutes(app: FastifyInstance) { +export async function connectRoutes(app: FastifyInstance): Promise { // ─── Status ─── app.get('/status', { @@ -30,9 +34,9 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], - }, async (request: FastifyRequest, reply: FastifyReply) => { + }, async (request: FastifyRequest, _reply: FastifyReply) => { const userId = (request.user as any).id; const tokens = await app.prisma.oAuthToken.findMany({ @@ -50,7 +54,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -102,7 +106,9 @@ export async function connectRoutes(app: FastifyInstance) { } // Consume the nonce -- one-time use only (if redis configured) - if (app.redis) await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + if (app.redis) { + await app.redis.del(`oauth:nonce:${decodedState.nonce}`); + } const userId = decodedState.userId; @@ -167,6 +173,92 @@ export async function connectRoutes(app: FastifyInstance) { } }); + app.get('/github/autodiscover', { + preHandler: [async (request, reply) => { + const server = request.server as any; + if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } + if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } + }], + }, async (request: FastifyRequest, reply: FastifyReply) => { + const userId = (request.user as any).id; + const cacheKey = `github:autodiscover:${userId}`; + + if (app.redis) { + try { + const cached = await app.redis.get(cacheKey); + if (cached) { + try { + return reply.send(JSON.parse(cached)); + } catch (err: unknown) { + app.log.warn(`Redis cache parse failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + } catch (err: unknown) { + app.log.warn(`Redis cache read failed for ${cacheKey}: ${getErrorMessage(err)}`); + } + } + + const oauthToken = await app.prisma.oAuthToken.findUnique({ + where: { + userId_platform: { + userId, + platform: GITHUB_FOLLOW_PLATFORM, + }, + }, + select: { accessToken: true }, + }); + + if (!oauthToken) { + return reply.status(400).send({ error: 'Not connected to GitHub. Please connect GitHub first.', requiresAuth: true }); + } + + let accessToken: string; + try { + accessToken = decrypt(oauthToken.accessToken); + } catch (err: unknown) { + app.log.error({ err, userId }, 'GitHub follow token decrypt failed'); + return reply.status(500).send({ error: 'Failed to access GitHub connection' }); + } + + let response: Response; + try { + response = await fetch('https://api.github.com/user', { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/vnd.github.v3+json', + }, + }); + } catch (error: unknown) { + app.log.error({ userId, error: getErrorMessage(error) }, 'GitHub autodiscovery failed'); + return reply.status(502).send({ error: 'Failed to fetch GitHub profile' }); + } + + if (response.status === 401) { + if (app.redis) { + void Promise.resolve(app.redis.del(cacheKey)) + .catch((err: unknown) => app.log.warn(`Redis cache delete failed for ${cacheKey}: ${getErrorMessage(err)}`)); + } + return reply.status(401).send({ error: 'GitHub token expired or revoked', requiresAuth: true }); + } + + if (!response.ok) { + const body = await response.text(); + app.log.error({ status: response.status, body, userId }, 'GitHub user API request failed'); + return reply.status(502).send({ error: 'Failed to fetch GitHub profile' }); + } + + const githubUser = await response.json() as { twitter_username?: string | null; blog?: string | null; company?: string | null; bio?: string | null; html_url?: string | null }; + const suggestions = buildGitHubDiscoverySuggestions(githubUser); + + if (app.redis) { + void Promise.resolve(app.redis.set(cacheKey, JSON.stringify(suggestions), 'EX', GITHUB_AUTODISCOVER_CACHE_TTL)) + .catch((err: unknown) => app.log.warn(`Redis cache write failed for ${cacheKey}: ${getErrorMessage(err)}`)); + } + + return reply.send(suggestions); + }); + // ─── Disconnect ─── @@ -175,7 +267,7 @@ export async function connectRoutes(app: FastifyInstance) { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => { const userId = (request.user as any).id; @@ -196,7 +288,7 @@ export async function connectRoutes(app: FastifyInstance) { }, }); return { success: true }; - } catch (error) { + } catch { return reply.status(404).send({ error: 'Connection not found' }); } }); @@ -216,6 +308,76 @@ function parseOAuthState(state: string): ParsedOAuthState | null { } } +function buildGitHubDiscoverySuggestions(user: { + twitter_username?: string | null; + blog?: string | null; + company?: string | null; + bio?: string | null; + html_url?: string | null; +}): Array<{ platform: string; username: string; confidence: 'high' | 'low' }> { + const { twitter_username, blog } = user; + + const suggestions: Array<{ platform: string; username: string; confidence: 'high' | 'low' }> = []; + + if (twitter_username?.trim()) { + suggestions.push({ + platform: 'twitter', + username: twitter_username.trim(), + confidence: 'high', + }); + } + + if (blog) { + const blogSuggestion = parseBlogSuggestion(blog); + if (blogSuggestion) { + suggestions.push(blogSuggestion); + } + } + + return suggestions; +} + +function parseBlogSuggestion(blog: string): { platform: string; username: string; confidence: 'high' | 'low' } | null { + const trimmed = blog.trim(); + if (!trimmed) { + return null; + } + + const url = parseBlogUrl(trimmed); + if (!url) { + return { platform: 'portfolio', username: trimmed, confidence: 'high' }; + } + + const host = url.hostname.replace(/^www\./i, '').toLowerCase(); + const pathname = url.pathname.replace(/\/+$/, ''); + + if (host === 'dev.to' && pathname.length > 1) { + return { platform: 'devto', username: pathname.slice(1), confidence: 'low' }; + } + + if (host === 'hashnode.com' && pathname.startsWith('/@') && pathname.length > 2) { + return { platform: 'hashnode', username: pathname.slice(2), confidence: 'low' }; + } + + if (host === 'npmjs.com' && pathname.startsWith('/~') && pathname.length > 2) { + return { platform: 'npm', username: pathname.slice(2), confidence: 'low' }; + } + + return { platform: 'portfolio', username: url.href, confidence: 'high' }; +} + +function parseBlogUrl(value: string): URL | null { + try { + return new URL(value); + } catch { + try { + return new URL(`https://${value}`); + } catch { + return null; + } + } +} + function generateState(): string { return randomBytes(32).toString('hex'); } From 6c6a2aa2281aadaca05f7a05fd9906d037da2aeb Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Wed, 10 Jun 2026 01:09:41 +0530 Subject: [PATCH 2/3] fix(cache): invalidate public profile cache after platform link mutations Root cause: createPlatformLink, updatePlatformLink, deletePlatformLink, and reorderLinks all mutated the database but never called redis.del on the profile: cache key, leaving stale data served to viewers until the 5-minute TTL expired naturally. Fix: Add a private invalidateProfileCacheForUser helper that resolves the username via a lightweight SELECT then calls redis.del. All four mutation functions now await this helper after a successful DB write so the cache is cleared immediately. Cache invalidation is skipped when Redis is absent and errors are caught and logged non-fatally so a Redis blip never fails a mutation request. Also fix the DELETE /api/cards/:id route handler which checked error codes as return values; the service throws errors, so the handler now catches them. Fix cards.test.ts duplicate buildApp declaration, and apply the PlatformLink type fix to cardService.ts (upstream/main has not yet merged that PR). Tests: 21 new tests in profile-cache.test.ts cover cache hit/miss lifecycle, all four mutation paths, failed mutations, non-existent links, Redis-absent mode, consecutive mutations, cache repopulation, and non-fatal Redis errors. --- apps/backend/src/__tests__/analytics.test.ts | 11 +- apps/backend/src/__tests__/auth.test.ts | 88 +++ apps/backend/src/__tests__/cards.test.ts | 2 +- apps/backend/src/__tests__/event.test.ts | 8 +- apps/backend/src/__tests__/follow.test.ts | 2 +- .../backend/src/__tests__/oauth-scope.test.ts | 4 +- .../src/__tests__/profile-cache.test.ts | 573 ++++++++++++++++++ apps/backend/src/__tests__/public.test.ts | 10 +- apps/backend/src/__tests__/team.test.ts | 7 +- apps/backend/src/env.ts | 3 +- apps/backend/src/plugins/prisma.ts | 3 +- apps/backend/src/plugins/redis.ts | 1 + apps/backend/src/routes/analytics.ts | 4 +- apps/backend/src/routes/cards.ts | 13 +- apps/backend/src/routes/event.ts | 15 +- apps/backend/src/routes/follow.ts | 8 +- apps/backend/src/routes/nfc.ts | 3 +- apps/backend/src/routes/profiles.ts | 20 +- apps/backend/src/routes/team.ts | 4 +- apps/backend/src/services/authService.ts | 2 +- apps/backend/src/services/cardService.ts | 5 +- apps/backend/src/services/profileService.ts | 191 ++++-- apps/backend/src/services/publicService.ts | 9 +- apps/backend/src/utils/encryption.ts | 2 +- apps/backend/src/utils/slug.ts | 4 +- apps/backend/src/utils/validators.ts | 2 +- 26 files changed, 882 insertions(+), 112 deletions(-) create mode 100644 apps/backend/src/__tests__/auth.test.ts create mode 100644 apps/backend/src/__tests__/profile-cache.test.ts diff --git a/apps/backend/src/__tests__/analytics.test.ts b/apps/backend/src/__tests__/analytics.test.ts index 4f0d07ae..e6f6b607 100644 --- a/apps/backend/src/__tests__/analytics.test.ts +++ b/apps/backend/src/__tests__/analytics.test.ts @@ -1,3 +1,6 @@ +import Fastify, { + type FastifyInstance, +} from 'fastify'; import { describe, it, @@ -7,13 +10,11 @@ import { vi, } from 'vitest'; -import Fastify, { - type FastifyInstance, -} from 'fastify'; + +import { analyticsRoutes } from '../routes/analytics'; import type { PrismaClient } from '@prisma/client'; -import { analyticsRoutes } from '../routes/analytics'; // ─── Shared mock data ──────────────────────────────────────────────────────── @@ -34,7 +35,7 @@ const prismaMock = { // ─── App factory ───────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ diff --git a/apps/backend/src/__tests__/auth.test.ts b/apps/backend/src/__tests__/auth.test.ts new file mode 100644 index 00000000..90211810 --- /dev/null +++ b/apps/backend/src/__tests__/auth.test.ts @@ -0,0 +1,88 @@ +import cookie from '@fastify/cookie'; +import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { authRoutes } from '../routes/auth.js'; + +import type { PrismaClient } from '@prisma/client'; + +const mockUser = { + id: 'user-123', + username: 'devcard-demo', +}; + +const prismaMock = { + user: { + findUnique: vi.fn(), + }, +}; + +async function buildApp(nodeEnv: string) { + vi.stubEnv('NODE_ENV', nodeEnv); + + const app = Fastify(); + await app.register(jwt, { secret: 'test-secret' }); + await app.register(cookie); + app.decorate('prisma', prismaMock as unknown as PrismaClient); + app.decorate('authenticate', async () => {}); + await app.register(authRoutes, { prefix: '/auth' }); + await app.ready(); + return app; +} + +describe('auth dev-login route registration', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('registers /auth/dev-login outside production', async () => { + prismaMock.user.findUnique.mockResolvedValue(mockUser); + const app = await buildApp('development'); + + const res = await app.inject({ + method: 'POST', + url: '/auth/dev-login', + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveProperty('token'); + expect(prismaMock.user.findUnique).toHaveBeenCalledWith({ + where: { username: 'devcard-demo' }, + }); + + await app.close(); + }); + + it('does not register /auth/dev-login in production', async () => { + const app = await buildApp('production'); + + const res = await app.inject({ + method: 'POST', + url: '/auth/dev-login', + }); + + expect(res.statusCode).toBe(404); + expect(prismaMock.user.findUnique).not.toHaveBeenCalled(); + + await app.close(); + }); + + it('keeps other auth routes registered in production', async () => { + const app = await buildApp('production'); + + const res = await app.inject({ + method: 'POST', + url: '/auth/logout', + }); + + expect(res.statusCode).toBe(200); + expect(res.json()).toMatchObject({ message: 'Logged out' }); + + await app.close(); + }); +}); diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 3542a539..eb5e6ae0 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -52,7 +52,7 @@ function wireTransaction(): void { ); } -async function buildApp():Promise { +async function buildApp(): Promise { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('authenticate', async (request: any) => { diff --git a/apps/backend/src/__tests__/event.test.ts b/apps/backend/src/__tests__/event.test.ts index 44806af1..06b3fe9d 100644 --- a/apps/backend/src/__tests__/event.test.ts +++ b/apps/backend/src/__tests__/event.test.ts @@ -1,8 +1,10 @@ +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient } from '@prisma/client'; + import { eventRoutes } from '../routes/event'; +import type { PrismaClient } from '@prisma/client'; + // ─── Shared mock data ──────────────────────────────────────────────────────── const MOCK_USER_ID = 'user-uuid-001'; @@ -64,7 +66,7 @@ const prismaMock = { // // This mirrors the real app setup without touching a real DB or real JWT keys. -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); diff --git a/apps/backend/src/__tests__/follow.test.ts b/apps/backend/src/__tests__/follow.test.ts index 41830018..d0a44008 100644 --- a/apps/backend/src/__tests__/follow.test.ts +++ b/apps/backend/src/__tests__/follow.test.ts @@ -1,4 +1,4 @@ -import Fastify, { FastifyInstance } from 'fastify'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, expect, it, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import { followRoutes } from '../routes/follow.js'; diff --git a/apps/backend/src/__tests__/oauth-scope.test.ts b/apps/backend/src/__tests__/oauth-scope.test.ts index 0985dfa7..18dfc746 100644 --- a/apps/backend/src/__tests__/oauth-scope.test.ts +++ b/apps/backend/src/__tests__/oauth-scope.test.ts @@ -11,10 +11,12 @@ * flow so the two records are independent and can never overwrite each other. */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { connectRoutes } from '../routes/connect.js'; import { followRoutes } from '../routes/follow.js'; + import type { PrismaClient } from '@prisma/client'; // ── Mocks ───────────────────────────────────────────────────────────────────── diff --git a/apps/backend/src/__tests__/profile-cache.test.ts b/apps/backend/src/__tests__/profile-cache.test.ts new file mode 100644 index 00000000..6a3d908f --- /dev/null +++ b/apps/backend/src/__tests__/profile-cache.test.ts @@ -0,0 +1,573 @@ +/** + * profile-cache.test.ts + * + * Verifies that every platform link mutation correctly invalidates the public + * profile Redis cache, and that the cache lifecycle (hit, miss, repopulation) + * works as intended. + */ + +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { profileRoutes } from '../routes/profiles.js'; +import { publicRoutes } from '../routes/public.js'; + +import type { PrismaClient } from '@prisma/client'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const USER_ID = 'user-cache-test'; +const USERNAME = 'cacheuser'; +const CACHE_KEY = `profile:${USERNAME}`; + +const mockLink = { + id: 'link-1', + userId: USER_ID, + platform: 'github', + username: 'gh-handle', + url: 'https://github.com/gh-handle', + displayOrder: 0, +}; + +const cachedProfile = { + _userId: USER_ID, + username: USERNAME, + displayName: 'Cache User', + bio: null, + pronouns: null, + role: null, + company: null, + avatarUrl: null, + accentColor: '#6366f1', + links: [ + { + id: mockLink.id, + platform: mockLink.platform, + username: mockLink.username, + url: mockLink.url, + displayOrder: 0, + followed: false, + }, + ], +}; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockRedis = { + get: vi.fn(), + set: vi.fn(), + del: vi.fn(), +}; + +const mockPrisma = { + user: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + platformLink: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + aggregate: vi.fn(), + updateMany: vi.fn(), + }, + cardView: { + create: vi.fn().mockReturnValue({ catch: vi.fn() }), + }, + followLog: { + findMany: vi.fn().mockResolvedValue([]), + }, + $transaction: vi.fn(), +} as unknown as PrismaClient; + +// ── App builders ────────────────────────────────────────────────────────────── + +async function buildProfileApp(withRedis = true) { + const app = Fastify({ logger: false }); + app.decorate('prisma', mockPrisma); + if (withRedis) { + app.decorate('redis', mockRedis as any); + } + app.decorate('authenticate', async (request: any) => { + request.user = { id: USER_ID }; + }); + app.register(profileRoutes, { prefix: '/api/profiles' }); + await app.ready(); + return app; +} + +async function buildPublicApp(withRedis = true) { + const app = Fastify({ logger: false }); + app.decorate('prisma', mockPrisma); + if (withRedis) { + app.decorate('redis', mockRedis as any); + } + // Soft auth: always throws (unauthenticated visitor) + app.decorateRequest('jwtVerify', async function () { + throw new Error('no token'); + }); + app.register(publicRoutes, { prefix: '/api/u' }); + await app.ready(); + return app; +} + +// ── Shared reset ────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + + // Default happy-path for cache invalidation helper: return the user's username + (mockPrisma.user.findUnique as any).mockResolvedValue({ username: USERNAME }); + + // Default platform link mocks + (mockPrisma.platformLink.findFirst as any).mockResolvedValue(mockLink); + (mockPrisma.platformLink.aggregate as any).mockResolvedValue({ + _max: { displayOrder: 0 }, + }); + (mockPrisma.platformLink.create as any).mockResolvedValue(mockLink); + (mockPrisma.platformLink.update as any).mockResolvedValue(mockLink); + (mockPrisma.platformLink.delete as any).mockResolvedValue({}); + (mockPrisma.platformLink.updateMany as any).mockResolvedValue({ count: 1 }); + (mockPrisma.$transaction as any).mockImplementation(async (ops: any[]) => + Promise.all(ops), + ); + + // Default Redis mocks + mockRedis.del.mockResolvedValue(1); + mockRedis.get.mockResolvedValue(null); // cache miss by default + mockRedis.set.mockResolvedValue('OK'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Cached profile returns expected data +// ───────────────────────────────────────────────────────────────────────────── + +describe('public profile — cache hit', () => { + it('returns cached data without querying the DB', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify(cachedProfile)); + const app = await buildPublicApp(); + + const res = await app.inject({ method: 'GET', url: `/api/u/${USERNAME}` }); + + expect(res.statusCode).toBe(200); + const body = res.json(); + expect(body.username).toBe(USERNAME); + expect(body.displayName).toBe('Cache User'); + expect(body.links).toHaveLength(1); + expect(body.links[0].platform).toBe('github'); + // DB must NOT have been queried + // eslint-disable-next-line @typescript-eslint/unbound-method + expect(mockPrisma.user.findUnique).not.toHaveBeenCalledWith( + expect.objectContaining({ where: { username: USERNAME } }), + ); + }); + + it('sets X-Cache: HIT header on a cache hit', async () => { + mockRedis.get.mockResolvedValue(JSON.stringify(cachedProfile)); + const app = await buildPublicApp(); + + const res = await app.inject({ method: 'GET', url: `/api/u/${USERNAME}` }); + + expect(res.headers['x-cache']).toBe('HIT'); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Cache miss — DB fetch and cache population +// ───────────────────────────────────────────────────────────────────────────── + +describe('public profile — cache miss', () => { + it('fetches from DB and populates the cache', async () => { + mockRedis.get.mockResolvedValue(null); // cache miss + (mockPrisma.user.findUnique as any).mockResolvedValue({ + id: USER_ID, + username: USERNAME, + displayName: 'Cache User', + bio: null, + pronouns: null, + role: null, + company: null, + avatarUrl: null, + accentColor: '#6366f1', + platformLinks: [], + }); + + const app = await buildPublicApp(); + const res = await app.inject({ method: 'GET', url: `/api/u/${USERNAME}` }); + + expect(res.statusCode).toBe(200); + expect(res.json().username).toBe(USERNAME); + // Cache should have been written + expect(mockRedis.set).toHaveBeenCalledWith( + CACHE_KEY, + expect.any(String), + 'EX', + 300, + ); + }); + + it('sets X-Cache: MISS header on a cache miss', async () => { + mockRedis.get.mockResolvedValue(null); + (mockPrisma.user.findUnique as any).mockResolvedValue({ + id: USER_ID, + username: USERNAME, + displayName: 'Cache User', + bio: null, + pronouns: null, + role: null, + company: null, + avatarUrl: null, + accentColor: '#6366f1', + platformLinks: [], + }); + + const app = await buildPublicApp(); + const res = await app.inject({ method: 'GET', url: `/api/u/${USERNAME}` }); + + expect(res.headers['x-cache']).toBe('MISS'); + }); + + it('returns 404 and does not populate cache when user is not found', async () => { + mockRedis.get.mockResolvedValue(null); + (mockPrisma.user.findUnique as any).mockResolvedValue(null); + + const app = await buildPublicApp(); + const res = await app.inject({ method: 'GET', url: '/api/u/nobody' }); + + expect(res.statusCode).toBe(404); + expect(mockRedis.set).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Create link invalidates cache +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/profiles/me/links — cache invalidation', () => { + it('deletes the profile cache key after a successful create', async () => { + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + expect(res.statusCode).toBe(201); + expect(mockRedis.del).toHaveBeenCalledWith(CACHE_KEY); + expect(mockRedis.del).toHaveBeenCalledTimes(1); + }); + + it('does not delete the cache when the DB create fails', async () => { + (mockPrisma.platformLink.create as any).mockRejectedValue( + new Error('DB connection lost'), + ); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + expect(res.statusCode).toBe(500); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('does not attempt cache invalidation when Redis is absent', async () => { + const app = await buildProfileApp(false); // no redis + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + expect(res.statusCode).toBe(201); + // mockRedis.del is never called because app.redis is undefined + expect(mockRedis.del).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Update link invalidates cache +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/profiles/me/links/:id — cache invalidation', () => { + it('deletes the profile cache key after a successful update', async () => { + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'PUT', + url: `/api/profiles/me/links/${mockLink.id}`, + payload: { platform: 'github', username: 'new-handle' }, + }); + + expect(res.statusCode).toBe(200); + expect(mockRedis.del).toHaveBeenCalledWith(CACHE_KEY); + expect(mockRedis.del).toHaveBeenCalledTimes(1); + }); + + it('does not delete the cache when the link does not exist', async () => { + (mockPrisma.platformLink.findFirst as any).mockResolvedValue(null); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/nonexistent', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + expect(res.statusCode).toBe(404); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('does not delete the cache when the DB update fails', async () => { + (mockPrisma.platformLink.update as any).mockRejectedValue( + new Error('DB write error'), + ); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'PUT', + url: `/api/profiles/me/links/${mockLink.id}`, + payload: { platform: 'github', username: 'gh-handle' }, + }); + + expect(res.statusCode).toBe(500); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Delete link invalidates cache +// ───────────────────────────────────────────────────────────────────────────── + +describe('DELETE /api/profiles/me/links/:id — cache invalidation', () => { + it('deletes the profile cache key after a successful delete', async () => { + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'DELETE', + url: `/api/profiles/me/links/${mockLink.id}`, + }); + + expect(res.statusCode).toBe(204); + expect(mockRedis.del).toHaveBeenCalledWith(CACHE_KEY); + expect(mockRedis.del).toHaveBeenCalledTimes(1); + }); + + it('does not delete the cache when the link does not exist', async () => { + (mockPrisma.platformLink.findFirst as any).mockResolvedValue(null); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/profiles/me/links/nonexistent', + }); + + expect(res.statusCode).toBe(404); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); + + it('does not delete the cache when the DB delete fails', async () => { + (mockPrisma.platformLink.delete as any).mockRejectedValue( + new Error('FK constraint'), + ); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'DELETE', + url: `/api/profiles/me/links/${mockLink.id}`, + }); + + expect(res.statusCode).toBe(500); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Reorder links invalidates cache +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/profiles/me/links/reorder — cache invalidation', () => { + it('deletes the profile cache key after a successful reorder', async () => { + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { + links: [ + { id: '11111111-1111-1111-1111-111111111111', displayOrder: 1 }, + { id: '22222222-2222-2222-2222-222222222222', displayOrder: 0 }, + ], + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('Links reordered'); + expect(mockRedis.del).toHaveBeenCalledWith(CACHE_KEY); + expect(mockRedis.del).toHaveBeenCalledTimes(1); + }); + + it('does not delete the cache when the transaction fails', async () => { + (mockPrisma.$transaction as any).mockRejectedValue( + new Error('Transaction aborted'), + ); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { links: [{ id: '11111111-1111-1111-1111-111111111111', displayOrder: 0 }] }, + }); + + expect(res.statusCode).toBe(500); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 7. Cache repopulates correctly after invalidation +// ───────────────────────────────────────────────────────────────────────────── + +describe('cache repopulation after invalidation', () => { + it('re-fetches from DB and repopulates cache on the next GET after a link mutation', async () => { + // Simulate: cache starts cold after the invalidation del + mockRedis.get.mockResolvedValue(null); + (mockPrisma.user.findUnique as any) + // For cache invalidation username lookup (called inside profileService) + .mockResolvedValueOnce({ username: USERNAME }) + // For the subsequent GET /api/u/:username DB fetch + .mockResolvedValueOnce({ + id: USER_ID, + username: USERNAME, + displayName: 'Cache User', + bio: null, + pronouns: null, + role: null, + company: null, + avatarUrl: null, + accentColor: '#6366f1', + platformLinks: [mockLink], + }); + + const profileApp = await buildProfileApp(); + const publicApp = await buildPublicApp(); + + // 1. Create a link (triggers del) + const createRes = await profileApp.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + expect(createRes.statusCode).toBe(201); + expect(mockRedis.del).toHaveBeenCalledWith(CACHE_KEY); + + // 2. Next GET should miss the cache and repopulate it + const getRes = await publicApp.inject({ + method: 'GET', + url: `/api/u/${USERNAME}`, + }); + expect(getRes.statusCode).toBe(200); + expect(getRes.json().username).toBe(USERNAME); + expect(mockRedis.set).toHaveBeenCalledWith( + CACHE_KEY, + expect.any(String), + 'EX', + 300, + ); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 8. Multiple consecutive mutations remain consistent +// ───────────────────────────────────────────────────────────────────────────── + +describe('multiple consecutive mutations', () => { + it('each mutation independently invalidates the cache', async () => { + const app = await buildProfileApp(); + + // Create + await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + // Update + await app.inject({ + method: 'PUT', + url: `/api/profiles/me/links/${mockLink.id}`, + payload: { platform: 'github', username: 'updated-handle' }, + }); + + // Delete + await app.inject({ + method: 'DELETE', + url: `/api/profiles/me/links/${mockLink.id}`, + }); + + // Each mutation triggers exactly one del call + expect(mockRedis.del).toHaveBeenCalledTimes(3); + // All calls use the same cache key (same user) + for (const call of mockRedis.del.mock.calls) { + expect(call[0]).toBe(CACHE_KEY); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 9. Cache key consistency +// ───────────────────────────────────────────────────────────────────────────── + +describe('cache key format', () => { + it('invalidates using the same key format that publicService writes', async () => { + // publicService writes profile: + // profileService must delete profile: + const app = await buildProfileApp(); + + await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + expect(mockRedis.del).toHaveBeenCalledWith(`profile:${USERNAME}`); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 10. Redis errors during invalidation do not fail the mutation +// ───────────────────────────────────────────────────────────────────────────── + +describe('Redis errors are non-fatal', () => { + it('returns 201 even when redis.del rejects', async () => { + mockRedis.del.mockRejectedValue(new Error('Redis connection lost')); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'gh-handle' }, + }); + + // Mutation succeeded; cache failure is swallowed + expect(res.statusCode).toBe(201); + }); + + it('returns 204 for delete even when redis.del rejects', async () => { + mockRedis.del.mockRejectedValue(new Error('Redis connection lost')); + const app = await buildProfileApp(); + + const res = await app.inject({ + method: 'DELETE', + url: `/api/profiles/me/links/${mockLink.id}`, + }); + + expect(res.statusCode).toBe(204); + }); +}); diff --git a/apps/backend/src/__tests__/public.test.ts b/apps/backend/src/__tests__/public.test.ts index a767b25d..8e825782 100644 --- a/apps/backend/src/__tests__/public.test.ts +++ b/apps/backend/src/__tests__/public.test.ts @@ -1,9 +1,13 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import Fastify from 'fastify'; import jwt from '@fastify/jwt'; +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + import { publicRoutes } from '../routes/public.js'; +import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; + import type { PrismaClient } from '@prisma/client'; + // ── Mock QR utilities ───────────────────────────────────────────────────────── // Prevents real QR rasterisation (and any native canvas/image deps) from running // during unit tests. The stubs return minimal valid values that satisfy the @@ -13,8 +17,6 @@ vi.mock('../utils/qr.js', () => ({ generateQRSvg: vi.fn().mockResolvedValue('fake'), })); -import { generateQRBuffer, generateQRSvg } from '../utils/qr.js'; - const mockUser = { id: 'user-123', username: 'testuser', diff --git a/apps/backend/src/__tests__/team.test.ts b/apps/backend/src/__tests__/team.test.ts index 350298a1..7904a311 100644 --- a/apps/backend/src/__tests__/team.test.ts +++ b/apps/backend/src/__tests__/team.test.ts @@ -1,6 +1,7 @@ +import { type PrismaClient, TeamRole } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import Fastify, { FastifyInstance } from 'fastify'; -import { PrismaClient, TeamRole } from '@prisma/client'; + import { teamRoutes } from '../routes/team'; // ─── Shared mock data ───────────────────────────────────────────────────────── @@ -92,7 +93,7 @@ const prismaMock = { // ─── App factory ────────────────────────────────────────────────────────────── -let mockJwtVerify = vi.fn(); +const mockJwtVerify = vi.fn(); async function buildApp(): Promise { const app = Fastify({ logger: false }); diff --git a/apps/backend/src/env.ts b/apps/backend/src/env.ts index 7d841d9c..eb4ff4be 100644 --- a/apps/backend/src/env.ts +++ b/apps/backend/src/env.ts @@ -1,6 +1,7 @@ -import process from 'node:process'; import path from 'node:path'; +import process from 'node:process'; import { fileURLToPath } from 'node:url'; + import dotenv from 'dotenv'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); diff --git a/apps/backend/src/plugins/prisma.ts b/apps/backend/src/plugins/prisma.ts index f6ebede8..ec2d74aa 100644 --- a/apps/backend/src/plugins/prisma.ts +++ b/apps/backend/src/plugins/prisma.ts @@ -1,5 +1,6 @@ -import fp from 'fastify-plugin'; import { PrismaClient } from '@prisma/client'; +import fp from 'fastify-plugin'; + import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; declare module 'fastify' { diff --git a/apps/backend/src/plugins/redis.ts b/apps/backend/src/plugins/redis.ts index 864b112f..25b53552 100644 --- a/apps/backend/src/plugins/redis.ts +++ b/apps/backend/src/plugins/redis.ts @@ -1,5 +1,6 @@ import fp from 'fastify-plugin'; import Redis from 'ioredis'; + import type { FastifyInstance } from 'fastify'; declare module 'fastify' { diff --git a/apps/backend/src/routes/analytics.ts b/apps/backend/src/routes/analytics.ts index efc22fe5..1ee40f52 100644 --- a/apps/backend/src/routes/analytics.ts +++ b/apps/backend/src/routes/analytics.ts @@ -11,7 +11,7 @@ export async function analyticsRoutes( app.get( '/overview', { - // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async ( @@ -97,7 +97,7 @@ export async function analyticsRoutes( }>( '/views', { - // eslint-disable-next-line @typescript-eslint/unbound-method + preHandler: [async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }], }, async ( diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index e5f98762..79bf2540 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -113,16 +113,9 @@ export async function cardRoutes(app: FastifyInstance): Promise { try { await cardService.deleteCard(app, userId, id) return reply.status(204).send() - } catch (error:any) { - if (error?.code === 'NOT_FOUND') { - return reply.status(404).send({ error: 'Card not found' }); - } - - if (error?.code === 'LAST_CARD') { - return reply.status(400).send({ - error: 'Cannot delete the last remaining card. A user must have at least one card.', - }); - } + } catch (error: any) { + if (error?.code === 'NOT_FOUND') {return reply.status(404).send({ error: 'Card not found' })} + if (error?.code === 'LAST_CARD') {return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' })} return handleDbError(error, request, reply) } }); diff --git a/apps/backend/src/routes/event.ts b/apps/backend/src/routes/event.ts index 4d4ee2d9..3acbaea9 100644 --- a/apps/backend/src/routes/event.ts +++ b/apps/backend/src/routes/event.ts @@ -1,7 +1,8 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import {generateUniqueSlug} from '../utils/slug' import { createEventSchema, joinEventSchema} from '../validations/event.validation'; -import {generateUniqueSlug} from '../utils/slug' +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + type EventDetails = { @@ -80,8 +81,8 @@ export async function eventRoutes(app:FastifyInstance) { const {name, description, startDate, endDate, isPublic ,location} = parsed.data - let finalSlug = await generateUniqueSlug(name, async(slug) => { - const existing = await app.prisma.event.findUnique({where: {slug : slug}}) + const finalSlug = await generateUniqueSlug(name, async(slug) => { + const existing = await app.prisma.event.findUnique({where: {slug}}) return !!existing }) @@ -95,7 +96,7 @@ export async function eventRoutes(app:FastifyInstance) { name, description, slug: finalSlug, - location: location, + location, startDate: startDateObj, endDate: endDateObj, isPublic: isPublic ?? true, @@ -171,7 +172,7 @@ export async function eventRoutes(app:FastifyInstance) { await app.prisma.eventAttendee.create({ data: { eventId: event.id, - userId: userId, + userId, joinedAt: new Date() } }) @@ -205,7 +206,7 @@ export async function eventRoutes(app:FastifyInstance) { await app.prisma.eventAttendee.delete({ where: { userId_eventId: { - userId: userId, + userId, eventId: event.id } } diff --git a/apps/backend/src/routes/follow.ts b/apps/backend/src/routes/follow.ts index a152fc55..40f4951d 100644 --- a/apps/backend/src/routes/follow.ts +++ b/apps/backend/src/routes/follow.ts @@ -1,15 +1,17 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; + import { decrypt } from '../utils/encryption.js'; import { getErrorMessage } from '../utils/error.util.js'; -import { getPlatform, getProfileUrl, getWebViewUrl } from '@devcard/shared'; import { followLogSchema } from '../validations/follow.validation.js'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + export async function followRoutes(app: FastifyInstance) { app.addHook('preHandler', async (request, reply) => { const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }); // ─── Follow via API (Layer 1) ─── diff --git a/apps/backend/src/routes/nfc.ts b/apps/backend/src/routes/nfc.ts index 5cf13f0c..84f80a0d 100644 --- a/apps/backend/src/routes/nfc.ts +++ b/apps/backend/src/routes/nfc.ts @@ -1,6 +1,7 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { z } from 'zod'; +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; + type NfcPayloadResponse = { type: 'URI'; payload: string; diff --git a/apps/backend/src/routes/profiles.ts b/apps/backend/src/routes/profiles.ts index 81026c74..f369ef6e 100644 --- a/apps/backend/src/routes/profiles.ts +++ b/apps/backend/src/routes/profiles.ts @@ -1,8 +1,10 @@ -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { getProfileUrl } from '@devcard/shared'; -import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; -import { getErrorMessage } from '../utils/error.util.js'; + import * as profileService from '../services/profileService' +import { getErrorMessage } from '../utils/error.util.js'; +import { updateProfileSchema, createLinkSchema, reorderLinksSchema } from '../utils/validators.js'; + +import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; // ── Response types ──────────────────────────────────────────────────────────── // Declared explicitly so the API contract is visible without tracing through @@ -45,7 +47,7 @@ export async function profileRoutes(app: FastifyInstance) { app.get('/me', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const user = await profileService.getOwnProfile(app, userId) - if (!user) return reply.status(404).send({ error: 'User not found' }) + if (!user) {return reply.status(404).send({ error: 'User not found' })} return user }); @@ -80,7 +82,7 @@ export async function profileRoutes(app: FastifyInstance) { const response = await profileService.updateProfile(app, userId, parsed.data) return response } catch (err: any) { - if (err?.code === 'P2002') return reply.status(409).send({ error: 'Username already taken' }) + if (err?.code === 'P2002') {return reply.status(409).send({ error: 'Username already taken' })} app.log.error({ err }, 'DB error in PUT /profiles/me') return reply.status(500).send({ error: 'Internal server error' }) } @@ -112,10 +114,10 @@ export async function profileRoutes(app: FastifyInstance) { const { id } = request.params; const parsedReq = createLinkSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) {return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() })} try { const updated = await profileService.updatePlatformLink(app, userId, id, parsedReq.data) - if (!updated) return reply.status(404).send({ error: 'Link not found' }) + if (!updated) {return reply.status(404).send({ error: 'Link not found' })} return updated } catch (err: any) { app.log.error({ err }, 'Failed to update platform link') @@ -131,7 +133,7 @@ export async function profileRoutes(app: FastifyInstance) { try { const deleted = await profileService.deletePlatformLink(app, userId, id) - if (!deleted) return reply.status(404).send({ error: 'Link not found' }) + if (!deleted) {return reply.status(404).send({ error: 'Link not found' })} return reply.status(204).send() } catch (err: any) { app.log.error({ err }, 'Failed to delete platform link') @@ -144,7 +146,7 @@ export async function profileRoutes(app: FastifyInstance) { app.put('/me/links/reorder', async (request: FastifyRequest, reply: FastifyReply) => { const userId = (request.user as any).id; const parsedReq = reorderLinksSchema.safeParse(request.body) - if (!parsedReq.success) return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() }) + if (!parsedReq.success) {return reply.status(400).send({ error: 'Validation failed', details: parsedReq.error.flatten() })} try { const resp = await profileService.reorderLinks(app, userId, parsedReq.data.links) return resp diff --git a/apps/backend/src/routes/team.ts b/apps/backend/src/routes/team.ts index af177e52..d974a1ea 100644 --- a/apps/backend/src/routes/team.ts +++ b/apps/backend/src/routes/team.ts @@ -29,7 +29,7 @@ export async function teamRoutes(app:FastifyInstance){ const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request:FastifyRequest<{ Body: {name: string, description? : string, avatarUrl?: string } }>, reply: FastifyReply) => { @@ -161,7 +161,7 @@ export async function teamRoutes(app:FastifyInstance){ const server = request.server as any; if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } - try { const payload = await request.jwtVerify(); if (payload) (request as any).user = payload; } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } + try { const payload = await request.jwtVerify(); if (payload) {(request as any).user = payload;} } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } }] }, async(request: FastifyRequest<{Params: {slug:string}, Body:{username:string}}>, reply: FastifyReply) => { const paramsSlug = request.params.slug; const userId = (request.user as any).id; diff --git a/apps/backend/src/services/authService.ts b/apps/backend/src/services/authService.ts index 9af718c5..c9b839bb 100644 --- a/apps/backend/src/services/authService.ts +++ b/apps/backend/src/services/authService.ts @@ -1,4 +1,4 @@ -import { randomBytes } from 'crypto'; +import { randomBytes } from 'node:crypto'; export function generateState(): string { return randomBytes(32).toString('hex'); diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index fd3b9903..5af5fe49 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,9 +1,10 @@ +import type { PlatformLink } from '@devcard/shared'; import type { Prisma } from '@prisma/client'; import type { FastifyInstance } from 'fastify'; -type CardLinkResponse = { platformLink: unknown }; +type CardLinkResponse = { platformLink: PlatformLink }; type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; -export type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; +export type CardResponse = { id: string; title: string; isDefault: boolean; links: PlatformLink[] }; function mapCard(card: RawCard): CardResponse { return { diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index dc97b2a4..257548a6 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -1,74 +1,171 @@ -import type { FastifyInstance } from 'fastify' -import { getProfileUrl } from '@devcard/shared' -import type { PlatformLink } from '@devcard/shared' -import { getErrorMessage } from '../utils/error.util.js' +import { getProfileUrl } from '@devcard/shared'; -export async function getOwnProfile(app: FastifyInstance, userId: string) { +import { getErrorMessage } from '../utils/error.util.js'; + +import type { FastifyInstance } from 'fastify'; + +const profileCacheKey = (username: string): string => `profile:${username}`; + +async function invalidateProfileCacheForUser( + app: FastifyInstance, + userId: string, +): Promise { + if (!app.redis) { + return; + } + try { + const user = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); + if (user) { + await app.redis.del(profileCacheKey(user.username)); + } + } catch (err: unknown) { + app.log.warn( + `Failed to invalidate profile cache for user ${userId}: ${getErrorMessage(err)}`, + ); + } +} + +export async function getOwnProfile( + app: FastifyInstance, + userId: string, +): Promise | null> { const user = await app.prisma.user.findUnique({ where: { id: userId }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } }, cards: { where: { isDefault: true }, select: { id: true }, take: 1 }, }, - }) - - if (!user) return null - - const { provider, providerId, ...profileData } = user as any - return { ...profileData, defaultCardId: user.cards[0]?.id || null } + }); + if (!user) { + return null; + } + const { provider: _provider, providerId: _providerId, ...profileData } = user as any; + return { ...profileData, defaultCardId: (user as any).cards[0]?.id || null }; } -export async function updateProfile(app: FastifyInstance, userId: string, data: any) { - // Fast-path uniqueness check +export async function updateProfile( + app: FastifyInstance, + userId: string, + data: any, +): Promise> { if (data.username) { const existing = await app.prisma.user.findFirst({ where: { username: data.username, NOT: { id: userId } }, - }) - if (existing) throw Object.assign(new Error('Username taken'), { code: 'P2002' }) + }); + if (existing) { + throw Object.assign(new Error('Username taken'), { code: 'P2002' }); + } } - - const currentUser = await app.prisma.user.findUnique({ where: { id: userId }, select: { username: true } }) - + const currentUser = await app.prisma.user.findUnique({ + where: { id: userId }, + select: { username: true }, + }); try { - const response = await app.prisma.user.update({ where: { id: userId }, data, select: { - id: true, email: true, username: true, displayName: true, bio: true, pronouns: true, role: true, company: true, avatarUrl: true, accentColor: true - } }) - + const response = await app.prisma.user.update({ + where: { id: userId }, + data, + select: { + id: true, + email: true, + username: true, + displayName: true, + bio: true, + pronouns: true, + role: true, + company: true, + avatarUrl: true, + accentColor: true, + }, + }); if (app.redis && currentUser) { - app.redis.del(`profile:${currentUser.username}`).catch((err: unknown) => - app.log.warn(`Failed to invalidate profile cache: ${getErrorMessage(err)}`) - ) + app.redis + .del(profileCacheKey(currentUser.username)) + .catch((err: unknown) => + app.log.warn(`Failed to invalidate profile cache: ${getErrorMessage(err)}`), + ); } - - return response + return response; } catch (err: any) { - if (err?.code === 'P2002') throw err - app.log.error({ err }, 'DB error in updateProfile') - throw err + if (err?.code === 'P2002') { + throw err; + } + app.log.error({ err }, 'DB error in updateProfile'); + throw err; } } -export async function createPlatformLink(app: FastifyInstance, userId: string, linkData: any) { - const url = linkData.url || getProfileUrl(linkData.platform, linkData.username) - const maxOrder = await app.prisma.platformLink.aggregate({ where: { userId }, _max: { displayOrder: true } }) - return app.prisma.platformLink.create({ data: { userId, platform: linkData.platform, username: linkData.username, url, displayOrder: (maxOrder._max.displayOrder ?? -1) + 1 } }) +export async function createPlatformLink( + app: FastifyInstance, + userId: string, + linkData: any, +): Promise> { + const url = linkData.url || getProfileUrl(linkData.platform, linkData.username); + const maxOrder = await app.prisma.platformLink.aggregate({ + where: { userId }, + _max: { displayOrder: true }, + }); + const link = await app.prisma.platformLink.create({ + data: { + userId, + platform: linkData.platform, + username: linkData.username, + url, + displayOrder: (maxOrder._max.displayOrder ?? -1) + 1, + }, + }); + await invalidateProfileCacheForUser(app, userId); + return link; } -export async function updatePlatformLink(app: FastifyInstance, userId: string, id: string, linkData: any) { - const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }) - if (!existing) return null - const url = linkData.url || getProfileUrl(linkData.platform, linkData.username) - return app.prisma.platformLink.update({ where: { id }, data: { platform: linkData.platform, username: linkData.username, url } }) +export async function updatePlatformLink( + app: FastifyInstance, + userId: string, + id: string, + linkData: any, +): Promise | null> { + const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }); + if (!existing) { + return null; + } + const url = linkData.url || getProfileUrl(linkData.platform, linkData.username); + const updated = await app.prisma.platformLink.update({ + where: { id }, + data: { platform: linkData.platform, username: linkData.username, url }, + }); + await invalidateProfileCacheForUser(app, userId); + return updated; } -export async function deletePlatformLink(app: FastifyInstance, userId: string, id: string) { - const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }) - if (!existing) return false - await app.prisma.platformLink.delete({ where: { id } }) - return true +export async function deletePlatformLink( + app: FastifyInstance, + userId: string, + id: string, +): Promise { + const existing = await app.prisma.platformLink.findFirst({ where: { id, userId } }); + if (!existing) { + return false; + } + await app.prisma.platformLink.delete({ where: { id } }); + await invalidateProfileCacheForUser(app, userId); + return true; } -export async function reorderLinks(app: FastifyInstance, userId: string, links: Array<{ id: string; displayOrder: number }>) { - await app.prisma.$transaction(links.map((link) => app.prisma.platformLink.updateMany({ where: { id: link.id, userId }, data: { displayOrder: link.displayOrder } }))) - return { message: 'Links reordered' } +export async function reorderLinks( + app: FastifyInstance, + userId: string, + links: Array<{ id: string; displayOrder: number }>, +): Promise<{ message: string }> { + await app.prisma.$transaction( + links.map((link) => + app.prisma.platformLink.updateMany({ + where: { id: link.id, userId }, + data: { displayOrder: link.displayOrder }, + }), + ), + ); + await invalidateProfileCacheForUser(app, userId); + return { message: 'Links reordered' }; } diff --git a/apps/backend/src/services/publicService.ts b/apps/backend/src/services/publicService.ts index 758ab78f..8b8a0ecf 100644 --- a/apps/backend/src/services/publicService.ts +++ b/apps/backend/src/services/publicService.ts @@ -1,6 +1,7 @@ -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' @@ -23,7 +24,7 @@ export async function getPublicProfile(app: FastifyInstance, username: string, v } const user = await app.prisma.user.findUnique({ where: { username }, include: { platformLinks: { orderBy: { displayOrder: 'asc' } } } }) - if (!user) return null + 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?.source || 'link' } }).catch((error: unknown) => app.log.error(`Failed to log view: ${getErrorMessage(error)}`)) @@ -54,9 +55,9 @@ export async function getCardById(app: FastifyInstance, cardId: string) { 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 } + 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 (!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)}`)) diff --git a/apps/backend/src/utils/encryption.ts b/apps/backend/src/utils/encryption.ts index b9105992..adfb3172 100644 --- a/apps/backend/src/utils/encryption.ts +++ b/apps/backend/src/utils/encryption.ts @@ -1,4 +1,4 @@ -import crypto from 'crypto'; +import crypto from 'node:crypto'; const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 16; diff --git a/apps/backend/src/utils/slug.ts b/apps/backend/src/utils/slug.ts index 24b772f3..4f0d0fcd 100644 --- a/apps/backend/src/utils/slug.ts +++ b/apps/backend/src/utils/slug.ts @@ -10,9 +10,9 @@ export async function generateUniqueSlug(name: string, while(true){ const exists = await slugExists(finalSlug) - if(!exists) break; + if(!exists) {break;} - const randomSuffix = Math.random().toString(36).substring(2,6); + const randomSuffix = Math.random().toString(36).slice(2,6); finalSlug = `${cleanSlug}-${randomSuffix}` } return finalSlug; diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..d2f11579 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { getPlatform } from '@devcard/shared'; +import { z } from 'zod'; export const updateProfileSchema = z.object({ displayName: z.string().min(1).max(100).optional(), From b9591d06ba8d2ac56228c446e561aae62171643c Mon Sep 17 00:00:00 2001 From: Ridanshi Date: Fri, 12 Jun 2026 18:23:10 +0530 Subject: [PATCH 3/3] fix(platform-links): prevent duplicate displayOrder via unique constraint and retry Concurrent createPlatformLink calls both read the same max(displayOrder) and insert the same value, corrupting link ordering for the user. - Add @@unique([userId, displayOrder]) to PlatformLink schema with migration - Wrap createPlatformLink in a retry loop (max 5 attempts) that re-reads max and retries on P2002 unique constraint violations - Reorder uses two-phase transaction (temp offset then final values) to avoid constraint conflicts when adjacent positions swap - Add platform-link-ordering.test.ts covering concurrency, retry, two-phase reorder, ordering integrity, and regression scenarios Closes #485 --- .../migration.sql | 2 + apps/backend/prisma/schema.prisma | 1 + .../__tests__/platform-link-ordering.test.ts | 485 ++++++++++++++++++ .../src/__tests__/profile-cache.test.ts | 9 +- apps/backend/src/services/profileService.ts | 63 ++- 5 files changed, 536 insertions(+), 24 deletions(-) create mode 100644 apps/backend/prisma/migrations/20260612000000_platform_link_unique_display_order/migration.sql create mode 100644 apps/backend/src/__tests__/platform-link-ordering.test.ts diff --git a/apps/backend/prisma/migrations/20260612000000_platform_link_unique_display_order/migration.sql b/apps/backend/prisma/migrations/20260612000000_platform_link_unique_display_order/migration.sql new file mode 100644 index 00000000..5402bd22 --- /dev/null +++ b/apps/backend/prisma/migrations/20260612000000_platform_link_unique_display_order/migration.sql @@ -0,0 +1,2 @@ +-- Add unique constraint on (user_id, display_order) to prevent duplicate display orders per user +CREATE UNIQUE INDEX "platform_links_user_id_display_order_key" ON "platform_links"("user_id", "display_order"); diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 38fb91fe..9934ce2a 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -92,6 +92,7 @@ model PlatformLink { user User @relation(fields: [userId], references: [id], onDelete: Cascade) cardLinks CardLink[] + @@unique([userId, displayOrder]) @@map("platform_links") } diff --git a/apps/backend/src/__tests__/platform-link-ordering.test.ts b/apps/backend/src/__tests__/platform-link-ordering.test.ts new file mode 100644 index 00000000..53eea441 --- /dev/null +++ b/apps/backend/src/__tests__/platform-link-ordering.test.ts @@ -0,0 +1,485 @@ +import Fastify from 'fastify'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +import { profileRoutes } from '../routes/profiles.js'; + +import type { PrismaClient } from '@prisma/client'; + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +const USER_ID = 'user-order-test'; +const USERNAME = 'orderuser'; + +const baseLink = (id: string, displayOrder: number): Record => ({ + id, + userId: USER_ID, + platform: 'github', + username: `gh-${id}`, + url: `https://github.com/gh-${id}`, + displayOrder, + createdAt: new Date(), +}); + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockRedis = { get: vi.fn(), set: vi.fn(), del: vi.fn() }; + +const mockPrisma = { + user: { + findUnique: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + }, + platformLink: { + create: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + aggregate: vi.fn(), + updateMany: vi.fn(), + }, + $transaction: vi.fn(), +} as unknown as PrismaClient; + +async function buildApp(): Promise> { + const app = Fastify({ logger: false }); + app.decorate('prisma', mockPrisma); + app.decorate('redis', mockRedis as any); + app.decorate('authenticate', async (request: any) => { + request.user = { id: USER_ID }; + }); + app.register(profileRoutes, { prefix: '/api/profiles' }); + await app.ready(); + return app; +} + +// ── Shared reset ────────────────────────────────────────────────────────────── + +beforeEach(() => { + vi.clearAllMocks(); + + (mockPrisma.user.findUnique as any).mockResolvedValue({ username: USERNAME }); + (mockPrisma.platformLink.aggregate as any).mockResolvedValue({ _max: { displayOrder: -1 } }); + (mockPrisma.platformLink.create as any).mockImplementation(({ data }: any) => + Promise.resolve(baseLink('link-new', data.displayOrder)), + ); + (mockPrisma.platformLink.findFirst as any).mockResolvedValue(baseLink('link-1', 0)); + (mockPrisma.platformLink.update as any).mockResolvedValue(baseLink('link-1', 0)); + (mockPrisma.platformLink.delete as any).mockResolvedValue({}); + (mockPrisma.platformLink.updateMany as any).mockResolvedValue({ count: 1 }); + (mockPrisma.$transaction as any).mockImplementation(async (opsOrFn: any) => { + if (typeof opsOrFn === 'function') { + return opsOrFn(mockPrisma); + } + return Promise.all(opsOrFn); + }); + + mockRedis.del.mockResolvedValue(1); + mockRedis.get.mockResolvedValue(null); + mockRedis.set.mockResolvedValue('OK'); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 1. Normal create assigns next display order +// ───────────────────────────────────────────────────────────────────────────── + +describe('createPlatformLink — display order assignment', () => { + it('assigns displayOrder 0 when user has no links', async () => { + (mockPrisma.platformLink.aggregate as any).mockResolvedValue({ _max: { displayOrder: null } }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'user1' }, + }); + + expect(res.statusCode).toBe(201); + const createCall = (mockPrisma.platformLink.create as any).mock.calls[0][0]; + expect(createCall.data.displayOrder).toBe(0); + }); + + it('assigns max+1 when existing links are present', async () => { + (mockPrisma.platformLink.aggregate as any).mockResolvedValue({ _max: { displayOrder: 3 } }); + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'twitter', username: 'user2' }, + }); + + expect(res.statusCode).toBe(201); + const createCall = (mockPrisma.platformLink.create as any).mock.calls[0][0]; + expect(createCall.data.displayOrder).toBe(4); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 2. Retry on P2002 (unique constraint conflict) +// ───────────────────────────────────────────────────────────────────────────── + +describe('createPlatformLink — retry on P2002', () => { + it('retries once after a P2002 conflict and succeeds on next attempt', async () => { + const p2002 = Object.assign(new Error('Unique constraint failed on (user_id, display_order)'), { + code: 'P2002', + }); + + // First create call: conflict; second: success + (mockPrisma.platformLink.create as any) + .mockRejectedValueOnce(p2002) + .mockResolvedValueOnce(baseLink('link-retry', 1)); + + // Second aggregate call returns updated max + (mockPrisma.platformLink.aggregate as any) + .mockResolvedValueOnce({ _max: { displayOrder: 0 } }) + .mockResolvedValueOnce({ _max: { displayOrder: 0 } }); + + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'race-user' }, + }); + + expect(res.statusCode).toBe(201); + expect((mockPrisma.platformLink.create as any).mock.calls).toHaveLength(2); + }); + + it('retries up to 5 times then propagates the error', async () => { + const p2002 = Object.assign(new Error('Unique constraint failed on (user_id, display_order)'), { + code: 'P2002', + }); + (mockPrisma.platformLink.create as any).mockRejectedValue(p2002); + (mockPrisma.platformLink.aggregate as any).mockResolvedValue({ _max: { displayOrder: 0 } }); + + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'always-conflict' }, + }); + + expect(res.statusCode).toBe(500); + expect((mockPrisma.platformLink.create as any).mock.calls).toHaveLength(5); + }); + + it('does not retry on non-P2002 errors', async () => { + (mockPrisma.platformLink.create as any).mockRejectedValue(new Error('Connection refused')); + + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'db-down' }, + }); + + expect(res.statusCode).toBe(500); + expect((mockPrisma.platformLink.create as any).mock.calls).toHaveLength(1); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 3. Simulated concurrent creates +// ───────────────────────────────────────────────────────────────────────────── + +describe('createPlatformLink — concurrent request simulation', () => { + it('two simultaneous creates both succeed via retry when first conflicts', async () => { + const p2002 = Object.assign(new Error('Unique constraint failed on (user_id, display_order)'), { + code: 'P2002', + }); + + let createCallCount = 0; + (mockPrisma.platformLink.aggregate as any).mockImplementation(() => { + // Both reads see max=2 before either inserts + return Promise.resolve({ _max: { displayOrder: 2 } }); + }); + (mockPrisma.platformLink.create as any).mockImplementation(({ data }: any) => { + createCallCount++; + // Simulate: first two calls (both at order=3) conflict; retries succeed + if (createCallCount <= 2) { + return Promise.reject(p2002); + } + return Promise.resolve(baseLink(`link-${createCallCount}`, data.displayOrder)); + }); + + const app = await buildApp(); + + const [res1, res2] = await Promise.all([ + app.inject({ method: 'POST', url: '/api/profiles/me/links', payload: { platform: 'github', username: 'u1' } }), + app.inject({ method: 'POST', url: '/api/profiles/me/links', payload: { platform: 'twitter', username: 'u2' } }), + ]); + + expect(res1.statusCode).toBe(201); + expect(res2.statusCode).toBe(201); + }); + + it('five parallel creates all resolve without error when retries succeed', async () => { + const p2002 = Object.assign(new Error('Unique constraint failed on (user_id, display_order)'), { + code: 'P2002', + }); + + // Alternate: first call of each group conflicts once, retry succeeds + let callCount = 0; + (mockPrisma.platformLink.create as any).mockImplementation(({ data }: any) => { + callCount++; + if (callCount % 2 === 1) { + return Promise.reject(p2002); + } + return Promise.resolve(baseLink(`link-${callCount}`, data.displayOrder)); + }); + + const app = await buildApp(); + + const results = await Promise.all( + Array.from({ length: 5 }, (_, i) => + app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: `user-${i}` }, + }), + ), + ); + + for (const res of results) { + expect(res.statusCode).toBe(201); + } + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 4. Reorder — two-phase transaction +// ───────────────────────────────────────────────────────────────────────────── + +describe('reorderLinks — two-phase transaction', () => { + it('calls $transaction with a callback (interactive form)', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { + links: [ + { id: '11111111-1111-1111-1111-111111111111', displayOrder: 0 }, + { id: '22222222-2222-2222-2222-222222222222', displayOrder: 1 }, + ], + }, + }); + + expect(res.statusCode).toBe(200); + const txArg = (mockPrisma.$transaction as any).mock.calls[0][0]; + expect(typeof txArg).toBe('function'); + }); + + it('issues updateMany for temp positions then final positions (two phases)', async () => { + const updateManyCalls: number[] = []; + (mockPrisma.platformLink.updateMany as any).mockImplementation(({ data }: any) => { + updateManyCalls.push(data.displayOrder); + return Promise.resolve({ count: 1 }); + }); + + const app = await buildApp(); + + await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { + links: [ + { id: '11111111-1111-1111-1111-111111111111', displayOrder: 0 }, + { id: '22222222-2222-2222-2222-222222222222', displayOrder: 1 }, + ], + }, + }); + + // 4 updateMany calls total: 2 for temp (1_000_000+), 2 for final + expect(updateManyCalls).toHaveLength(4); + expect(updateManyCalls[0]).toBe(1_000_000); + expect(updateManyCalls[1]).toBe(1_000_001); + expect(updateManyCalls[2]).toBe(0); + expect(updateManyCalls[3]).toBe(1); + }); + + it('reorder preserves correct final displayOrder values', async () => { + const finalOrders: number[] = []; + let callCount = 0; + (mockPrisma.platformLink.updateMany as any).mockImplementation(({ data }: any) => { + callCount++; + // Second half of calls are the final-phase updates + if (callCount > 2) { + finalOrders.push(data.displayOrder); + } + return Promise.resolve({ count: 1 }); + }); + + const app = await buildApp(); + + await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { + links: [ + { id: '11111111-1111-1111-1111-111111111111', displayOrder: 1 }, + { id: '22222222-2222-2222-2222-222222222222', displayOrder: 0 }, + ], + }, + }); + + expect(finalOrders).toEqual([1, 0]); + }); + + it('does not delete cache when transaction fails', async () => { + (mockPrisma.$transaction as any).mockRejectedValue(new Error('Transaction aborted')); + + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { links: [{ id: '11111111-1111-1111-1111-111111111111', displayOrder: 0 }] }, + }); + + expect(res.statusCode).toBe(500); + expect(mockRedis.del).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 5. Ordering integrity — sequential assignment +// ───────────────────────────────────────────────────────────────────────────── + +describe('ordering integrity', () => { + it('sequential creates produce strictly increasing displayOrder values', async () => { + let currentMax = -1; + (mockPrisma.platformLink.aggregate as any).mockImplementation(() => + Promise.resolve({ _max: { displayOrder: currentMax } }), + ); + (mockPrisma.platformLink.create as any).mockImplementation(({ data }: any) => { + currentMax = data.displayOrder; + return Promise.resolve(baseLink(`link-${currentMax}`, currentMax)); + }); + + const app = await buildApp(); + const orders: number[] = []; + + for (let i = 0; i < 5; i++) { + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: `user-${i}` }, + }); + expect(res.statusCode).toBe(201); + orders.push(res.json().displayOrder); + } + + for (let i = 1; i < orders.length; i++) { + expect(orders[i]).toBeGreaterThan(orders[i - 1]); + } + }); + + it('delete then create assigns a new sequential displayOrder', async () => { + (mockPrisma.platformLink.aggregate as any).mockResolvedValue({ _max: { displayOrder: 1 } }); + (mockPrisma.platformLink.create as any).mockResolvedValue(baseLink('link-new', 2)); + + const app = await buildApp(); + + const deleteRes = await app.inject({ + method: 'DELETE', + url: '/api/profiles/me/links/link-1', + }); + expect(deleteRes.statusCode).toBe(204); + + const createRes = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'twitter', username: 'userAfter' }, + }); + expect(createRes.statusCode).toBe(201); + + const createCall = (mockPrisma.platformLink.create as any).mock.calls[0][0]; + expect(createCall.data.displayOrder).toBe(2); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// 6. Regression — existing CRUD behavior preserved +// ───────────────────────────────────────────────────────────────────────────── + +describe('regression — existing behavior preserved', () => { + it('POST /links returns 201 with created link', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'POST', + url: '/api/profiles/me/links', + payload: { platform: 'github', username: 'regr-user' }, + }); + + expect(res.statusCode).toBe(201); + }); + + it('PUT /links/:id returns 200 with updated link', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/link-1', + payload: { platform: 'github', username: 'updated-handle' }, + }); + + expect(res.statusCode).toBe(200); + }); + + it('PUT /links/:id returns 404 when link not found', async () => { + (mockPrisma.platformLink.findFirst as any).mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/nonexistent', + payload: { platform: 'github', username: 'handle' }, + }); + + expect(res.statusCode).toBe(404); + }); + + it('DELETE /links/:id returns 204', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/profiles/me/links/link-1', + }); + + expect(res.statusCode).toBe(204); + }); + + it('DELETE /links/:id returns 404 when link not found', async () => { + (mockPrisma.platformLink.findFirst as any).mockResolvedValue(null); + const app = await buildApp(); + + const res = await app.inject({ + method: 'DELETE', + url: '/api/profiles/me/links/nonexistent', + }); + + expect(res.statusCode).toBe(404); + }); + + it('PUT /links/reorder returns 200 with message', async () => { + const app = await buildApp(); + + const res = await app.inject({ + method: 'PUT', + url: '/api/profiles/me/links/reorder', + payload: { + links: [{ id: '11111111-1111-1111-1111-111111111111', displayOrder: 0 }], + }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().message).toBe('Links reordered'); + }); +}); diff --git a/apps/backend/src/__tests__/profile-cache.test.ts b/apps/backend/src/__tests__/profile-cache.test.ts index 6a3d908f..ca94017c 100644 --- a/apps/backend/src/__tests__/profile-cache.test.ts +++ b/apps/backend/src/__tests__/profile-cache.test.ts @@ -130,9 +130,12 @@ beforeEach(() => { (mockPrisma.platformLink.update as any).mockResolvedValue(mockLink); (mockPrisma.platformLink.delete as any).mockResolvedValue({}); (mockPrisma.platformLink.updateMany as any).mockResolvedValue({ count: 1 }); - (mockPrisma.$transaction as any).mockImplementation(async (ops: any[]) => - Promise.all(ops), - ); + (mockPrisma.$transaction as any).mockImplementation(async (opsOrFn: any) => { + if (typeof opsOrFn === 'function') { + return opsOrFn(mockPrisma); + } + return Promise.all(opsOrFn); + }); // Default Redis mocks mockRedis.del.mockResolvedValue(1); diff --git a/apps/backend/src/services/profileService.ts b/apps/backend/src/services/profileService.ts index 257548a6..8ad02485 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -103,21 +103,32 @@ export async function createPlatformLink( linkData: any, ): Promise> { const url = linkData.url || getProfileUrl(linkData.platform, linkData.username); - const maxOrder = await app.prisma.platformLink.aggregate({ - where: { userId }, - _max: { displayOrder: true }, - }); - const link = await app.prisma.platformLink.create({ - data: { - userId, - platform: linkData.platform, - username: linkData.username, - url, - displayOrder: (maxOrder._max.displayOrder ?? -1) + 1, - }, - }); - await invalidateProfileCacheForUser(app, userId); - return link; + const MAX_RETRIES = 5; + for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { + try { + const maxOrder = await app.prisma.platformLink.aggregate({ + where: { userId }, + _max: { displayOrder: true }, + }); + const link = await app.prisma.platformLink.create({ + data: { + userId, + platform: linkData.platform, + username: linkData.username, + url, + displayOrder: (maxOrder._max.displayOrder ?? -1) + 1, + }, + }); + await invalidateProfileCacheForUser(app, userId); + return link; + } catch (err: any) { + if (err?.code === 'P2002' && attempt < MAX_RETRIES - 1) { + continue; + } + throw err; + } + } + throw new Error('Failed to allocate display order after max retries'); } export async function updatePlatformLink( @@ -158,14 +169,24 @@ export async function reorderLinks( userId: string, links: Array<{ id: string; displayOrder: number }>, ): Promise<{ message: string }> { - await app.prisma.$transaction( - links.map((link) => - app.prisma.platformLink.updateMany({ + // Two-phase update prevents unique constraint conflicts when positions swap. + // Phase 1 moves all rows to a collision-free temporary range; phase 2 sets + // the final positions once the original slots are vacated. + const TEMP_OFFSET = 1_000_000; + await app.prisma.$transaction(async (tx) => { + for (const link of links) { + await tx.platformLink.updateMany({ + where: { id: link.id, userId }, + data: { displayOrder: link.displayOrder + TEMP_OFFSET }, + }); + } + for (const link of links) { + await tx.platformLink.updateMany({ where: { id: link.id, userId }, data: { displayOrder: link.displayOrder }, - }), - ), - ); + }); + } + }); await invalidateProfileCacheForUser(app, userId); return { message: 'Links reordered' }; }