ci: add pre-push hook (auto-installed) and API/model smoke tests

- .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 <noreply@anthropic.com>
This commit is contained in:
itsrubberduck
2026-06-18 10:46:56 +02:00
parent d8c27260da
commit bfa83305e8
4 changed files with 171 additions and 1 deletions

9
.githooks/pre-push Executable file
View File

@@ -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"

View File

@@ -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",

View File

@@ -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`)
}
})
})

View File

@@ -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')
})
})