test.test.ts raw

   1  import { describe, it, expect, beforeAll, afterAll } from 'bun:test';
   2  import { Database } from 'bun:sqlite';
   3  import { applySchema } from '../src/lib/schema';
   4  import { seedProducts } from '../src/lib/products';
   5  import bcrypt from 'bcryptjs';
   6  import crypto from 'crypto';
   7  
   8  let db: Database;
   9  const adminId = crypto.randomUUID();
  10  const userPassword = 'testpass';
  11  
  12  beforeAll(() => {
  13    db = new Database(':memory:');
  14    applySchema(db);
  15  
  16    // products
  17    seedProducts();
  18  
  19    // admin user
  20    const hash = bcrypt.hashSync(userPassword, 10);
  21    db.run('INSERT INTO users (id, email, password_hash, name, is_admin) VALUES (?, ?, ?, ?, 1)', [adminId, 'admin@test.com', hash, 'Admin']);
  22  
  23    // mock getDb
  24    const mod = require.cache ? require.cache[require.resolve('../src/lib/db')] : null;
  25    if (!mod) {
  26      require('../src/lib/db');
  27    }
  28    // the db module uses a module-level singleton; we replace it by
  29    // shadowing process.env.DB_PATH with :memory: and resetting.
  30    // simpler: just override the module's closure via bun:test mock.
  31  });
  32  
  33  import { mock } from 'bun:test';
  34  mock.module('../src/lib/db', () => {
  35    return { getDb: () => db, closeDb: () => {} };
  36  });
  37  
  38  // re-import so the mocked module takes effect
  39  import { getAllProducts, getProductBySlug, getCategories, getProductsByCategory } from '../src/lib/products';
  40  import { createUser, verifyPassword, createSession, getUserFromSession, deleteSession, issueNostrChallenge, verifyNostrAuthEvent } from '../src/lib/auth';
  41  import { getCities, getOfficesByCourier } from '../src/lib/couriers';
  42  import { createOrder, getOrderForView, orderView, CheckoutError } from '../src/lib/orders';
  43  
  44  describe('products', () => {
  45    it('returns all products', () => {
  46      const p = getAllProducts();
  47      expect(p.length).toBe(14);
  48      expect(p[0]).toHaveProperty('slug');
  49      expect(p[0]).toHaveProperty('name');
  50      expect(p[0]).toHaveProperty('price_cents');
  51    });
  52  
  53    it('finds product by slug', () => {
  54      const p = getProductBySlug('borovinka');
  55      expect(p).not.toBeNull();
  56      expect(p!.name).toBe('Боровинка');
  57    });
  58  
  59    it('returns null for unknown slug', () => {
  60      expect(getProductBySlug('nonexistent')).toBeNull();
  61    });
  62  
  63    it('filters by category', () => {
  64      expect(getProductsByCategory('bilki').length).toBeGreaterThan(0);
  65    });
  66  
  67    it('returns categories with counts', () => {
  68      const c = getCategories();
  69      expect(c.length).toBeGreaterThan(0);
  70      expect(c.some(cat => cat.category === 'bilki')).toBe(true);
  71    });
  72  });
  73  
  74  describe('auth - email/password', () => {
  75    it('creates a user', () => {
  76      const id = createUser('test@example.com', 'secret');
  77      expect(id).toBeTruthy();
  78    });
  79  
  80    it('verifies correct password', () => {
  81      expect(verifyPassword('test@example.com', 'secret')).toBeTruthy();
  82    });
  83  
  84    it('rejects wrong password', () => {
  85      expect(verifyPassword('test@example.com', 'wrong')).toBeNull();
  86    });
  87  });
  88  
  89  describe('auth - sessions', () => {
  90    it('creates and validates session', () => {
  91      const token = createSession(adminId);
  92      const user = getUserFromSession(token);
  93      expect(user).toBeTruthy();
  94      expect(user.id).toBe(adminId);
  95      expect(user.is_admin).toBe(1);
  96    });
  97  
  98    it('returns null for invalid session', () => {
  99      expect(getUserFromSession('invalid-token')).toBeNull();
 100    });
 101  
 102    it('rejects expired session', () => {
 103      const token = crypto.randomBytes(32).toString('hex');
 104      db.run('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)', [token, adminId, Date.now() - 1000]);
 105      expect(getUserFromSession(token)).toBeNull();
 106    });
 107  
 108    it('deletes session on logout', () => {
 109      const token = createSession(adminId);
 110      deleteSession(token);
 111      expect(getUserFromSession(token)).toBeNull();
 112    });
 113  });
 114  
 115  describe('auth - nostr challenge', () => {
 116    it('issues and consumes challenge', () => {
 117      const ch = issueNostrChallenge();
 118      expect(ch).toHaveLength(64);
 119      // consume via real signature
 120      const { schnorr } = require('@noble/curves/secp256k1.js');
 121      const priv = crypto.randomBytes(32);
 122      const pub = Buffer.from(schnorr.getPublicKey(priv)).toString('hex');
 123      const now = Math.floor(Date.now() / 1000);
 124      const ev = { kind: 22242, pubkey: pub, created_at: now, tags: [['challenge', ch]], content: '' };
 125      const id = crypto.createHash('sha256').update(new TextEncoder().encode(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]))).digest();
 126      ev.sig = Buffer.from(schnorr.sign(id, priv)).toString('hex');
 127      expect(verifyNostrAuthEvent(ev)).toBe(pub);
 128      // replay fails
 129      expect(verifyNostrAuthEvent(ev)).toBeNull();
 130    });
 131  
 132    it('rejects wrong kind', () => {
 133      const ch = issueNostrChallenge();
 134      const ev = { kind: 1, pubkey: 'a'.repeat(64), created_at: Math.floor(Date.now()/1000), tags: [['challenge', ch]], content: '', sig: '0'.repeat(128) };
 135      expect(verifyNostrAuthEvent(ev)).toBeNull();
 136    });
 137  
 138    it('rejects expired challenge', () => {
 139      const ch = crypto.randomBytes(32).toString('hex');
 140      db.run('INSERT INTO nostr_challenges (challenge, created_at) VALUES (?, ?)', [ch, Date.now() - 10 * 60 * 1000]);
 141      const ev = { kind: 22242, pubkey: 'a'.repeat(64), created_at: Math.floor(Date.now()/1000), tags: [['challenge', ch]], content: '', sig: '0'.repeat(128) };
 142      expect(verifyNostrAuthEvent(ev)).toBeNull();
 143    });
 144  });
 145  
 146  describe('orders', () => {
 147    it('creates order with server-priced items', () => {
 148      const order = createOrder({
 149        items: [{ slug: 'borovinka', qty: 2 }],
 150        email: 'order@test.com',
 151        shipping: { name: 'Test', city: 'София', address: 'ул.1' },
 152        paymentMethod: 'cod',
 153      });
 154      expect(order.id).toBeTruthy();
 155      expect(order.view_token).toBeTruthy();
 156      expect(order.subtotal_cents).toBe(5000); // 2500 * 2
 157      expect(order.shipping_cents).toBe(0);    // free over 50 лв
 158      expect(order.total_cents).toBe(5000);
 159      expect(order.status).toBe('pending');
 160    });
 161  
 162    it('applies shipping cost for small orders', () => {
 163      const order = createOrder({
 164        items: [{ slug: 'menta', qty: 1 }],
 165        email: 't@test.com',
 166        shipping: { name: 'T', city: 'София', address: 'ул.1' },
 167        paymentMethod: 'cod',
 168      });
 169      expect(order.shipping_cents).toBe(490);
 170    });
 171  
 172    it('rejects client price fields (only slug+qty accepted)', () => {
 173      const order = createOrder({
 174        items: [{ slug: 'borovinka', qty: 1, price_cents: 1 }],
 175        email: 't@test.com',
 176        shipping: { name: 'T' },
 177        paymentMethod: 'cod',
 178      });
 179      expect(order.total_cents).toBe(2990); // 2500 + 490 shipping
 180    });
 181  
 182    it('rejects unknown slug', () => {
 183      expect(() => createOrder({
 184        items: [{ slug: 'fake', qty: 1 }],
 185        email: 't@test.com',
 186        shipping: { name: 'T' },
 187        paymentMethod: 'cod',
 188      })).toThrow(CheckoutError);
 189    });
 190  
 191    it('rejects invalid payment method', () => {
 192      expect(() => createOrder({
 193        items: [{ slug: 'borovinka', qty: 1 }],
 194        email: 't@test.com',
 195        shipping: { name: 'T' },
 196        paymentMethod: 'paypal',
 197      })).toThrow(CheckoutError);
 198    });
 199  
 200    it('rejects missing email/shipping name', () => {
 201      expect(() => createOrder({
 202        items: [{ slug: 'borovinka', qty: 1 }],
 203        email: '',
 204        shipping: { name: 'T' },
 205        paymentMethod: 'cod',
 206      })).toThrow(CheckoutError);
 207      expect(() => createOrder({
 208        items: [{ slug: 'borovinka', qty: 1 }],
 209        email: 't@t.com',
 210        shipping: { name: '' },
 211        paymentMethod: 'cod',
 212      })).toThrow(CheckoutError);
 213    });
 214  
 215    it('view_token gates order retrieval', () => {
 216      const order = createOrder({
 217        items: [{ slug: 'menta', qty: 1 }],
 218        email: 't@test.com',
 219        shipping: { name: 'T' },
 220        paymentMethod: 'cod',
 221      });
 222      expect(getOrderForView(order.id, order.view_token)).toBeTruthy();
 223      expect(getOrderForView(order.id, 'wrong-token')).toBeNull();
 224    });
 225  
 226    it('transitions order through state machine', () => {
 227      const { transitionOrder } = require('../src/lib/orders');
 228      const order = createOrder({
 229        items: [{ slug: 'menta', qty: 1 }],
 230        email: 't@test.com',
 231        shipping: { name: 'T' },
 232        paymentMethod: 'cod',
 233      });
 234      expect(transitionOrder(order.id, 'paid').status).toBe('paid');
 235      expect(transitionOrder(order.id, 'shipped').status).toBe('shipped');
 236      expect(() => transitionOrder(order.id, 'pending')).toThrow(CheckoutError);
 237    });
 238  });
 239  
 240  describe('couriers (db-backed)', () => {
 241    it('returns fallback cities when db is empty', () => {
 242      const cities = getCities();
 243      expect(cities.length).toBeGreaterThan(0);
 244    });
 245  
 246    it('returns empty offices without cache', () => {
 247      expect(getOfficesByCourier('econt').length).toBe(0);
 248    });
 249  });
 250