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
148 changes: 147 additions & 1 deletion apps/backend/src/__tests__/public.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify from 'fastify';

Check failure on line 2 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`fastify` import should occur before import of `vitest`
import jwt from '@fastify/jwt';

Check failure on line 3 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`@fastify/jwt` import should occur before import of `vitest`

Check failure on line 3 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { publicRoutes } from '../routes/public.js';

Check failure on line 4 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import type { PrismaClient } from '@prisma/client';
Expand All @@ -13,7 +13,7 @@
generateQRSvg: vi.fn().mockResolvedValue('<svg>fake</svg>'),
}));

import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';

Check failure on line 16 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`../utils/qr.js` import should occur before type import of `@prisma/client`

Check failure on line 16 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Import in body of module; reorder to top

const mockUser = {
id: 'user-123',
Expand All @@ -39,7 +39,9 @@
followLog: {
findMany: vi.fn().mockResolvedValue([]),
},
card: {} as any,
card: {
findUnique: vi.fn(),
},
};

// ── Redis mock ────────────────────────────────────────────────────────────────
Expand All @@ -50,7 +52,7 @@
del: vi.fn().mockResolvedValue(1),
};

async function buildApp() {

Check warning on line 55 in apps/backend/src/__tests__/public.test.ts

View workflow job for this annotation

GitHub Actions / backend-ci

Missing return type on function
const app = Fastify();
// Register JWT so app.jwt.sign() is available for the qr-session route.
// @fastify/jwt also adds request.jwtVerify(), which throws when no valid
Expand Down Expand Up @@ -464,3 +466,147 @@
);
});
});

// ─── Direct card view tracking (Issue #495) ───────────────────────────────────

const mockCard = {
id: 'card-abc',
title: 'My Dev Card',
user: {
id: 'user-123',
username: 'testuser',
displayName: 'Test User',
bio: null,
avatarUrl: null,
accentColor: '#ffffff',
},
cardLinks: [],
};

describe('GET /api/public/card/:cardId — direct card view tracking', () => {
beforeEach(() => {
vi.clearAllMocks();
mockRedis.get.mockResolvedValue(null);
mockRedis.set.mockResolvedValue('OK');
mockPrisma.cardView.create.mockReturnValue({ catch: vi.fn() });
mockPrisma.card.findUnique.mockResolvedValue(mockCard);
});

it('returns 200 with correct card shape', async () => {
const app = await buildApp();
const res = await app.inject({
method: 'GET',
url: '/api/public/card/card-abc',
});

expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.id).toBe('card-abc');
expect(body.title).toBe('My Dev Card');
expect(body.owner.username).toBe('testuser');
expect(Array.isArray(body.links)).toBe(true);
});

it('returns 404 when card does not exist', async () => {
mockPrisma.card.findUnique.mockResolvedValue(null);

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

expect(res.statusCode).toBe(404);
expect(res.json().error).toBe('Card not found');
});

it('records CardView when authenticated viewer requests card', async () => {
const app = await buildApp();

// Sign a JWT for a different user (viewer-456 ≠ owner user-123)
const token = app.jwt.sign({ id: 'viewer-456' });

const res = await app.inject({
method: 'GET',
url: '/api/public/card/card-abc',
headers: { Authorization: `Bearer ${token}` },
});

expect(res.statusCode).toBe(200);
expect(mockPrisma.cardView.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
ownerId: 'user-123',
cardId: 'card-abc',
viewerId: 'viewer-456',
}),
}),
);
});

it('does not record CardView for anonymous (unauthenticated) request', async () => {
const app = await buildApp();

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

expect(res.statusCode).toBe(200);
expect(mockPrisma.cardView.create).not.toHaveBeenCalled();
});

it('does not record CardView when viewer is the card owner', async () => {
const app = await buildApp();

// Sign JWT as the owner (user-123 === card.user.id)
const token = app.jwt.sign({ id: 'user-123' });

const res = await app.inject({
method: 'GET',
url: '/api/public/card/card-abc',
headers: { Authorization: `Bearer ${token}` },
});

expect(res.statusCode).toBe(200);
expect(mockPrisma.cardView.create).not.toHaveBeenCalled();
});

it('uses "link" as default source for direct card views', async () => {
const app = await buildApp();
const token = app.jwt.sign({ id: 'viewer-456' });

await app.inject({
method: 'GET',
url: '/api/public/card/card-abc',
headers: { Authorization: `Bearer ${token}` },
});

expect(mockPrisma.cardView.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
source: 'link',
}),
}),
);
});

it('records CardView with custom source query param when provided', async () => {
const app = await buildApp();
const token = app.jwt.sign({ id: 'viewer-456' });

await app.inject({
method: 'GET',
url: '/api/public/card/card-abc?source=web',
headers: { Authorization: `Bearer ${token}` },
});

expect(mockPrisma.cardView.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
source: 'web',
}),
}),
);
});
});
74 changes: 74 additions & 0 deletions apps/backend/src/__tests__/slug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, it, expect, vi } from 'vitest';

import { createSlug, generateUniqueSlug } from '../utils/slug';

