Skip to content
Merged
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
31 changes: 20 additions & 11 deletions apps/api/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,25 +40,33 @@ vi.mock('../jobs/seedPlans', () => ({
}))

describe('Server entry point (index.ts)', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
})

it('starts the server on the configured port', async () => {
await import('../index')

expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function))
await vi.waitFor(() => {
expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function))
})
})

it('logs server start message', async () => {
await import('../index')

expect(mockLogger.info).toHaveBeenCalledWith(
{ port: 4000 },
'Server started',
)
await vi.waitFor(() => {
expect(mockLogger.info).toHaveBeenCalledWith(
{ port: 4000 },
'Server started',
)
})
})

it('starts health check job when interval is configured', async () => {
await import('../index')

expect(mockStartHealthCheckJob).toHaveBeenCalledWith(3600000)
await vi.waitFor(() => {
expect(mockStartHealthCheckJob).toHaveBeenCalledWith(3600000)
})
expect(mockLogger.info).toHaveBeenCalledWith(
{ interval: 3600000 },
'Health check job started',
Expand All @@ -67,7 +75,8 @@ describe('Server entry point (index.ts)', () => {

it('seeds plans on startup (fire-and-forget)', async () => {
await import('../index')

expect(mockSeedPlans).toHaveBeenCalled()
await vi.waitFor(() => {
expect(mockSeedPlans).toHaveBeenCalled()
})
})
})
121 changes: 101 additions & 20 deletions apps/api/src/controllers/__tests__/qr.controller.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import type { Request, Response, NextFunction } from 'express'

const mockDb = vi.hoisted(() => ({
select: vi.fn(),
}))

vi.mock('../../db', () => ({
db: mockDb,
}))

vi.mock('../../utils/env', () => ({
env: { BASE_URL: 'http://localhost:3000' },
}))

vi.mock('../../services/qr.service', () => ({
generateQrCode: vi.fn(),
}))

vi.mock('../../services/url.services', () => ({
resolveUrl: vi.fn(),
getQrCache: vi.fn(),
setQrCache: vi.fn(),
deleteAllQrCaches: vi.fn(),
}))

import * as qrController from '../qr.controller'

function mockChain() {
const chain: any = { limit: vi.fn() }
chain.where = vi.fn().mockReturnValue(chain)
chain.from = vi.fn().mockReturnValue(chain)
chain.limit = vi.fn().mockResolvedValue([])
return chain
}

function mockReq(overrides: Partial<Request> = {}): Request {
return {
user: { id: 'user-1', role: 'user' } as any,
Expand All @@ -34,83 +56,142 @@ beforeEach(() => {
})

describe('generateQr', () => {
function setupDbRow(overrides = {}) {
const chain = mockChain()
chain.limit.mockResolvedValue([{ qrExpiresAt: null, userId: 'user-1', ...overrides }])
mockDb.select.mockReturnValue(chain)
return chain
}

it('generates PNG QR code', async () => {
setupDbRow()
const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } })
const res = mockRes()
const next = vi.fn()

const { resolveUrl } = await import('../../services/url.services')
const { getQrCache } = await import('../../services/url.services')
const { generateQrCode } = await import('../../services/qr.service')

vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any)
vi.mocked(generateQrCode).mockResolvedValueOnce(Buffer.from('png-data'))
vi.mocked(getQrCache).mockResolvedValue(null as any)
vi.mocked(generateQrCode).mockResolvedValue(Buffer.from('png-data'))

await qrController.generateQr(req, res, next)

expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'png', undefined)
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png')
expect(res.send).toHaveBeenCalledWith(Buffer.from('png-data'))
})

it('generates SVG QR code', async () => {
setupDbRow()
const req = mockReq({ params: { code: 'abc' }, query: { format: 'svg' } })
const res = mockRes()
const next = vi.fn()

const { resolveUrl } = await import('../../services/url.services')
const { getQrCache } = await import('../../services/url.services')
const { generateQrCode } = await import('../../services/qr.service')

vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any)
vi.mocked(generateQrCode).mockResolvedValueOnce('<svg>...</svg>')
vi.mocked(getQrCache).mockResolvedValue(null as any)
vi.mocked(generateQrCode).mockResolvedValue('<svg>...</svg>')

await qrController.generateQr(req, res, next)

expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'svg', undefined)
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml')
expect(res.send).toHaveBeenCalledWith('<svg>...</svg>')
})

