Skip to content

Commit 00a9ec9

Browse files
author
Amrit
committed
feat: add public profile OG previews
1 parent 02320c0 commit 00a9ec9

8 files changed

Lines changed: 687 additions & 115 deletions

File tree

apps/backend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@fastify/rate-limit": "^10.3.0",
2727
"@fastify/static": "^8.0.0",
2828
"@prisma/client": "^6.0.0",
29+
"@resvg/resvg-js": "^2.6.2",
2930
"dotenv": "^16.4.0",
3031
"fastify": "^5.0.0",
3132
"fastify-plugin": "^5.0.0",

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

Lines changed: 143 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { describe, it, expect, beforeEach, vi } from 'vitest';
2-
import Fastify from 'fastify';
31
import jwt from '@fastify/jwt';
2+
import Fastify from 'fastify';
3+
import { describe, it, expect, beforeEach, vi } from 'vitest';
4+
45
import { publicRoutes } from '../routes/public.js';
6+
import { generateOgImage } from '../utils/og-image.js';
7+
import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';
8+
59
import type { PrismaClient } from '@prisma/client';
610

11+
712
// ── Mock QR utilities ─────────────────────────────────────────────────────────
813
// Prevents real QR rasterisation (and any native canvas/image deps) from running
914
// during unit tests. The stubs return minimal valid values that satisfy the
@@ -13,7 +18,12 @@ vi.mock('../utils/qr.js', () => ({
1318
generateQRSvg: vi.fn().mockResolvedValue('<svg>fake</svg>'),
1419
}));
1520

16-
import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';
21+
// ── Mock OG image utility ─────────────────────────────────────────────────────
22+
// Prevents actual Resvg/resvg-js and external avatar-fetch calls in tests.
23+
vi.mock('../utils/og-image.js', () => ({
24+
generateOgImage: vi.fn().mockResolvedValue(Buffer.from('fake-og-png')),
25+
}));
26+
1727

1828
const mockUser = {
1929
id: 'user-123',
@@ -50,7 +60,7 @@ const mockRedis = {
5060
del: vi.fn().mockResolvedValue(1),
5161
};
5262

53-
async function buildApp() {
63+
async function buildApp(): Promise<ReturnType<typeof Fastify>> {
5464
const app = Fastify();
5565
// Register JWT so app.jwt.sign() is available for the qr-session route.
5666
// @fastify/jwt also adds request.jwtVerify(), which throws when no valid
@@ -464,3 +474,132 @@ describe('GET /api/public/:username/qr-session', () => {
464474
);
465475
});
466476
});
477+
478+
// ─── OG image endpoint ────────────────────────────────────────────────────────
479+
480+
// The minimal user shape returned by the OG image DB query (select projection).
481+
const mockOgUser = {
482+
displayName: 'Test User',
483+
bio: 'Building cool things.',
484+
avatarUrl: null,
485+
accentColor: '#6366f1',
486+
_count: { platformLinks: 3 },
487+
platformLinks: [
488+
{ platform: 'github' },
489+
{ platform: 'linkedin' },
490+
{ platform: 'twitter' },
491+
],
492+
};
493+
494+
describe('GET /api/public/:username/og-image', () => {
495+
beforeEach(() => {
496+
vi.clearAllMocks();
497+
// Restore default mock behaviours after clearAllMocks.
498+
(generateOgImage as ReturnType<typeof vi.fn>).mockResolvedValue(
499+
Buffer.from('fake-og-png'),
500+
);
501+
(generateQRBuffer as ReturnType<typeof vi.fn>).mockResolvedValue(
502+
Buffer.from('fake-png'),
503+
);
504+
(generateQRSvg as ReturnType<typeof vi.fn>).mockResolvedValue(
505+
'<svg>fake</svg>',
506+
);
507+
mockRedis.get.mockResolvedValue(null);
508+
mockRedis.set.mockResolvedValue('OK');
509+
mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() });
510+
});
511+
512+
it('returns 200 with image/png content-type for an existing user', async () => {
513+
mockPrisma.user.findUnique.mockResolvedValue(mockOgUser);
514+
const app = await buildApp();
515+
516+
const res = await app.inject({
517+
method: 'GET',
518+
url: '/api/public/testuser/og-image',
519+
});
520+
521+
expect(res.statusCode).toBe(200);
522+
expect(res.headers['content-type']).toMatch(/image\/png/);
523+
expect(res.headers['cache-control']).toBe(
524+
'public, max-age=86400, stale-while-revalidate=3600',
525+
);
526+
});
527+
528+
it('returns 404 for an unknown username', async () => {
529+
mockPrisma.user.findUnique.mockResolvedValue(null);
530+
const app = await buildApp();
531+
532+
const res = await app.inject({
533+
method: 'GET',
534+
url: '/api/public/nobody/og-image',
535+
});
536+
537+
expect(res.statusCode).toBe(404);
538+
expect(res.json().error).toBe('User not found');
539+
});
540+
541+
it('returns X-Cache: MISS and calls generateOgImage on a fresh request', async () => {
542+
mockPrisma.user.findUnique.mockResolvedValue(mockOgUser);
543+
const app = await buildApp();
544+
545+
const res = await app.inject({
546+
method: 'GET',
547+
url: '/api/public/testuser/og-image',
548+
});
549+
550+
expect(res.statusCode).toBe(200);
551+
expect(res.headers['x-cache']).toBe('MISS');
552+
expect(generateOgImage).toHaveBeenCalledOnce();
553+
});
554+
555+
it('writes the generated PNG to Redis with a 24 h TTL', async () => {
556+
mockPrisma.user.findUnique.mockResolvedValue(mockOgUser);
557+
const app = await buildApp();
558+
559+
await app.inject({
560+
method: 'GET',
561+
url: '/api/public/testuser/og-image',
562+
});
563+
564+
expect(mockRedis.set).toHaveBeenCalledWith(
565+
'og-image:testuser',
566+
expect.any(String), // base64-encoded PNG
567+
'EX',
568+
86400,
569+
);
570+
});
571+
572+
it('returns X-Cache: HIT and skips DB + generateOgImage when cached', async () => {
573+
// Simulate a warm Redis cache entry (base64-encoded PNG).
574+
const cachedPng = Buffer.from('fake-og-png').toString('base64');
575+
mockRedis.get.mockResolvedValue(cachedPng);
576+
577+
const app = await buildApp();
578+
const res = await app.inject({
579+
method: 'GET',
580+
url: '/api/public/testuser/og-image',
581+
});
582+
583+
expect(res.statusCode).toBe(200);
584+
expect(res.headers['x-cache']).toBe('HIT');
585+
// DB must not be queried and the generator must not run.
586+
expect(mockPrisma.user.findUnique).not.toHaveBeenCalled();
587+
expect(generateOgImage).not.toHaveBeenCalled();
588+
});
589+
590+
it('returns 500 when generateOgImage throws', async () => {
591+
mockPrisma.user.findUnique.mockResolvedValue(mockOgUser);
592+
(generateOgImage as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
593+
new Error('resvg render error'),
594+
);
595+
const app = await buildApp();
596+
597+
const res = await app.inject({
598+
method: 'GET',
599+
url: '/api/public/testuser/og-image',
600+
});
601+
602+
expect(res.statusCode).toBe(500);
603+
expect(res.json().error).toBe('Failed to generate OG image');
604+
});
605+
});

0 commit comments

Comments
 (0)