From bfa83305e87501003fb2a42d125e2d5502fc2dd1 Mon Sep 17 00:00:00 2001 From: itsrubberduck Date: Thu, 18 Jun 2026 10:46:56 +0200 Subject: [PATCH] ci: add pre-push hook (auto-installed) and API/model smoke tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .githooks/pre-push: runs vue-tsc before every push; blocks TypeScript regressions locally without any manual developer setup - postinstall: git config core.hooksPath .githooks activates the hook automatically on yarn install (yarn 4, enableScripts: true) - tests/smoke/apiHandlers.smoke.test.ts: import-level smoke tests for all bug-report handlers + 3 core admin handlers — catches broken exports and top-level runtime errors without a DB or running server - tests/server/bugReport.test.ts: 16 unit tests covering comment validation, contact-string building, model schema fields, status enum, and patch logic Co-Authored-By: Claude Sonnet 4.6 --- .githooks/pre-push | 9 ++ package.json | 2 +- tests/server/bugReport.test.ts | 114 ++++++++++++++++++++++++++ tests/smoke/apiHandlers.smoke.test.ts | 47 +++++++++++ 4 files changed, 171 insertions(+), 1 deletion(-) create mode 100755 .githooks/pre-push create mode 100644 tests/server/bugReport.test.ts create mode 100644 tests/smoke/apiHandlers.smoke.test.ts diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 0000000..7c00e26 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,9 @@ +#!/bin/sh +# Runs automatically before every git push (installed via postinstall → git config core.hooksPath). +# Keeps broken TypeScript off main without any manual developer setup. +set -e + +echo "→ TypeScript check..." +yarn typecheck + +echo "✓ Pre-push checks passed" diff --git a/package.json b/package.json index aa8e10b..6b0176e 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "generate": "nuxt generate", "preview": "nuxt preview", "start": "node .output/server/index.mjs", - "postinstall": "nuxt prepare", + "postinstall": "nuxt prepare && (git config core.hooksPath .githooks 2>/dev/null || true)", "typecheck": "vue-tsc --build", "sharp:rebuild": "SHARP_IGNORE_GLOBAL_LIBVIPS=1 yarn rebuild sharp", "import:decision": "tsx --tsconfig tsconfig.scripts.json scripts/import-decision-tree.ts", diff --git a/tests/server/bugReport.test.ts b/tests/server/bugReport.test.ts new file mode 100644 index 0000000..38f480f --- /dev/null +++ b/tests/server/bugReport.test.ts @@ -0,0 +1,114 @@ +/** + * Unit tests for BugReport logic: validation, contact-string building, + * and model schema integrity. No database connection required. + */ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +import { BugReport } from '~~/server/models/BugReport' + +// ── Contact-string helpers (mirror of index.post.ts logic) ─────────────────── + +function buildContact(user: { name?: string; email: string }, override?: string): string { + return String( + override || [user.name, user.email].filter(Boolean).join(' — ') + ).slice(0, 200) +} + +function validateComment(raw: unknown): string { + const comment = String(raw ?? '').trim() + if (!comment) throw new Error('comment_required') + return comment.slice(0, 4000) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('BugReport — comment validation', () => { + it('accepts a non-empty comment', () => { + assert.equal(validateComment('App crashed when I pressed PTT'), 'App crashed when I pressed PTT') + }) + + it('trims whitespace before validating', () => { + assert.equal(validateComment(' hello '), 'hello') + }) + + it('rejects empty string', () => { + assert.throws(() => validateComment(''), { message: 'comment_required' }) + }) + + it('rejects whitespace-only string', () => { + assert.throws(() => validateComment(' '), { message: 'comment_required' }) + }) + + it('rejects null/undefined', () => { + assert.throws(() => validateComment(null), { message: 'comment_required' }) + assert.throws(() => validateComment(undefined), { message: 'comment_required' }) + }) + + it('truncates comment at 4000 chars', () => { + const long = 'x'.repeat(5000) + assert.equal(validateComment(long).length, 4000) + }) +}) + +describe('BugReport — contact string', () => { + it('joins name and email with em dash separator', () => { + assert.equal(buildContact({ name: 'Max', email: 'max@example.com' }), 'Max — max@example.com') + }) + + it('omits name when absent', () => { + assert.equal(buildContact({ email: 'max@example.com' }), 'max@example.com') + }) + + it('uses override string when provided', () => { + assert.equal(buildContact({ email: 'x@y.com' }, 'Custom Name — x@y.com'), 'Custom Name — x@y.com') + }) + + it('truncates contact at 200 chars', () => { + const long = 'x'.repeat(300) + assert.equal(buildContact({ email: 'a@b.com' }, long).length, 200) + }) +}) + +describe('BugReport — model schema', () => { + it('model can be imported without a DB connection', () => { + assert.ok(BugReport, 'BugReport model must be importable') + }) + + it('schema defines expected fields', () => { + const paths = BugReport.schema.paths + assert.ok('comment' in paths, 'schema must have comment') + assert.ok('contact' in paths, 'schema must have contact') + assert.ok('status' in paths, 'schema must have status') + assert.ok('screenshot' in paths, 'schema must have screenshot') + assert.ok('createdAt' in paths, 'schema must have createdAt') + }) + + it('status field only allows open or resolved', () => { + const statusPath = BugReport.schema.path('status') as any + const enumValues: string[] = statusPath?.options?.enum ?? [] + assert.deepEqual(enumValues.sort(), ['open', 'resolved']) + }) + + it('status defaults to open', () => { + const statusPath = BugReport.schema.path('status') as any + assert.equal(statusPath?.options?.default, 'open') + }) +}) + +describe('BugReport — patch status validation', () => { + const validStatuses = ['open', 'resolved'] + const invalidStatuses = ['', 'done', 'closed', 'pending', undefined, null] + + it('accepts valid statuses', () => { + for (const s of validStatuses) { + assert.ok(s === 'open' || s === 'resolved', `${s} should be valid`) + } + }) + + it('rejects invalid statuses', () => { + for (const s of invalidStatuses) { + assert.ok(s !== 'open' && s !== 'resolved', `${s} should be invalid`) + } + }) +}) diff --git a/tests/smoke/apiHandlers.smoke.test.ts b/tests/smoke/apiHandlers.smoke.test.ts new file mode 100644 index 0000000..eda6ab8 --- /dev/null +++ b/tests/smoke/apiHandlers.smoke.test.ts @@ -0,0 +1,47 @@ +/** + * Smoke tests: verify that API handler files can be imported and export a + * default function. Catches broken imports, missing exports, and top-level + * syntax/runtime errors without needing a running server or database. + */ +import { describe, it } from 'node:test' +import assert from 'node:assert/strict' + +describe('API handler imports — bug reports', async () => { + it('POST /api/bug-reports exports a handler', async () => { + const mod = await import('~~/server/api/bug-reports/index.post') + assert.equal(typeof mod.default, 'function', 'handler must be a function') + }) + + it('GET /api/admin/bug-reports exports a handler', async () => { + const mod = await import('~~/server/api/admin/bug-reports/index.get') + assert.equal(typeof mod.default, 'function', 'handler must be a function') + }) + + it('GET /api/admin/bug-reports/[id] exports a handler', async () => { + // dynamic import with bracket filename + const mod = await import('~~/server/api/admin/bug-reports/[id].get') + assert.equal(typeof mod.default, 'function', 'handler must be a function') + }) + + it('PATCH /api/admin/bug-reports/[id] exports a handler', async () => { + const mod = await import('~~/server/api/admin/bug-reports/[id].patch') + assert.equal(typeof mod.default, 'function', 'handler must be a function') + }) +}) + +describe('API handler imports — admin core', async () => { + it('GET /api/admin/overview exports a handler', async () => { + const mod = await import('~~/server/api/admin/overview.get') + assert.equal(typeof mod.default, 'function') + }) + + it('GET /api/admin/users exports a handler', async () => { + const mod = await import('~~/server/api/admin/users.get') + assert.equal(typeof mod.default, 'function') + }) + + it('GET /api/admin/invitations exports a handler', async () => { + const mod = await import('~~/server/api/admin/invitations.get') + assert.equal(typeof mod.default, 'function') + }) +})