describe('createSlug', () => {
it('lowercases and trims input', () => {
expect(createSlug(' Hello World ')).toBe('hello-world');
});

it('replaces spaces with hyphens', () => {
expect(createSlug('My Team Name')).toBe('my-team-name');
});

it('strips non-alphanumeric characters', () => {
expect(createSlug('DevCard @Core!')).toBe('devcard-core');
});

it('collapses multiple hyphens', () => {
expect(createSlug('a--b---c')).toBe('a-b-c');
});

it('removes leading and trailing hyphens', () => {
expect(createSlug('--team--')).toBe('team');
});
});

describe('generateUniqueSlug', () => {
it('returns base slug when it is available', async () => {
const slugExists = vi.fn().mockResolvedValue(false);
const result = await generateUniqueSlug('My Team', slugExists);
expect(result).toBe('my-team');
expect(slugExists).toHaveBeenCalledOnce();
});

it('returns sequential numeric suffix when base slug is taken', async () => {
const slugExists = vi.fn()
.mockResolvedValueOnce(true) // my-team taken
.mockResolvedValueOnce(false); // my-team-1 free
const result = await generateUniqueSlug('My Team', slugExists);
expect(result).toBe('my-team-1');
});

it('increments suffix deterministically until a free slot is found', async () => {
const slugExists = vi.fn()
.mockResolvedValueOnce(true) // my-team
.mockResolvedValueOnce(true) // my-team-1
.mockResolvedValueOnce(true) // my-team-2
.mockResolvedValueOnce(false); // my-team-3 free
const result = await generateUniqueSlug('My Team', slugExists);
expect(result).toBe('my-team-3');
});

it('throws when all 10 suffix candidates are taken', async () => {
const slugExists = vi.fn().mockResolvedValue(true);
await expect(generateUniqueSlug('My Team', slugExists)).rejects.toThrow(
'Unable to generate unique slug',
);
expect(slugExists).toHaveBeenCalledTimes(11); // base + 10 suffixes
});

it('produces consistent slugs across concurrent calls for different inputs', async () => {
const takenSlugs = new Set<string>();
const slugExists = vi.fn(async (slug: string) => takenSlugs.has(slug));

const [a, b] = await Promise.all([
generateUniqueSlug('Alpha Team', slugExists),
generateUniqueSlug('Beta Team', slugExists),
]);

expect(a).toBe('alpha-team');
expect(b).toBe('beta-team');
expect(a).not.toBe(b);
});
});
52 changes: 48 additions & 4 deletions apps/backend/src/__tests__/team.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import { Prisma, TeamRole } from '@prisma/client';
import Fastify 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';

import type { PrismaClient } from '@prisma/client';
import type { FastifyInstance } from 'fastify';

// ─── Shared mock data ─────────────────────────────────────────────────────────

const MOCK_OWNER_ID = 'user-uuid-001';
Expand Down Expand Up @@ -92,7 +96,7 @@ const prismaMock = {

// ─── App factory ──────────────────────────────────────────────────────────────

let mockJwtVerify = vi.fn();
const mockJwtVerify = vi.fn();

async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
Expand All @@ -118,7 +122,7 @@ async function createTeam(
app: FastifyInstance,
body: Record<string, unknown>,
authenticated = true,
) {
): Promise<ReturnType<typeof app.inject>> {
return app.inject({
method: 'POST',
url: '/',
Expand Down Expand Up @@ -220,6 +224,46 @@ describe('Teams API', () => {
expect(res.statusCode).toBe(500);
expect(res.json()).toMatchObject({ error: 'Failed to create team' });
});

it('201 — retries and succeeds when first attempt loses slug race to concurrent request', async () => {
// First generateUniqueSlug: base slug appears available
prismaMock.team.findUnique.mockResolvedValueOnce(null);
// First $transaction: P2002 — another request inserted first
prismaMock.$transaction.mockRejectedValueOnce(
new Prisma.PrismaClientKnownRequestError('Unique constraint failed', { code: 'P2002', clientVersion: '0' }),
);
// Second generateUniqueSlug: base slug now taken, devcard-core-1 is free
prismaMock.team.findUnique.mockResolvedValueOnce(MOCK_TEAM); // devcard-core taken
prismaMock.team.findUnique.mockResolvedValueOnce(null); // devcard-core-1 free
// Second $transaction: succeeds with suffix slug
prismaMock.$transaction.mockImplementationOnce(async (cb: any) => {
return cb({
team: { create: vi.fn().mockResolvedValue({ ...MOCK_TEAM, slug: 'devcard-core-1' }) },
teamMember: { create: vi.fn().mockResolvedValue({}) },
});
});

const res = await createTeam(app, validBody);

expect(res.statusCode).toBe(201);
expect(res.json().slug).toBe('devcard-core-1');
});

it('409 — exhausts all retry attempts when DB rejects every slug with P2002', async () => {
const p2002 = new Prisma.PrismaClientKnownRequestError(
'Unique constraint failed on the fields: (`slug`)',
{ code: 'P2002', clientVersion: '0' },
);
// Slug always appears available at the application level
prismaMock.team.findUnique.mockResolvedValue(null);
// DB always rejects with P2002 (concurrent inserts won every race)
prismaMock.$transaction.mockRejectedValue(p2002);

const res = await createTeam(app, validBody);

expect(res.statusCode).toBe(409);
expect(prismaMock.$transaction).toHaveBeenCalledTimes(5);
});
});

// ── GET /:slug — public team profile ─────────────────────────────────────
Expand Down
Loading
Loading