it('generates QR with logo', async () => {
setupDbRow()
const req = mockReq({ params: { code: 'abc' }, query: { format: 'png', logo: 'https://example.com/logo.png' } })
const res = mockRes()
const next = vi.fn()

const { resolveUrl } = await import('../../services/url.services')
const { getQrCache } = await import('../../services/url.services')
const { generateQrCode } = await import('../../services/qr.service')

vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any)
vi.mocked(generateQrCode).mockResolvedValueOnce(Buffer.from('png-with-logo'))
vi.mocked(getQrCache).mockResolvedValue(null as any)
vi.mocked(generateQrCode).mockResolvedValue(Buffer.from('png-with-logo'))

await qrController.generateQr(req, res, next)

expect(generateQrCode).toHaveBeenCalledWith('https://example.com', 'png', 'https://example.com/logo.png')
expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'png', 'https://example.com/logo.png')
expect(res.send).toHaveBeenCalledWith(Buffer.from('png-with-logo'))
})

it('defaults to PNG format', async () => {
setupDbRow()
const req = mockReq({ params: { code: 'abc' }, query: {} })
const res = mockRes()
const next = vi.fn()

const { resolveUrl } = await import('../../services/url.services')
const { getQrCache } = await import('../../services/url.services')
const { generateQrCode } = await import('../../services/qr.service')

vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any)
vi.mocked(generateQrCode).mockResolvedValueOnce(Buffer.from('png-data'))
vi.mocked(getQrCache).mockResolvedValue(null as any)
vi.mocked(generateQrCode).mockResolvedValue(Buffer.from('png-data'))

await qrController.generateQr(req, res, next)

expect(generateQrCode).toHaveBeenCalledWith('https://example.com', 'png', undefined)
expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'png', undefined)
})

it('passes errors to next', async () => {
const req = mockReq({ params: { code: 'abc' }, query: {} })
it('returns 410 when QR has expired', async () => {
setupDbRow({ qrExpiresAt: new Date('2020-01-01') })
const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } })
const res = mockRes()
const next = vi.fn()

await qrController.generateQr(req, res, next)

expect(res.status).toHaveBeenCalledWith(410)
expect(res.json).toHaveBeenCalledWith({
error: 'QR_CODE_EXPIRED',
message: expect.stringContaining('expired'),
expiredAt: expect.any(String),
})
})

it('returns cached QR without regenerating', async () => {
setupDbRow()
const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } })
const res = mockRes()
const next = vi.fn()

const { resolveUrl } = await import('../../services/url.services')
vi.mocked(resolveUrl).mockRejectedValueOnce(new Error('not found'))
const { getQrCache } = await import('../../services/url.services')
const { generateQrCode } = await import('../../services/qr.service')

vi.mocked(getQrCache).mockResolvedValue({ data: Buffer.from('cached-qr').toString('base64'), createdAt: new Date() })

await qrController.generateQr(req, res, next)

expect(generateQrCode).not.toHaveBeenCalled()
expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png')
expect(res.send).toHaveBeenCalledWith(expect.any(Buffer))
})

it('returns 404 if code does not exist', async () => {
const chain = mockChain()
chain.limit.mockResolvedValue([])
mockDb.select.mockReturnValue(chain)

const req = mockReq({ params: { code: 'missing' }, query: { format: 'png' } })
const res = mockRes()
const next = vi.fn()

await qrController.generateQr(req, res, next)

expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404 }))
})

it('returns 404 if user does not own the URL', async () => {
setupDbRow({ userId: 'other-user' })
const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } })
const res = mockRes()
const next = vi.fn()

await qrController.generateQr(req, res, next)

expect(next).toHaveBeenCalledWith(expect.any(Error))
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404 }))
})
})
102 changes: 85 additions & 17 deletions apps/api/src/controllers/qr.controller.ts
Original file line number Diff line number Diff line change
@@ -1,34 +1,102 @@
import type { Request, Response, NextFunction } from 'express'
import { z } from 'zod'
import { and, eq, isNull } from 'drizzle-orm'
import { getUrlParamsSchema } from '../validators/url.validators'
import { qrQuerySchema, regenerateQrSchema } from '../validators/qr.validators'
import { generateQrCode } from '../services/qr.service'
import { resolveUrl } from '../services/url.services'
import { getQrCache, setQrCache, deleteAllQrCaches } from '../services/url.services'
import { db } from '../db'
import { urls } from '../db/schema'
import { env } from '../utils/env'

