import { test, expect, beforeAll, afterAll } from 'bun:test'; import { spawn, type ChildProcess } from 'child_process'; import { join } from 'path'; import { existsSync, unlinkSync, writeFileSync } from 'fs'; const projectDir = join(import.meta.dir, '..'); let apiProc: ChildProcess; beforeAll(async () => { const envPath = join(projectDir, '.env'); const testDb = join(projectDir, 'data', 'test.db'); if (existsSync(testDb)) unlinkSync(testDb); writeFileSync(envPath, `SITE_URL=http://localhost:3000 API_PORT=3001 DB_PATH=${testDb} JWT_SECRET=test-secret LUD16_ADDRESS= STRIPE_SECRET_KEY= BTCPAY_URL= PAYMENT_MOCKS=1 `); // run setup const setup = spawn('bun', ['run', 'src/setup.ts'], { cwd: projectDir, env: { ...process.env, ADMIN_PASSWORD: 'pa55word', DB_PATH: testDb }, stdio: ['ignore', 'pipe', 'pipe'], }); let err = ''; setup.stderr.on('data', (d: any) => { err += d.toString(); }); const code = await new Promise((resolve) => { setup.on('close', resolve); }); if (code !== 0) { console.error('setup stderr:', err); throw new Error('setup failed'); } // start api apiProc = spawn('bun', ['run', 'src/server.ts'], { cwd: projectDir, env: { ...process.env, API_PORT: '3001', DB_PATH: testDb, PAYMENT_MOCKS: '1' }, stdio: ['ignore', 'pipe', 'pipe'], }); for (let i = 0; i < 50; i++) { try { const r = await fetch('http://localhost:3001/products'); if (r.ok) break; } catch {} await Bun.sleep(300); } }); afterAll(() => { apiProc?.kill(); }); test('GET /products returns 14 products', async () => { const r = await fetch('http://localhost:3001/products'); expect(r.status).toBe(200); const data = await r.json(); expect(Array.isArray(data)).toBe(true); expect(data.length).toBe(14); }); test('GET /products/:slug returns product', async () => { const r = await fetch('http://localhost:3001/products/borovinka'); expect(r.status).toBe(200); const data = await r.json(); expect(data.slug).toBe('borovinka'); expect(data.price_cents).toBeGreaterThan(0); }); test('GET /products/:slug returns 404', async () => { const r = await fetch('http://localhost:3001/products/nope'); expect(r.status).toBe(404); }); test('GET /categories', async () => { const r = await fetch('http://localhost:3001/categories'); expect(r.status).toBe(200); const data = await r.json(); expect(data.length).toBeGreaterThan(0); }); test('POST /shipping-quote', async () => { const r = await fetch('http://localhost:3001/shipping-quote', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }] }), }); expect(r.status).toBe(200); const d = await r.json(); expect(d.subtotal_cents).toBe(1400); expect(d.shipping_cents).toBe(490); }); test('POST /auth/login success', async () => { const r = await fetch('http://localhost:3001/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@zlattea.com', password: 'pa55word' }), }); expect(r.status).toBe(200); const d = await r.json(); expect(d).toHaveProperty('token'); expect(d.user.email).toBe('admin@zlattea.com'); }); test('POST /auth/login wrong password', async () => { const r = await fetch('http://localhost:3001/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@zlattea.com', password: 'wrong' }), }); expect(r.status).toBe(401); }); test('GET /auth/session returns user', async () => { const login = await fetch('http://localhost:3001/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@zlattea.com', password: 'pa55word' }), }); const { token } = await login.json(); const r = await fetch('http://localhost:3001/auth/session', { headers: { 'Authorization': 'Bearer ' + token }, }); expect(r.status).toBe(200); const d = await r.json(); expect(d.user.email).toBe('admin@zlattea.com'); expect(d.user.is_admin).toBe(1); }); test('GET /auth/session returns null without token', async () => { const r = await fetch('http://localhost:3001/auth/session'); const d = await r.json(); expect(d.user).toBeNull(); }); test('POST /auth/logout', async () => { const login = await fetch('http://localhost:3001/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email: 'admin@zlattea.com', password: 'pa55word' }), }); const { token } = await login.json(); await fetch('http://localhost:3001/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } }); const r = await fetch('http://localhost:3001/auth/session', { headers: { 'Authorization': 'Bearer ' + token } }); expect((await r.json()).user).toBeNull(); }); test('Nostr full flow (challenge -> sign -> login)', async () => { const chRes = await fetch('http://localhost:3001/auth/nostr-challenge'); const { challenge } = await chRes.json(); const { schnorr } = require('@noble/curves/secp256k1.js'); const crypto = require('crypto'); const priv = crypto.randomBytes(32); const pub = Buffer.from(schnorr.getPublicKey(priv)).toString('hex'); const now = Math.floor(Date.now() / 1000); const ev = { kind: 22242, pubkey: pub, created_at: now, tags: [['challenge', challenge]], content: '' }; const id = crypto.createHash('sha256').update(new TextEncoder().encode(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]))).digest(); ev.sig = Buffer.from(schnorr.sign(id, priv)).toString('hex'); const r = await fetch('http://localhost:3001/auth/nostr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: ev }), }); expect(r.status).toBe(200); const d = await r.json(); expect(d).toHaveProperty('token'); expect(d.user.pubkey).toBe(pub); }); test('Nostr bad pubkey rejected', async () => { const chRes = await fetch('http://localhost:3001/auth/nostr-challenge'); const { challenge } = await chRes.json(); const r = await fetch('http://localhost:3001/auth/nostr', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ event: { kind: 22242, pubkey: 'z'.repeat(64), created_at: Math.floor(Date.now()/1000), tags: [['challenge', challenge]], content: '', sig: '0'.repeat(128) } }), }); expect(r.status).toBe(401); }); test('checkout cod', async () => { const r = await fetch('http://localhost:3001/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }], email: 'cod@test.com', shipping: { name: 'T', phone: '123', city: 'София', address: 'ул.1' }, courier: 'speedy', paymentMethod: 'cod', }), }); expect(r.status).toBe(200); const d = await r.json(); expect(d.orderId).toBeTruthy(); expect(d.viewToken).toBeTruthy(); expect(d.status).toBe('pending'); expect(d.payment.type).toBe('cod'); }); test('checkout with lightning mock', async () => { const r = await fetch('http://localhost:3001/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }], email: 'ln@test.com', shipping: { name: 'T' }, paymentMethod: 'lightning', }), }); expect(r.status).toBe(200); const d = await r.json(); expect(d.payment.pr).toBeTruthy(); }); test('checkout with stripe mock', async () => { const r = await fetch('http://localhost:3001/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }], email: 'stripe@test.com', shipping: { name: 'T' }, paymentMethod: 'stripe', }), }); expect(r.status).toBe(200); const d = await r.json(); expect(d.payment.clientSecret).toBeTruthy(); }); test('checkout rejects empty items', async () => { const r = await fetch('http://localhost:3001/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [], email: 't@t.com', shipping: { name: 'T' }, paymentMethod: 'cod' }), }); expect(r.status).toBe(400); }); test('order view with token', async () => { const create = await fetch('http://localhost:3001/checkout', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'cod' }), }); const { orderId, viewToken } = await create.json(); const r = await fetch(`http://localhost:3001/orders/${orderId}?t=${viewToken}`); expect(r.status).toBe(200); const d = await r.json(); expect(d.id).toBe(orderId); expect(d.items.length).toBe(1); }); test('order view without token returns 404', async () => { const r = await fetch('http://localhost:3001/orders/00000000-0000-0000-0000-000000000000'); expect(r.status).toBe(404); }); // static page checks (dist must be built) import { readFileSync } from 'fs'; test('about page built', () => { const html = readFileSync(join(projectDir, 'dist', 'client', 'about', 'index.html'), 'utf-8'); expect(html).toContain('За нас'); }); test('cart page built', () => { const html = readFileSync(join(projectDir, 'dist', 'client', 'cart', 'index.html'), 'utf-8'); expect(html).toContain('Количка'); });