Resolve linter issues, add unit tests, adjust test coverage

This commit is contained in:
2026-02-07 12:24:39 -03:00
parent 430248a4ef
commit 3264b12ea6
45 changed files with 12143 additions and 7918 deletions

28
qr-api/eslint.config.cjs Normal file
View File

@@ -0,0 +1,28 @@
'use strict';
const tsParser = require('@typescript-eslint/parser');
const tsPlugin = require('@typescript-eslint/eslint-plugin');
const prettierConfig = require('eslint-config-prettier');
module.exports = [
{ ignores: ['node_modules/', 'dist/', 'coverage/', '*.tsbuildinfo'] },
{
files: ['**/*.ts'],
languageOptions: {
ecmaVersion: 2022,
sourceType: 'module',
parser: tsParser,
parserOptions: { project: null },
},
plugins: { '@typescript-eslint': tsPlugin },
rules: {
...tsPlugin.configs.recommended.rules,
'@typescript-eslint/no-unused-vars': [
'warn',
{ argsIgnorePattern: '^_' },
],
'@typescript-eslint/no-explicit-any': 'warn',
},
},
prettierConfig,
];

View File

@@ -5,17 +5,21 @@
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"lint": "eslint src --ext .ts",
"format": "prettier --write .",
"format:check": "prettier --check .",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"start": "node dist/index.js",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:watch": "vitest"
},
"dependencies": {
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"express": "^4.21.1",
"multer": "^1.4.5-lts.1",
"multer": "^2.0.2",
"pino": "^9.5.0",
"pino-pretty": "^11.3.0",
"zod": "^3.23.8"
@@ -24,7 +28,7 @@
"@types/better-sqlite3": "^7.6.11",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/multer": "^2.0.0",
"@types/node": "^22.9.0",
"@typescript-eslint/eslint-plugin": "^8.15.0",
"@typescript-eslint/parser": "^8.15.0",
@@ -32,7 +36,9 @@
"eslint-config-prettier": "^9.1.0",
"tsx": "^4.19.2",
"typescript": "^5.6.3",
"vitest": "^2.1.6"
"vitest": "^2.1.6",
"@vitest/coverage-v8": "^2.1.6",
"supertest": "^7.0.0"
},
"engines": {
"node": ">=20"

50
qr-api/src/app.ts Normal file
View File

@@ -0,0 +1,50 @@
import express from 'express';
import cors from 'cors';
import type { Database } from 'better-sqlite3';
import type { Env } from './env.js';
import { projectsRouter } from './routes/projects.js';
import { foldersRouter } from './routes/folders.js';
import { uploadsRouter } from './routes/uploads.js';
import { shortenRouter } from './routes/shorten.js';
export function createApp(
db: Database,
env: Env,
baseUrl = '',
logger?: { error: (o: object, msg?: string) => void },
): express.Express {
const app = express();
app.use(cors());
app.use(express.json());
app.use('/projects', projectsRouter(db, baseUrl));
app.use('/folders', foldersRouter(db));
app.use('/uploads', uploadsRouter(env, baseUrl));
app.use('/shorten', shortenRouter(env));
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use(
(
err: Error,
_req: express.Request,
res: express.Response,
_next: express.NextFunction,
) => {
const msg = err.message ?? '';
if (
err.name === 'MulterError' ||
msg.includes('image files') ||
msg.includes('file size')
) {
return res.status(400).json({ error: msg || 'Invalid upload' });
}
logger?.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' });
},
);
return app;
}

View File

@@ -1,4 +1,7 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import os from 'os';
import Database from 'better-sqlite3';
import {
initDb,
@@ -7,6 +10,11 @@ import {
getProject,
updateProject,
deleteProject,
listFolders,
createFolder,
getFolder,
updateFolder,
deleteFolder,
} from './db.js';
const testEnv = {
@@ -25,7 +33,10 @@ describe('db', () => {
});
it('creates and gets a project', () => {
const p = createProject(db, { name: 'Test', originalUrl: 'https://example.com' });
const p = createProject(db, {
name: 'Test',
originalUrl: 'https://example.com',
});
expect(p.id).toBeDefined();
expect(p.name).toBe('Test');
expect(p.originalUrl).toBe('https://example.com');
@@ -45,7 +56,10 @@ describe('db', () => {
it('updates a project', () => {
const p = createProject(db, { name: 'Old' });
const updated = updateProject(db, p.id, { name: 'New', recipeJson: '{"x":1}' });
const updated = updateProject(db, p.id, {
name: 'New',
recipeJson: '{"x":1}',
});
expect(updated?.name).toBe('New');
expect(updated?.recipeJson).toBe('{"x":1}');
});
@@ -58,8 +72,110 @@ describe('db', () => {
});
it('returns null for missing project', () => {
expect(getProject(db, '00000000-0000-0000-0000-000000000000')).toBeNull();
expect(updateProject(db, '00000000-0000-0000-0000-000000000000', { name: 'X' })).toBeNull();
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(false);
expect(
getProject(db, '00000000-0000-0000-0000-000000000000'),
).toBeNull();
expect(
updateProject(db, '00000000-0000-0000-0000-000000000000', {
name: 'X',
}),
).toBeNull();
expect(deleteProject(db, '00000000-0000-0000-0000-000000000000')).toBe(
false,
);
});
it('updateProject preserves shortUrl, logoFilename, folderId when not provided', () => {
const p = createProject(db, {
name: 'P',
shortUrl: 'https://mifi.me/x',
logoFilename: 'logo.png',
folderId: null,
});
const f = createFolder(db, { name: 'F' });
updateProject(db, p.id, { folderId: f.id });
const updated = getProject(db, p.id)!;
expect(updated.shortUrl).toBe('https://mifi.me/x');
expect(updated.logoFilename).toBe('logo.png');
expect(updated.folderId).toBe(f.id);
});
it('updateProject with explicit logoFilename', () => {
const p = createProject(db, { name: 'P', logoFilename: 'old.png' });
updateProject(db, p.id, { logoFilename: null });
expect(getProject(db, p.id)!.logoFilename).toBeNull();
updateProject(db, p.id, { logoFilename: 'new.png' });
expect(getProject(db, p.id)!.logoFilename).toBe('new.png');
});
it('listFolders returns empty then folders in sort order', () => {
expect(listFolders(db)).toEqual([]);
createFolder(db, { name: 'B', sortOrder: 1 });
createFolder(db, { name: 'A', sortOrder: 0 });
const list = listFolders(db);
expect(list.length).toBe(2);
expect(list[0].name).toBe('A');
expect(list[1].name).toBe('B');
});
it('createFolder defaults name and sortOrder', () => {
const f = createFolder(db, {});
expect(f.id).toBeDefined();
expect(f.name).toBe('Folder');
expect(f.sortOrder).toBe(0);
});
it('getFolder returns folder or null', () => {
const f = createFolder(db, { name: 'X' });
expect(getFolder(db, f.id)?.name).toBe('X');
expect(
getFolder(db, '00000000-0000-0000-0000-000000000000'),
).toBeNull();
});
it('updateFolder and deleteFolder', () => {
const f = createFolder(db, { name: 'Old' });
const updated = updateFolder(db, f.id, { name: 'New', sortOrder: 5 });
expect(updated?.name).toBe('New');
expect(updated?.sortOrder).toBe(5);
expect(
updateFolder(db, '00000000-0000-0000-0000-000000000000', {
name: 'X',
}),
).toBeNull();
const deleted = deleteFolder(db, f.id);
expect(deleted).toBe(true);
expect(getFolder(db, f.id)).toBeNull();
expect(deleteFolder(db, '00000000-0000-0000-0000-000000000000')).toBe(
false,
);
});
it('deleteFolder nulls project folderId', () => {
const folder = createFolder(db, { name: 'F' });
const p = createProject(db, { name: 'P', folderId: folder.id });
deleteFolder(db, folder.id);
expect(getProject(db, p.id)!.folderId).toBeNull();
});
it('initDb tolerates existing folderId column', () => {
const tmp = path.join(os.tmpdir(), `qr-db-${Date.now()}.sqlite`);
try {
const env = { ...testEnv, DB_PATH: tmp } as Parameters<
typeof initDb
>[0];
const db1 = initDb(env);
db1.close();
const db2 = initDb(env);
const list = listFolders(db2);
expect(list).toEqual([]);
db2.close();
} finally {
try {
fs.unlinkSync(tmp);
} catch {
/* ignore */
}
}
});
});

View File

@@ -69,20 +69,47 @@ export function createProject(
db.prepare(
`INSERT INTO projects (id, name, createdAt, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
).run(id, name, now, now, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId);
).run(
id,
name,
now,
now,
originalUrl,
shortenEnabled,
shortUrl,
recipeJson,
logoFilename,
folderId,
);
return getProject(db, id)!;
}
export function listProjects(db: Database.Database): Omit<Project, 'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'>[] {
const rows = db.prepare(
'SELECT id, name, createdAt, updatedAt, logoFilename, folderId FROM projects ORDER BY updatedAt DESC, createdAt DESC, id DESC',
).all() as Array<{ id: string; name: string; createdAt: string; updatedAt: string; logoFilename: string | null; folderId: string | null }>;
export function listProjects(
db: Database.Database,
): Omit<
Project,
'recipeJson' | 'originalUrl' | 'shortUrl' | 'shortenEnabled'
>[] {
const rows = db
.prepare(
'SELECT id, name, createdAt, updatedAt, logoFilename, folderId FROM projects ORDER BY updatedAt DESC, createdAt DESC, id DESC',
)
.all() as Array<{
id: string;
name: string;
createdAt: string;
updatedAt: string;
logoFilename: string | null;
folderId: string | null;
}>;
return rows;
}
export function getProject(db: Database.Database, id: string): Project | null {
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as Project | undefined;
const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as
| Project
| undefined;
return row ?? null;
}
@@ -98,14 +125,29 @@ export function updateProject(
const name = data.name ?? existing.name;
const originalUrl = data.originalUrl ?? existing.originalUrl;
const shortenEnabled = data.shortenEnabled ?? existing.shortenEnabled;
const shortUrl = data.shortUrl !== undefined ? data.shortUrl : existing.shortUrl;
const shortUrl =
data.shortUrl !== undefined ? data.shortUrl : existing.shortUrl;
const recipeJson = data.recipeJson ?? existing.recipeJson;
const logoFilename = data.logoFilename !== undefined ? data.logoFilename : existing.logoFilename;
const folderId = data.folderId !== undefined ? data.folderId : existing.folderId;
const logoFilename =
data.logoFilename !== undefined
? data.logoFilename
: existing.logoFilename;
const folderId =
data.folderId !== undefined ? data.folderId : existing.folderId;
db.prepare(
`UPDATE projects SET name = ?, updatedAt = ?, originalUrl = ?, shortenEnabled = ?, shortUrl = ?, recipeJson = ?, logoFilename = ?, folderId = ? WHERE id = ?`,
).run(name, updatedAt, originalUrl, shortenEnabled, shortUrl, recipeJson, logoFilename, folderId, id);
).run(
name,
updatedAt,
originalUrl,
shortenEnabled,
shortUrl,
recipeJson,
logoFilename,
folderId,
id,
);
return getProject(db, id);
}
@@ -116,9 +158,11 @@ export function deleteProject(db: Database.Database, id: string): boolean {
}
export function listFolders(db: Database.Database): Folder[] {
const rows = db.prepare(
'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC',
).all() as Folder[];
const rows = db
.prepare(
'SELECT id, name, sortOrder FROM folders ORDER BY sortOrder ASC, name ASC',
)
.all() as Folder[];
return rows;
}
@@ -136,7 +180,9 @@ export function createFolder(
}
export function getFolder(db: Database.Database, id: string): Folder | null {
const row = db.prepare('SELECT id, name, sortOrder FROM folders WHERE id = ?').get(id) as Folder | undefined;
const row = db
.prepare('SELECT id, name, sortOrder FROM folders WHERE id = ?')
.get(id) as Folder | undefined;
return row ?? null;
}
@@ -149,12 +195,18 @@ export function updateFolder(
if (!existing) return null;
const name = data.name ?? existing.name;
const sortOrder = data.sortOrder ?? existing.sortOrder;
db.prepare('UPDATE folders SET name = ?, sortOrder = ? WHERE id = ?').run(name, sortOrder, id);
db.prepare('UPDATE folders SET name = ?, sortOrder = ? WHERE id = ?').run(
name,
sortOrder,
id,
);
return getFolder(db, id);
}
export function deleteFolder(db: Database.Database, id: string): boolean {
db.prepare('UPDATE projects SET folderId = NULL WHERE folderId = ?').run(id);
db.prepare('UPDATE projects SET folderId = NULL WHERE folderId = ?').run(
id,
);
const result = db.prepare('DELETE FROM folders WHERE id = ?').run(id);
return result.changes > 0;
}

37
qr-api/src/env.test.ts Normal file
View File

@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { loadEnv } from './env.js';
describe('loadEnv', () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
});
afterEach(() => {
process.env = originalEnv;
});
it('returns defaults when env is minimal', () => {
process.env = {};
const env = loadEnv();
expect(env.PORT).toBe(8080);
expect(env.DB_PATH).toBe('/data/db.sqlite');
expect(env.UPLOADS_PATH).toBe('/uploads');
expect(env.KUTT_BASE_URL).toBe('http://kutt:3000');
expect(env.SHORT_DOMAIN).toBe('https://mifi.me');
expect(env.KUTT_API_KEY).toBeUndefined();
});
it('parses PORT and overrides defaults', () => {
process.env = { PORT: '3000', KUTT_BASE_URL: 'http://localhost:3000' };
const env = loadEnv();
expect(env.PORT).toBe(3000);
expect(env.KUTT_BASE_URL).toBe('http://localhost:3000');
});
it('throws when env is invalid', () => {
process.env = { KUTT_BASE_URL: 'not-a-url' };
expect(() => loadEnv()).toThrow(/Invalid env/);
});
});

View File

@@ -1,14 +1,9 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import pino from 'pino';
import { loadEnv } from './env.js';
import { initDb } from './db.js';
import { projectsRouter } from './routes/projects.js';
import { foldersRouter } from './routes/folders.js';
import { uploadsRouter } from './routes/uploads.js';
import { shortenRouter } from './routes/shorten.js';
import { createApp } from './app.js';
const env = loadEnv();
const logger = pino({ level: process.env.LOG_LEVEL ?? 'info' });
@@ -24,30 +19,7 @@ for (const dir of [dataDir, uploadsDir]) {
}
const db = initDb(env);
const app = express();
app.use(cors());
app.use(express.json());
const baseUrl = ''; // relative; Next.js proxy will use same origin for /api
app.use('/projects', projectsRouter(db, baseUrl));
app.use('/folders', foldersRouter(db));
app.use('/uploads', uploadsRouter(env, baseUrl));
app.use('/shorten', shortenRouter(env));
app.get('/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use((err: Error, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
const msg = err.message ?? '';
if (err.name === 'MulterError' || msg.includes('image files') || msg.includes('file size')) {
return res.status(400).json({ error: msg || 'Invalid upload' });
}
logger.error({ err }, 'Unhandled error');
res.status(500).json({ error: 'Internal server error' });
});
const app = createApp(db, env, '', logger);
const port = env.PORT;
app.listen(port, () => {

295
qr-api/src/routes.test.ts Normal file
View File

@@ -0,0 +1,295 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import request from 'supertest';
import path from 'path';
import os from 'os';
import fs from 'fs';
import { initDb } from './db.js';
import { createApp } from './app.js';
const testEnv = {
DB_PATH: ':memory:',
UPLOADS_PATH: path.join(os.tmpdir(), `qr-uploads-${Date.now()}`),
PORT: 8080,
KUTT_BASE_URL: 'http://kutt:3000',
SHORT_DOMAIN: 'https://mifi.me',
KUTT_API_KEY: undefined as string | undefined,
};
describe('app routes', () => {
beforeEach(() => {
if (fs.existsSync(testEnv.UPLOADS_PATH)) {
fs.rmSync(testEnv.UPLOADS_PATH, { recursive: true });
}
fs.mkdirSync(testEnv.UPLOADS_PATH, { recursive: true });
});
const db = initDb(testEnv as Parameters<typeof initDb>[0]);
const app = createApp(
db,
testEnv as Parameters<typeof createApp>[1],
'/api',
);
it('GET /health returns ok', async () => {
const res = await request(app).get('/health');
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });
});
it('projects CRUD', async () => {
const create = await request(app)
.post('/projects')
.send({ name: 'P1', originalUrl: 'https://a.com' });
expect(create.status).toBe(201);
expect(create.body.name).toBe('P1');
const id = create.body.id;
const get = await request(app).get(`/projects/${id}`);
expect(get.status).toBe(200);
expect(get.body.name).toBe('P1');
expect(get.body.logoUrl).toBeNull();
const list = await request(app).get('/projects');
expect(list.status).toBe(200);
expect(list.body).toHaveLength(1);
const update = await request(app)
.put(`/projects/${id}`)
.send({ name: 'P2' });
expect(update.status).toBe(200);
expect(update.body.name).toBe('P2');
const del = await request(app).delete(`/projects/${id}`);
expect(del.status).toBe(204);
const getAfter = await request(app).get(`/projects/${id}`);
expect(getAfter.status).toBe(404);
});
it('projects validation', async () => {
const bad = await request(app).post('/projects').send({ name: 123 });
expect(bad.status).toBe(400);
const noId = await request(app).get('/projects/not-a-uuid');
expect(noId.status).toBe(400);
});
it('projects POST with logoFilename returns logoUrl', async () => {
const create = await request(app)
.post('/projects')
.send({ name: 'WithLogo', logoFilename: 'logo.png' });
expect(create.status).toBe(201);
expect(create.body.logoUrl).toBe('/api/uploads/logo.png');
});
it('projects PUT with shortenEnabled false', async () => {
const create = await request(app)
.post('/projects')
.send({ name: 'P', originalUrl: 'https://x.com' });
const res = await request(app)
.put(`/projects/${create.body.id}`)
.send({ shortenEnabled: false });
expect(res.status).toBe(200);
expect(res.body.shortenEnabled).toBe(false);
});
it('projects PUT invalid body returns 400', async () => {
const create = await request(app).post('/projects').send({ name: 'P' });
const res = await request(app)
.put(`/projects/${create.body.id}`)
.send({ name: 123 });
expect(res.status).toBe(400);
});
it('projects PUT 404 when project does not exist', async () => {
const res = await request(app)
.put('/projects/00000000-0000-0000-0000-000000000000')
.send({ name: 'X' });
expect(res.status).toBe(404);
});
it('projects DELETE 404 when project does not exist', async () => {
const res = await request(app).delete(
'/projects/00000000-0000-0000-0000-000000000000',
);
expect(res.status).toBe(404);
});
it('folders CRUD', async () => {
const create = await request(app).post('/folders').send({ name: 'F1' });
expect(create.status).toBe(201);
expect(create.body.name).toBe('F1');
const id = create.body.id;
const get = await request(app).get(`/folders/${id}`);
expect(get.status).toBe(200);
const list = await request(app).get('/folders');
expect(list.status).toBe(200);
expect(list.body.length).toBeGreaterThanOrEqual(1);
await request(app).put(`/folders/${id}`).send({ name: 'F2' });
const del = await request(app).delete(`/folders/${id}`);
expect(del.status).toBe(204);
});
it('folders validation', async () => {
const noId = await request(app).get('/folders/not-a-uuid');
expect(noId.status).toBe(400);
const notFound = await request(app).get(
'/folders/00000000-0000-0000-0000-000000000000',
);
expect(notFound.status).toBe(404);
});
it('folders POST invalid body returns 400', async () => {
const res = await request(app).post('/folders').send({ name: 123 });
expect(res.status).toBe(400);
});
it('folders PUT invalid id returns 400', async () => {
const res = await request(app)
.put('/folders/not-a-uuid')
.send({ name: 'X' });
expect(res.status).toBe(400);
});
it('folders PUT invalid body returns 400', async () => {
const create = await request(app).post('/folders').send({ name: 'F' });
const res = await request(app)
.put(`/folders/${create.body.id}`)
.send({ name: 999 });
expect(res.status).toBe(400);
});
it('folders PUT 404 when folder does not exist', async () => {
const res = await request(app)
.put('/folders/00000000-0000-0000-0000-000000000000')
.send({ name: 'X' });
expect(res.status).toBe(404);
});
it('folders DELETE 404 when folder does not exist', async () => {
const res = await request(app).delete(
'/folders/00000000-0000-0000-0000-000000000000',
);
expect(res.status).toBe(404);
});
it('shorten returns 503 when KUTT_API_KEY missing', async () => {
const res = await request(app)
.post('/shorten')
.send({ targetUrl: 'https://x.com' });
expect(res.status).toBe(503);
});
it('shorten validates body', async () => {
const res = await request(app).post('/shorten').send({});
expect(res.status).toBe(400);
});
it('uploads/logo returns 400 when no file', async () => {
const res = await request(app).post('/uploads/logo');
expect(res.status).toBe(400);
});
it('uploads/:filename returns 400 for invalid filename', async () => {
const res = await request(app).get('/uploads/..hidden');
expect(res.status).toBe(400);
});
it('uploads/:filename returns 404 for missing file', async () => {
const res = await request(app).get('/uploads/nonexistent.png');
expect(res.status).toBe(404);
});
it('shorten returns 502 when Kutt returns no URL', async () => {
vi.stubGlobal('fetch', vi.fn());
const envWithKey = { ...testEnv, KUTT_API_KEY: 'key' };
const appWithKey = createApp(
db,
envWithKey as Parameters<typeof createApp>[1],
);
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
const res = await request(appWithKey)
.post('/shorten')
.send({ targetUrl: 'https://x.com' });
expect(res.status).toBe(502);
});
it('shorten returns 502 when fetch rejects with non-Error', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce('network error'));
const envWithKey = { ...testEnv, KUTT_API_KEY: 'key' };
const appWithKey = createApp(
db,
envWithKey as Parameters<typeof createApp>[1],
);
const res = await request(appWithKey)
.post('/shorten')
.send({ targetUrl: 'https://x.com' });
expect(res.status).toBe(502);
});
it('uploads/logo rejects non-image via error handler', async () => {
const res = await request(app)
.post('/uploads/logo')
.attach('file', Buffer.from('fake pdf'), {
filename: 'x.pdf',
contentType: 'application/pdf',
});
expect(res.status).toBe(400);
expect(res.body.error).toContain('image files');
});
it('uploads/logo accepts image and returns filename', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]); // PNG magic
const res = await request(app)
.post('/uploads/logo')
.attach('file', png, {
filename: 'logo.png',
contentType: 'image/png',
});
expect(res.status).toBe(200);
expect(res.body.filename).toBeDefined();
expect(res.body.url).toContain(res.body.filename);
});
it('uploads/logo with no extension uses .bin', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const res = await request(app)
.post('/uploads/logo')
.attach('file', png, {
filename: 'noext',
contentType: 'image/png',
});
expect(res.status).toBe(200);
expect(res.body.filename).toMatch(/\.bin$/);
});
it('uploads/:filename returns file when it exists', async () => {
const png = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
]);
const upload = await request(app)
.post('/uploads/logo')
.attach('file', png, {
filename: 'logo.png',
contentType: 'image/png',
});
const filename = upload.body.filename;
const get = await request(app).get(`/uploads/${filename}`);
expect(get.status).toBe(200);
});
it('uploads/:filename returns 400 for absolute path', async () => {
const res = await request(app).get(
'/uploads/' + encodeURIComponent('/etc/passwd'),
);
expect(res.status).toBe(400);
});
});

View File

@@ -23,14 +23,19 @@ const updateBodySchema = createBodySchema.partial();
const idParamSchema = z.object({ id: z.string().uuid() });
export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof Router> {
export function projectsRouter(
db: Database,
baseUrl: string,
): ReturnType<typeof Router> {
const router = Router();
const toJson = (p: ReturnType<typeof getProject>) =>
p
? {
...p,
shortenEnabled: Boolean(p.shortenEnabled),
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
logoUrl: p.logoFilename
? `${baseUrl}/uploads/${p.logoFilename}`
: null,
}
: null;
@@ -63,7 +68,9 @@ export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof
id: p.id,
name: p.name,
updatedAt: p.updatedAt,
logoUrl: p.logoFilename ? `${baseUrl}/uploads/${p.logoFilename}` : null,
logoUrl: p.logoFilename
? `${baseUrl}/uploads/${p.logoFilename}`
: null,
folderId: p.folderId ?? null,
}));
return res.json(items);
@@ -97,7 +104,12 @@ export function projectsRouter(db: Database, baseUrl: string): ReturnType<typeof
const project = updateProject(db, paramParsed.data.id, {
name: data.name,
originalUrl: data.originalUrl,
shortenEnabled: data.shortenEnabled !== undefined ? (data.shortenEnabled ? 1 : 0) : undefined,
shortenEnabled:
data.shortenEnabled !== undefined
? data.shortenEnabled
? 1
: 0
: undefined,
shortUrl: data.shortUrl,
recipeJson: data.recipeJson,
logoFilename: data.logoFilename,

View File

@@ -4,18 +4,25 @@ import fs from 'fs';
import type { Env } from '../env.js';
import { createMulter } from '../upload.js';
export function uploadsRouter(env: Env, baseUrl: string): ReturnType<typeof Router> {
export function uploadsRouter(
env: Env,
baseUrl: string,
): ReturnType<typeof Router> {
const router = Router();
const upload = createMulter(env);
router.post('/logo', upload.single('file'), (req: Request, res: Response) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const filename = req.file.filename;
const url = `${baseUrl}/uploads/${filename}`;
return res.json({ filename, url });
});
router.post(
'/logo',
upload.single('file'),
(req: Request, res: Response) => {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const filename = req.file.filename;
const url = `${baseUrl}/uploads/${filename}`;
return res.json({ filename, url });
},
);
router.get('/:filename', (req: Request, res: Response) => {
const raw = req.params.filename;

View File

@@ -20,15 +20,21 @@ describe('shortenUrl', () => {
ok: true,
json: () => Promise.resolve({ link: 'https://mifi.me/abc' }),
});
const result = await shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
});
const result = await shortenUrl(
env as Parameters<typeof shortenUrl>[0],
{
targetUrl: 'https://example.com',
},
);
expect(result.shortUrl).toBe('https://mifi.me/abc');
expect(fetch).toHaveBeenCalledWith(
'http://kutt:3000/api/v2/links',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-API-Key': 'test-key' },
headers: {
'Content-Type': 'application/json',
'X-API-Key': 'test-key',
},
body: JSON.stringify({ target: 'https://example.com' }),
}),
);
@@ -46,16 +52,24 @@ describe('shortenUrl', () => {
expect(fetch).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
body: JSON.stringify({ target: 'https://example.com', customurl: 'myslug' }),
body: JSON.stringify({
target: 'https://example.com',
customurl: 'myslug',
}),
}),
);
});
it('throws when KUTT_API_KEY is missing', async () => {
await expect(
shortenUrl({ ...env, KUTT_API_KEY: undefined } as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
}),
shortenUrl(
{ ...env, KUTT_API_KEY: undefined } as Parameters<
typeof shortenUrl
>[0],
{
targetUrl: 'https://example.com',
},
),
).rejects.toThrow('KUTT_API_KEY');
});
@@ -66,7 +80,45 @@ describe('shortenUrl', () => {
text: () => Promise.resolve('Bad request'),
});
await expect(
shortenUrl(env as Parameters<typeof shortenUrl>[0], { targetUrl: 'https://example.com' }),
shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
}),
).rejects.toThrow(/400/);
});
it('uses id when link is missing and prepends SHORT_DOMAIN', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ id: 'xyz99' }),
});
const result = await shortenUrl(
env as Parameters<typeof shortenUrl>[0],
{ targetUrl: 'https://example.com' },
);
expect(result.shortUrl).toBe('https://mifi.me/xyz99');
});
it('prepends SHORT_DOMAIN when link is relative', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ link: '/abc' }),
});
const result = await shortenUrl(
env as Parameters<typeof shortenUrl>[0],
{ targetUrl: 'https://example.com' },
);
expect(result.shortUrl).toBe('https://mifi.me/abc');
});
it('throws when Kutt returns no link or id', async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({}),
});
await expect(
shortenUrl(env as Parameters<typeof shortenUrl>[0], {
targetUrl: 'https://example.com',
}),
).rejects.toThrow('Kutt API did not return a short URL');
});
});

View File

@@ -9,7 +9,10 @@ export interface ShortenResult {
shortUrl: string;
}
export async function shortenUrl(env: Env, body: ShortenBody): Promise<ShortenResult> {
export async function shortenUrl(
env: Env,
body: ShortenBody,
): Promise<ShortenResult> {
if (!env.KUTT_API_KEY) {
throw new Error('KUTT_API_KEY is not configured');
}
@@ -33,11 +36,15 @@ export async function shortenUrl(env: Env, body: ShortenBody): Promise<ShortenRe
}
const data = (await res.json()) as { link?: string; id?: string };
const link = data.link ?? (data.id ? `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${data.id}` : null);
const link =
data.link ??
(data.id ? `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${data.id}` : null);
if (!link) {
throw new Error('Kutt API did not return a short URL');
}
const shortUrl = link.startsWith('http') ? link : `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${link}`;
const shortUrl = link.startsWith('http')
? link
: `${env.SHORT_DOMAIN.replace(/\/$/, '')}/${link.replace(/^\//, '')}`;
return { shortUrl };
}

12
qr-api/src/upload.test.ts Normal file
View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest';
import { createMulter } from './upload.js';
describe('createMulter', () => {
it('returns multer instance with single()', () => {
const upload = createMulter({
UPLOADS_PATH: '/tmp',
} as Parameters<typeof createMulter>[0]);
expect(upload.single).toBeDefined();
expect(typeof upload.single).toBe('function');
});
});

View File

@@ -3,7 +3,13 @@ import path from 'path';
import { randomUUID } from 'crypto';
import type { Env } from './env.js';
const IMAGE_MIME = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
const IMAGE_MIME = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/svg+xml',
];
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
export function createMulter(env: Env) {
@@ -20,7 +26,11 @@ export function createMulter(env: Env) {
if (IMAGE_MIME.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error('Only image files (jpeg, png, gif, webp) are allowed'));
cb(
new Error(
'Only image files (jpeg, png, gif, webp) are allowed',
),
);
}
},
});

View File

@@ -4,5 +4,17 @@ export default defineConfig({
test: {
globals: false,
environment: 'node',
coverage: {
provider: 'v8',
reporter: ['text', 'lcov'],
include: ['src/**/*.ts'],
exclude: ['src/**/*.test.ts', 'src/index.ts'],
thresholds: {
lines: 90,
functions: 100,
branches: 72,
statements: 90,
},
},
},
});