Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions apps/backend/src/__tests__/profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,14 @@ const mockPrisma = {
},
};

const mockRedis = {
del: vi.fn(),
};

async function buildApp() {
const app = Fastify();
app.decorate('prisma', mockPrisma);
app.decorate('prisma', mockPrisma as any);
app.decorate('redis', mockRedis as any);
app.decorate('authenticate', async (request: any) => {
request.user = { id: 'user-123' };
});
Expand Down Expand Up @@ -67,6 +72,7 @@ describe('PUT /api/profiles/me', () => {

it('should update profile and return updated data', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
mockPrisma.user.update.mockResolvedValue({ ...mockUser, displayName: 'Updated Name' });
const app = await buildApp();
const res = await app.inject({
Expand All @@ -78,6 +84,22 @@ describe('PUT /api/profiles/me', () => {
expect(res.json().displayName).toBe('Updated Name');
});

it('should invalidate public profile cache after update', async () => {
mockPrisma.user.findFirst.mockResolvedValue(null);
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
mockPrisma.user.update.mockResolvedValue({ ...mockUser, username: 'newuser' });
const app = await buildApp();

const res = await app.inject({
method: 'PUT',
url: '/api/profiles/me',
payload: { username: 'newuser' },
});

expect(res.statusCode).toBe(200);
expect(mockRedis.del).toHaveBeenCalledWith('profile:testuser', 'profile:newuser');
});

it('should return 400 for invalid accentColor', async () => {
const app = await buildApp();
const res = await app.inject({
Expand All @@ -100,4 +122,4 @@ describe('PUT /api/profiles/me', () => {
expect(res.statusCode).toBe(409);
expect(res.json().error).toBe('Username already taken');
});
});
});
118 changes: 118 additions & 0 deletions apps/backend/src/__tests__/public.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';
import jwt from '@fastify/jwt';
import { publicRoutes } from '../routes/public.js';

const mockUser = {
id: 'user-123',
username: 'testuser',
displayName: 'Test User',
bio: 'Building things',
pronouns: null,
role: 'Engineer',
company: 'DevCard',
avatarUrl: null,
accentColor: '#ffffff',
platformLinks: [
{
id: 'link-1',
platform: 'github',
username: 'testuser',
url: 'https://github.com/testuser',
displayOrder: 0,
},
],
};

const redisStore = new Map<string, string>();

const mockRedis = {
get: vi.fn((key: string) => Promise.resolve(redisStore.get(key) ?? null)),
setex: vi.fn((key: string, _ttl: number, value: string) => {
redisStore.set(key, value);
return Promise.resolve('OK');
}),
};

const mockPrisma = {
user: {
findUnique: vi.fn(),
},
cardView: {
create: vi.fn(() => Promise.resolve({ id: 'view-1' })),
},
};

async function buildApp() {
const app = Fastify();
await app.register(jwt, { secret: 'test-secret' });
app.decorate('prisma', mockPrisma as any);
app.decorate('redis', mockRedis as any);
app.register(publicRoutes, { prefix: '/api/public' });
await app.ready();
return app;
}

describe('GET /api/public/:username', () => {
beforeEach(() => {
vi.clearAllMocks();
redisStore.clear();
});

it('should cache profile data after a MISS and serve repeat requests as HIT', async () => {
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const app = await buildApp();

const miss = await app.inject({ method: 'GET', url: '/api/public/testuser' });
expect(miss.statusCode).toBe(200);
expect(miss.headers['x-cache']).toBe('MISS');
expect(miss.headers['cache-control']).toBe('public, max-age=300, stale-while-revalidate=60');
expect(miss.json().displayName).toBe('Test User');
expect(mockPrisma.user.findUnique).toHaveBeenCalledTimes(1);
expect(mockRedis.setex).toHaveBeenCalledWith(
'profile:testuser',
300,
expect.any(String)
);

const hit = await app.inject({ method: 'GET', url: '/api/public/testuser' });
expect(hit.statusCode).toBe(200);
expect(hit.headers['x-cache']).toBe('HIT');
expect(hit.json().links[0].platform).toBe('github');
expect(mockPrisma.user.findUnique).toHaveBeenCalledTimes(1);
});

it('should return 404 without caching when the user is missing', async () => {
mockPrisma.user.findUnique.mockResolvedValue(null);
const app = await buildApp();

const res = await app.inject({ method: 'GET', url: '/api/public/missing' });

expect(res.statusCode).toBe(404);
expect(mockRedis.setex).not.toHaveBeenCalled();
});
});

describe('GET /api/public/:username/qr-session', () => {
beforeEach(() => {
vi.clearAllMocks();
redisStore.clear();
});

it('should return an offline-decodable signed token containing the profile snapshot', async () => {
mockPrisma.user.findUnique.mockResolvedValue(mockUser);
const app = await buildApp();

const res = await app.inject({ method: 'GET', url: '/api/public/testuser/qr-session' });

expect(res.statusCode).toBe(200);
expect(res.headers['x-cache']).toBe('MISS');
expect(res.json().expiresIn).toBe(600);

const decoded = app.jwt.verify(res.json().token) as any;
expect(decoded.type).toBe('qr-session');
expect(decoded.username).toBe('testuser');
expect(decoded.profile.displayName).toBe('Test User');
expect(decoded.exp - decoded.iat).toBe(600);
});
});
1 change: 1 addition & 0 deletions apps/backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export async function buildApp() {
await app.register(profileRoutes, { prefix: '/api/profiles' });
await app.register(cardRoutes, { prefix: '/api/cards' });
await app.register(publicRoutes, { prefix: '/api/u' });
await app.register(publicRoutes, { prefix: '/api/public' });
await app.register(followRoutes, { prefix: '/api/follow' });
await app.register(connectRoutes, { prefix: '/api/connect' });
await app.register(analyticsRoutes, { prefix: '/api/analytics' });
Expand Down
4 changes: 2 additions & 2 deletions apps/backend/src/routes/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ export async function analyticsRoutes(app: FastifyInstance) {
};
});

app.get('/views', {
app.get<{ Querystring: { page?: string, cardId?: string } }>('/views', {
preHandler: [app.authenticate],
}, async (request: FastifyRequest<{ Querystring: { page?: string, cardId?: string } }>, reply: FastifyReply) => {
}, async (request, reply) => {
const userId = (request.user as any).id;
const page = parseInt(request.query.page || '1', 10);
const limit = 20;
Expand Down
8 changes: 4 additions & 4 deletions apps/backend/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function authRoutes(app: FastifyInstance) {
const tokenData = (await tokenRes.json()) as any;

if (tokenData.error) {
app.log.error('GitHub token error:', tokenData);
app.log.error({ tokenData }, 'GitHub token error');
return reply.status(400).send({ error: 'Failed to authenticate with GitHub' });
}

Expand Down Expand Up @@ -134,7 +134,7 @@ export async function authRoutes(app: FastifyInstance) {

return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.error('GitHub auth error:', err);
app.log.error({ err }, 'GitHub auth error');
return reply.status(500).send({ error: 'Authentication failed' });
}
});
Expand Down Expand Up @@ -181,7 +181,7 @@ export async function authRoutes(app: FastifyInstance) {
const tokenData = (await tokenRes.json()) as any;

if (tokenData.error) {
app.log.error('Google token error:', tokenData);
app.log.error({ tokenData }, 'Google token error');
return reply.status(400).send({ error: 'Failed to authenticate with Google' });
}

Expand Down Expand Up @@ -235,7 +235,7 @@ export async function authRoutes(app: FastifyInstance) {

return reply.redirect(`${process.env.PUBLIC_APP_URL}/dashboard`);
} catch (err) {
app.log.error('Google auth error:', err);
app.log.error({ err }, 'Google auth error');
return reply.status(500).send({ error: 'Authentication failed' });
}
});
Expand Down
11 changes: 6 additions & 5 deletions apps/backend/src/routes/connect.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
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';
Expand Down Expand Up @@ -90,12 +91,12 @@ export async function connectRoutes(app: FastifyInstance) {
const tokenData = (await tokenRes.json()) as any;

if (tokenData.error) {
app.log.error('GitHub connect token error:', tokenData);
app.log.error({ tokenData }, 'GitHub connect token error');
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=connect_failed`);
}

// Encrypt and store the token
const encryptedToken = app.encryption.encrypt(tokenData.access_token);
const encryptedToken = encrypt(tokenData.access_token);

await app.prisma.oAuthToken.upsert({
where: {
Expand Down Expand Up @@ -125,17 +126,17 @@ export async function connectRoutes(app: FastifyInstance) {
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?connected=github`);

} catch (err) {
app.log.error('GitHub connect error:', err);
app.log.error({ err }, 'GitHub connect error');
return reply.redirect(`${process.env.PUBLIC_APP_URL}/settings?error=server_error`);
}
});


// ─── Disconnect ───

app.delete('/:platform', {
app.delete<{ Params: { platform: string } }>('/:platform', {
preHandler: [app.authenticate],
}, async (request: FastifyRequest<{ Params: { platform: string } }>, reply: FastifyReply) => {
}, async (request, reply) => {
const userId = (request.user as any).id;
const { platform } = request.params;

Expand Down
6 changes: 3 additions & 3 deletions apps/backend/src/routes/follow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,12 +53,12 @@ export async function followRoutes(app: FastifyInstance) {
status: 'success',
layer: 'api',
},
}).catch(err => app.log.error('Failed to log follow:', err));
}).catch((err: unknown) => app.log.error({ err }, 'Failed to log follow'));
}

return result;
} catch (err: any) {
app.log.error(`Follow error for ${platform}:`, err);
app.log.error({ err, platform }, 'Follow error');

app.prisma.followLog.create({
data: {
Expand All @@ -68,7 +68,7 @@ export async function followRoutes(app: FastifyInstance) {
status: 'error',
layer: 'api',
},
}).catch(e => app.log.error('Failed to log follow error:', e));
}).catch((err: unknown) => app.log.error({ err }, 'Failed to log follow error'));

return reply.status(500).send({ error: 'Follow action failed', message: err.message });
}
Expand Down
18 changes: 18 additions & 0 deletions apps/backend/src/routes/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ export async function profileRoutes(app: FastifyInstance) {
}
}

const current = await app.prisma.user.findUnique({
where: { id: userId },
select: { username: true },
});

const updated = await app.prisma.user.update({
where: { id: userId },
data: parsed.data,
Expand All @@ -82,6 +87,19 @@ export async function profileRoutes(app: FastifyInstance) {
},
});

const cacheKeys = new Set([
current?.username ? `profile:${current.username}` : null,
`profile:${updated.username}`,
].filter((key): key is string => Boolean(key)));

try {
if (cacheKeys.size > 0) {
await app.redis.del(...cacheKeys);
}
} catch (err) {
app.log.warn({ err, userId }, 'Failed to invalidate public profile cache');
}

return updated;
});

Expand Down
Loading