mirror of
https://github.com/OpenSquawk/OpenSquawk
synced 2026-07-31 13:55:34 +08:00
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:
9
.githooks/pre-push
Executable file
9
.githooks/pre-push
Executable 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"
|
||||
@@ -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",
|
||||
|
||||
114
tests/server/bugReport.test.ts
Normal file
114
tests/server/bugReport.test.ts
Normal 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`)
|
||||
}
|
||||
})
|
||||
})
|
||||
47
tests/smoke/apiHandlers.smoke.test.ts
Normal file
47
tests/smoke/apiHandlers.smoke.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user