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__/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__/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 new file mode 100644 index 00000000..ca94017c --- /dev/null +++ b/apps/backend/src/__tests__/profile-cache.test.ts @@ -0,0 +1,576 @@ +/** + * 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 (opsOrFn: any) => { + if (typeof opsOrFn === 'function') { + return opsOrFn(mockPrisma); + } + return Promise.all(opsOrFn); + }); + + // 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..8ad02485 100644 --- a/apps/backend/src/services/profileService.ts +++ b/apps/backend/src/services/profileService.ts @@ -1,74 +1,192 @@ -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 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(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 }> { + // 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' }; } 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(),