const qrQuerySchema = z.object({
format: z.enum(['png', 'svg']).default('png'),
logo: z.string().optional(),
})
import { AppError } from '../utils/AppError'

export async function generateQr(req: Request, res: Response, next: NextFunction) {
try {
const { code } = getUrlParamsSchema.parse(req.params)
const { format, logo } = qrQuerySchema.parse(req.query)

const urlRow = await resolveUrl(code)
const targetUrl = urlRow.url
const [urlRow] = await db
.select({ qrExpiresAt: urls.qrExpiresAt, userId: urls.userId })
.from(urls)
.where(and(eq(urls.code, code), isNull(urls.deletedAt)))
.limit(1)

if (!urlRow) {
throw new AppError('URL not found', 404, 'URL_NOT_FOUND')
}

if (urlRow.userId !== req.user!.id) {
throw new AppError('URL not found', 404, 'URL_NOT_FOUND')
}

if (urlRow.qrExpiresAt && urlRow.qrExpiresAt < new Date()) {
res.status(410).json({
error: 'QR_CODE_EXPIRED',
message: `This QR code expired on ${urlRow.qrExpiresAt.toISOString()}`,
expiredAt: urlRow.qrExpiresAt.toISOString(),
})
return
}

const cached = await getQrCache(code, format, logo)
if (cached) {
const buf = Buffer.from(cached.data, 'base64')
res.setHeader('Content-Type', format === 'svg' ? 'image/svg+xml' : 'image/png')
res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.${format}"`)
res.send(buf)
return
}

const targetUrl = `${env.BASE_URL}/${code}`
const result = await generateQrCode(targetUrl, format, logo)

if (format === 'svg') {
res.setHeader('Content-Type', 'image/svg+xml')
res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.svg"`)
res.send(result)
} else {
res.setHeader('Content-Type', 'image/png')
res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.png"`)
res.send(result)
const data = format === 'svg' ? result.toString() : (result as Buffer).toString('base64')
await setQrCache(code, format, data, logo)

res.setHeader('Content-Type', format === 'svg' ? 'image/svg+xml' : 'image/png')
res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.${format}"`)
res.send(result)
} catch (err) {
next(err)
}
}

export async function regenerateQr(req: Request, res: Response, next: NextFunction) {
try {
const { code } = getUrlParamsSchema.parse(req.params)
const { format, logo } = qrQuerySchema.parse(req.query)
const { expiresAt } = regenerateQrSchema.parse(req.body)

const [urlRow] = await db
.select({ qrExpiresAt: urls.qrExpiresAt, userId: urls.userId })
.from(urls)
.where(and(eq(urls.code, code), isNull(urls.deletedAt)))
.limit(1)

if (!urlRow) {
throw new AppError('URL not found', 404, 'URL_NOT_FOUND')
}

if (urlRow.userId !== req.user!.id) {
throw new AppError('URL not found', 404, 'URL_NOT_FOUND')
}

if (!urlRow.qrExpiresAt || urlRow.qrExpiresAt >= new Date()) {
throw new AppError('QR code has not expired yet', 400, 'QR_NOT_YET_EXPIRED')
}

await deleteAllQrCaches(code)

await db.update(urls).set({ qrExpiresAt: new Date(expiresAt) }).where(eq(urls.code, code))

const targetUrl = `${env.BASE_URL}/${code}`
const result = await generateQrCode(targetUrl, format, logo)

const data = format === 'svg' ? result.toString() : (result as Buffer).toString('base64')
await setQrCache(code, format, data, logo)

res.setHeader('Content-Type', format === 'svg' ? 'image/svg+xml' : 'image/png')
res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.${format}"`)
res.send(result)
} catch (err) {
next(err)
}
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/controllers/url.controllers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export async function getUrlInfo(req: Request, res: Response, next: NextFunction
activeAt: url.activeAt?.toISOString() ?? null,
hasPassword: !!url.passwordHash,
blockBots: url.blockBots,
qrExpiresAt: url.qrExpiresAt?.toISOString() ?? null,
createdAt: url.createdAt.toISOString(),
},
})
Expand Down
Loading
Loading