Resolve linter issues, add unit tests, adjust test coverage
This commit is contained in:
28
qr-api/eslint.config.cjs
Normal file
28
qr-api/eslint.config.cjs
Normal 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,
|
||||
];
|
||||
@@ -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
50
qr-api/src/app.ts
Normal 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;
|
||||
}
|
||||
@@ -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 */
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
37
qr-api/src/env.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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
295
qr-api/src/routes.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
12
qr-api/src/upload.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user