import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; import { Database } from 'bun:sqlite'; import { applySchema } from '../src/lib/schema'; import { seedProducts } from '../src/lib/products'; import bcrypt from 'bcryptjs'; import crypto from 'crypto'; let db: Database; const adminId = crypto.randomUUID(); const userPassword = 'testpass'; beforeAll(() => { db = new Database(':memory:'); applySchema(db); // products seedProducts(); // admin user const hash = bcrypt.hashSync(userPassword, 10); db.run('INSERT INTO users (id, email, password_hash, name, is_admin) VALUES (?, ?, ?, ?, 1)', [adminId, 'admin@test.com', hash, 'Admin']); // mock getDb const mod = require.cache ? require.cache[require.resolve('../src/lib/db')] : null; if (!mod) { require('../src/lib/db'); } // the db module uses a module-level singleton; we replace it by // shadowing process.env.DB_PATH with :memory: and resetting. // simpler: just override the module's closure via bun:test mock. }); import { mock } from 'bun:test'; mock.module('../src/lib/db', () => { return { getDb: () => db, closeDb: () => {} }; }); // re-import so the mocked module takes effect import { getAllProducts, getProductBySlug, getCategories, getProductsByCategory } from '../src/lib/products'; import { createUser, verifyPassword, createSession, getUserFromSession, deleteSession, issueNostrChallenge, verifyNostrAuthEvent } from '../src/lib/auth'; import { getCities, getOfficesByCourier } from '../src/lib/couriers'; import { createOrder, getOrderForView, orderView, CheckoutError } from '../src/lib/orders'; describe('products', () => { it('returns all products', () => { const p = getAllProducts(); expect(p.length).toBe(14); expect(p[0]).toHaveProperty('slug'); expect(p[0]).toHaveProperty('name'); expect(p[0]).toHaveProperty('price_cents'); }); it('finds product by slug', () => { const p = getProductBySlug('borovinka'); expect(p).not.toBeNull(); expect(p!.name).toBe('Боровинка'); }); it('returns null for unknown slug', () => { expect(getProductBySlug('nonexistent')).toBeNull(); }); it('filters by category', () => { expect(getProductsByCategory('bilki').length).toBeGreaterThan(0); }); it('returns categories with counts', () => { const c = getCategories(); expect(c.length).toBeGreaterThan(0); expect(c.some(cat => cat.category === 'bilki')).toBe(true); }); }); describe('auth - email/password', () => { it('creates a user', () => { const id = createUser('test@example.com', 'secret'); expect(id).toBeTruthy(); }); it('verifies correct password', () => { expect(verifyPassword('test@example.com', 'secret')).toBeTruthy(); }); it('rejects wrong password', () => { expect(verifyPassword('test@example.com', 'wrong')).toBeNull(); }); }); describe('auth - sessions', () => { it('creates and validates session', () => { const token = createSession(adminId); const user = getUserFromSession(token); expect(user).toBeTruthy(); expect(user.id).toBe(adminId); expect(user.is_admin).toBe(1); }); it('returns null for invalid session', () => { expect(getUserFromSession('invalid-token')).toBeNull(); }); it('rejects expired session', () => { const token = crypto.randomBytes(32).toString('hex'); db.run('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)', [token, adminId, Date.now() - 1000]); expect(getUserFromSession(token)).toBeNull(); }); it('deletes session on logout', () => { const token = createSession(adminId); deleteSession(token); expect(getUserFromSession(token)).toBeNull(); }); }); describe('auth - nostr challenge', () => { it('issues and consumes challenge', () => { const ch = issueNostrChallenge(); expect(ch).toHaveLength(64); // consume via real signature const { schnorr } = require('@noble/curves/secp256k1.js'); 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', ch]], 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'); expect(verifyNostrAuthEvent(ev)).toBe(pub); // replay fails expect(verifyNostrAuthEvent(ev)).toBeNull(); }); it('rejects wrong kind', () => { const ch = issueNostrChallenge(); const ev = { kind: 1, pubkey: 'a'.repeat(64), created_at: Math.floor(Date.now()/1000), tags: [['challenge', ch]], content: '', sig: '0'.repeat(128) }; expect(verifyNostrAuthEvent(ev)).toBeNull(); }); it('rejects expired challenge', () => { const ch = crypto.randomBytes(32).toString('hex'); db.run('INSERT INTO nostr_challenges (challenge, created_at) VALUES (?, ?)', [ch, Date.now() - 10 * 60 * 1000]); const ev = { kind: 22242, pubkey: 'a'.repeat(64), created_at: Math.floor(Date.now()/1000), tags: [['challenge', ch]], content: '', sig: '0'.repeat(128) }; expect(verifyNostrAuthEvent(ev)).toBeNull(); }); }); describe('orders', () => { it('creates order with server-priced items', () => { const order = createOrder({ items: [{ slug: 'borovinka', qty: 2 }], email: 'order@test.com', shipping: { name: 'Test', city: 'София', address: 'ул.1' }, paymentMethod: 'cod', }); expect(order.id).toBeTruthy(); expect(order.view_token).toBeTruthy(); expect(order.subtotal_cents).toBe(5000); // 2500 * 2 expect(order.shipping_cents).toBe(0); // free over 50 лв expect(order.total_cents).toBe(5000); expect(order.status).toBe('pending'); }); it('applies shipping cost for small orders', () => { const order = createOrder({ items: [{ slug: 'menta', qty: 1 }], email: 't@test.com', shipping: { name: 'T', city: 'София', address: 'ул.1' }, paymentMethod: 'cod', }); expect(order.shipping_cents).toBe(490); }); it('rejects client price fields (only slug+qty accepted)', () => { const order = createOrder({ items: [{ slug: 'borovinka', qty: 1, price_cents: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'cod', }); expect(order.total_cents).toBe(2990); // 2500 + 490 shipping }); it('rejects unknown slug', () => { expect(() => createOrder({ items: [{ slug: 'fake', qty: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'cod', })).toThrow(CheckoutError); }); it('rejects invalid payment method', () => { expect(() => createOrder({ items: [{ slug: 'borovinka', qty: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'paypal', })).toThrow(CheckoutError); }); it('rejects missing email/shipping name', () => { expect(() => createOrder({ items: [{ slug: 'borovinka', qty: 1 }], email: '', shipping: { name: 'T' }, paymentMethod: 'cod', })).toThrow(CheckoutError); expect(() => createOrder({ items: [{ slug: 'borovinka', qty: 1 }], email: 't@t.com', shipping: { name: '' }, paymentMethod: 'cod', })).toThrow(CheckoutError); }); it('view_token gates order retrieval', () => { const order = createOrder({ items: [{ slug: 'menta', qty: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'cod', }); expect(getOrderForView(order.id, order.view_token)).toBeTruthy(); expect(getOrderForView(order.id, 'wrong-token')).toBeNull(); }); it('transitions order through state machine', () => { const { transitionOrder } = require('../src/lib/orders'); const order = createOrder({ items: [{ slug: 'menta', qty: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'cod', }); expect(transitionOrder(order.id, 'paid').status).toBe('paid'); expect(transitionOrder(order.id, 'shipped').status).toBe('shipped'); expect(() => transitionOrder(order.id, 'pending')).toThrow(CheckoutError); }); }); describe('couriers (db-backed)', () => { it('returns fallback cities when db is empty', () => { const cities = getCities(); expect(cities.length).toBeGreaterThan(0); }); it('returns empty offices without cache', () => { expect(getOfficesByCourier('econt').length).toBe(0); }); });