test: add test infrastructure for frontend and server
- Frontend: vitest.config.ts with vue plugin + jsdom, dummy component test - Server: in-memory SQLite test DB factory + Hono testClient helper + smoke test - CI: add pnpm audit and server coverage threshold steps - Root: vitest workspace config for multi-project test discovery Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
6f0eb92ebb
commit
d9e32123fe
@@ -33,3 +33,9 @@ jobs:
|
|||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: pnpm lint
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Security audit
|
||||||
|
run: pnpm audit --audit-level=high
|
||||||
|
|
||||||
|
- name: Server test coverage
|
||||||
|
run: pnpm test -- --run --project server --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.thresholds.lines=30
|
||||||
|
|||||||
@@ -16,7 +16,10 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.2.1",
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@testing-library/vue": "^8.1.0",
|
||||||
"@vitejs/plugin-vue": "^5.2.3",
|
"@vitejs/plugin-vue": "^5.2.3",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
"tailwindcss": "^4.2.1",
|
"tailwindcss": "^4.2.1",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import DummyComponent from './DummyComponent.vue'
|
||||||
|
|
||||||
|
describe('DummyComponent', () => {
|
||||||
|
it('renders the message prop as text content', () => {
|
||||||
|
const wrapper = mount(DummyComponent, {
|
||||||
|
props: { message: 'BOTFIGHTS' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toBe('BOTFIGHTS')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders different message', () => {
|
||||||
|
const wrapper = mount(DummyComponent, {
|
||||||
|
props: { message: 'Stack sats, fight bots' },
|
||||||
|
})
|
||||||
|
expect(wrapper.text()).toBe('Stack sats, fight bots')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{ message: string }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="dummy">{{ message }}</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Frontend test setup for @vue/test-utils + jsdom
|
||||||
|
// Add global test utilities and mocks here as needed
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import { resolve } from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': resolve(__dirname, 'src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.ts'],
|
||||||
|
setupFiles: ['src/test-setup.ts'],
|
||||||
|
},
|
||||||
|
})
|
||||||
Generated
+676
-2
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { createTestDb, insertTestBot } from './db.js'
|
||||||
|
|
||||||
|
describe('test helpers', () => {
|
||||||
|
it('creates in-memory DB, inserts a bot, and queries it back', () => {
|
||||||
|
const { db, schema } = createTestDb()
|
||||||
|
|
||||||
|
const bot = insertTestBot(db, { name: 'SatoshiBot' })
|
||||||
|
|
||||||
|
const found = db
|
||||||
|
.select()
|
||||||
|
.from(schema.bots)
|
||||||
|
.where(eq(schema.bots.id, bot.id))
|
||||||
|
.get()
|
||||||
|
|
||||||
|
expect(found).toBeDefined()
|
||||||
|
expect(found!.name).toBe('SatoshiBot')
|
||||||
|
expect(found!.eloRating).toBe(1200)
|
||||||
|
expect(found!.webhookUrl).toBe('http://test.local/webhook')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('each createTestDb is isolated', () => {
|
||||||
|
const { db: db1, schema: s1 } = createTestDb()
|
||||||
|
const { db: db2, schema: s2 } = createTestDb()
|
||||||
|
|
||||||
|
insertTestBot(db1, { name: 'BotInDb1' })
|
||||||
|
|
||||||
|
const inDb1 = db1.select().from(s1.bots).all()
|
||||||
|
const inDb2 = db2.select().from(s2.bots).all()
|
||||||
|
|
||||||
|
expect(inDb1).toHaveLength(1)
|
||||||
|
expect(inDb2).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import Database from 'better-sqlite3'
|
||||||
|
import { drizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'
|
||||||
|
import * as schema from '../db/schema.js'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates an isolated in-memory SQLite database with full schema.
|
||||||
|
* Each call returns a fresh DB — no test pollution.
|
||||||
|
*/
|
||||||
|
export function createTestDb() {
|
||||||
|
const sqlite = new Database(':memory:')
|
||||||
|
sqlite.pragma('journal_mode = WAL')
|
||||||
|
sqlite.pragma('foreign_keys = ON')
|
||||||
|
|
||||||
|
// Create all tables (mirrors startup.ts)
|
||||||
|
sqlite.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS bots (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
webhook_url TEXT NOT NULL,
|
||||||
|
avatar_seed TEXT NOT NULL,
|
||||||
|
archetype TEXT NOT NULL DEFAULT 'standard',
|
||||||
|
secret_hash TEXT NOT NULL,
|
||||||
|
public_key TEXT,
|
||||||
|
profile_pic_url TEXT,
|
||||||
|
elo_rating REAL NOT NULL DEFAULT 1200,
|
||||||
|
wins INTEGER NOT NULL DEFAULT 0,
|
||||||
|
losses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
win_streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
best_streak INTEGER NOT NULL DEFAULT 0,
|
||||||
|
tier INTEGER NOT NULL DEFAULT 0,
|
||||||
|
is_active INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_fight_at TEXT,
|
||||||
|
consecutive_errors INTEGER NOT NULL DEFAULT 0,
|
||||||
|
last_error_at TEXT,
|
||||||
|
customization TEXT,
|
||||||
|
sats_won INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sats_wagered INTEGER NOT NULL DEFAULT 0,
|
||||||
|
has_wallet INTEGER NOT NULL DEFAULT 0,
|
||||||
|
zaps_received INTEGER NOT NULL DEFAULT 0,
|
||||||
|
bot_type TEXT NOT NULL DEFAULT 'regular',
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS fights (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
bot_a_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
bot_b_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
arena TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'scheduled',
|
||||||
|
winner_id TEXT REFERENCES bots(id),
|
||||||
|
bot_a_hp INTEGER NOT NULL DEFAULT 200,
|
||||||
|
bot_b_hp INTEGER NOT NULL DEFAULT 200,
|
||||||
|
total_rounds INTEGER NOT NULL DEFAULT 0,
|
||||||
|
scheduled_at TEXT,
|
||||||
|
started_at TEXT,
|
||||||
|
ended_at TEXT,
|
||||||
|
mode TEXT NOT NULL DEFAULT 'free',
|
||||||
|
pot_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
payout_status TEXT,
|
||||||
|
current_season TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rounds (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
fight_id TEXT NOT NULL REFERENCES fights(id),
|
||||||
|
round_number INTEGER NOT NULL,
|
||||||
|
challenge_type TEXT NOT NULL,
|
||||||
|
challenge_data TEXT NOT NULL,
|
||||||
|
bot_a_response TEXT,
|
||||||
|
bot_a_time_ms INTEGER,
|
||||||
|
bot_a_score REAL,
|
||||||
|
bot_b_response TEXT,
|
||||||
|
bot_b_time_ms INTEGER,
|
||||||
|
bot_b_score REAL,
|
||||||
|
winner_id TEXT REFERENCES bots(id),
|
||||||
|
narration TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS payments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
fight_id TEXT REFERENCES fights(id),
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
direction TEXT NOT NULL,
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
invoice TEXT,
|
||||||
|
preimage TEXT,
|
||||||
|
cashu_token TEXT,
|
||||||
|
error_reason TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
confirmed_at TEXT,
|
||||||
|
refunded_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wallet_connections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
bot_id TEXT NOT NULL UNIQUE REFERENCES bots(id),
|
||||||
|
method TEXT NOT NULL,
|
||||||
|
connection_data TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
last_used_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bets (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
fight_id TEXT NOT NULL REFERENCES fights(id),
|
||||||
|
bettor_pubkey TEXT NOT NULL,
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
amount_sats INTEGER NOT NULL,
|
||||||
|
odds_at_placement REAL NOT NULL,
|
||||||
|
cashu_token TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
payout_sats INTEGER,
|
||||||
|
payout_token TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
settled_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tournaments (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
format TEXT NOT NULL DEFAULT 'single_elim',
|
||||||
|
size INTEGER NOT NULL,
|
||||||
|
entry_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
prize_sats INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
current_round INTEGER NOT NULL DEFAULT 0,
|
||||||
|
season_id TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
started_at TEXT,
|
||||||
|
finished_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tournament_entries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tournament_id TEXT NOT NULL REFERENCES tournaments(id),
|
||||||
|
bot_id TEXT NOT NULL REFERENCES bots(id),
|
||||||
|
seed INTEGER NOT NULL DEFAULT 0,
|
||||||
|
eliminated INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS tournament_matches (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
tournament_id TEXT NOT NULL REFERENCES tournaments(id),
|
||||||
|
round INTEGER NOT NULL,
|
||||||
|
match_index INTEGER NOT NULL,
|
||||||
|
bot_a_id TEXT REFERENCES bots(id),
|
||||||
|
bot_b_id TEXT REFERENCES bots(id),
|
||||||
|
fight_id TEXT REFERENCES fights(id),
|
||||||
|
winner_id TEXT REFERENCES bots(id),
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS analytics (
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
metric TEXT NOT NULL,
|
||||||
|
value INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_analytics_date_metric ON analytics(date, metric);
|
||||||
|
`)
|
||||||
|
|
||||||
|
const db = drizzle(sqlite, { schema })
|
||||||
|
|
||||||
|
return { db, sqlite, schema }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a minimal test bot and return it */
|
||||||
|
export function insertTestBot(
|
||||||
|
db: BetterSQLite3Database<typeof schema>,
|
||||||
|
overrides: Partial<typeof schema.bots.$inferInsert> = {},
|
||||||
|
) {
|
||||||
|
const id = overrides.id || `bot-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const bot = {
|
||||||
|
id,
|
||||||
|
name: overrides.name || `TestBot-${id.slice(4)}`,
|
||||||
|
webhookUrl: overrides.webhookUrl || 'http://test.local/webhook',
|
||||||
|
avatarSeed: overrides.avatarSeed || 'test-seed',
|
||||||
|
archetype: overrides.archetype || 'standard',
|
||||||
|
secretHash: overrides.secretHash || 'testhash',
|
||||||
|
createdAt: overrides.createdAt || new Date().toISOString(),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
db.insert(schema.bots).values(bot).run()
|
||||||
|
return bot
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Hono } from 'hono'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test helper for Hono route testing without HTTP server.
|
||||||
|
* Uses Hono's built-in `app.request()` for direct request simulation.
|
||||||
|
*/
|
||||||
|
export function testClient(app: Hono) {
|
||||||
|
return {
|
||||||
|
get: (path: string, init?: RequestInit) =>
|
||||||
|
app.request(path, { method: 'GET', ...init }),
|
||||||
|
|
||||||
|
post: (path: string, body?: unknown, init?: RequestInit) =>
|
||||||
|
app.request(path, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
...init,
|
||||||
|
}),
|
||||||
|
|
||||||
|
put: (path: string, body?: unknown, init?: RequestInit) =>
|
||||||
|
app.request(path, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json', ...init?.headers },
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
...init,
|
||||||
|
}),
|
||||||
|
|
||||||
|
delete: (path: string, init?: RequestInit) =>
|
||||||
|
app.request(path, { method: 'DELETE', ...init }),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineWorkspace } from 'vitest/config'
|
||||||
|
|
||||||
|
export default defineWorkspace([
|
||||||
|
{
|
||||||
|
test: {
|
||||||
|
name: 'server',
|
||||||
|
include: ['server/src/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'frontend/vitest.config.ts',
|
||||||
|
])
|
||||||
Reference in New Issue
Block a user