e2e.test.ts raw

   1  import { test, expect, beforeAll, afterAll } from 'bun:test';
   2  import { spawn, type ChildProcess } from 'child_process';
   3  import { join } from 'path';
   4  import { existsSync, unlinkSync, writeFileSync } from 'fs';
   5  
   6  const projectDir = join(import.meta.dir, '..');
   7  let apiProc: ChildProcess;
   8  
   9  beforeAll(async () => {
  10    const envPath = join(projectDir, '.env');
  11    const testDb = join(projectDir, 'data', 'test.db');
  12    if (existsSync(testDb)) unlinkSync(testDb);
  13    writeFileSync(envPath, `SITE_URL=http://localhost:3000
  14  API_PORT=3001
  15  DB_PATH=${testDb}
  16  JWT_SECRET=test-secret
  17  LUD16_ADDRESS=
  18  STRIPE_SECRET_KEY=
  19  BTCPAY_URL=
  20  PAYMENT_MOCKS=1
  21  `);
  22  
  23    // run setup
  24    const setup = spawn('bun', ['run', 'src/setup.ts'], {
  25      cwd: projectDir,
  26      env: { ...process.env, ADMIN_PASSWORD: 'pa55word', DB_PATH: testDb },
  27      stdio: ['ignore', 'pipe', 'pipe'],
  28    });
  29    let err = '';
  30    setup.stderr.on('data', (d: any) => { err += d.toString(); });
  31    const code = await new Promise<number>((resolve) => { setup.on('close', resolve); });
  32    if (code !== 0) {
  33      console.error('setup stderr:', err);
  34      throw new Error('setup failed');
  35    }
  36  
  37    // start api
  38    apiProc = spawn('bun', ['run', 'src/server.ts'], {
  39      cwd: projectDir,
  40      env: { ...process.env, API_PORT: '3001', DB_PATH: testDb, PAYMENT_MOCKS: '1' },
  41      stdio: ['ignore', 'pipe', 'pipe'],
  42    });
  43  
  44    for (let i = 0; i < 50; i++) {
  45      try {
  46        const r = await fetch('http://localhost:3001/products');
  47        if (r.ok) break;
  48      } catch {}
  49      await Bun.sleep(300);
  50    }
  51  });
  52  
  53  afterAll(() => {
  54    apiProc?.kill();
  55  });
  56  
  57  test('GET /products returns 14 products', async () => {
  58    const r = await fetch('http://localhost:3001/products');
  59    expect(r.status).toBe(200);
  60    const data = await r.json();
  61    expect(Array.isArray(data)).toBe(true);
  62    expect(data.length).toBe(14);
  63  });
  64  
  65  test('GET /products/:slug returns product', async () => {
  66    const r = await fetch('http://localhost:3001/products/borovinka');
  67    expect(r.status).toBe(200);
  68    const data = await r.json();
  69    expect(data.slug).toBe('borovinka');
  70    expect(data.price_cents).toBeGreaterThan(0);
  71  });
  72  
  73  test('GET /products/:slug returns 404', async () => {
  74    const r = await fetch('http://localhost:3001/products/nope');
  75    expect(r.status).toBe(404);
  76  });
  77  
  78  test('GET /categories', async () => {
  79    const r = await fetch('http://localhost:3001/categories');
  80    expect(r.status).toBe(200);
  81    const data = await r.json();
  82    expect(data.length).toBeGreaterThan(0);
  83  });
  84  
  85  test('POST /shipping-quote', async () => {
  86    const r = await fetch('http://localhost:3001/shipping-quote', {
  87      method: 'POST',
  88      headers: { 'Content-Type': 'application/json' },
  89      body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }] }),
  90    });
  91    expect(r.status).toBe(200);
  92    const d = await r.json();
  93    expect(d.subtotal_cents).toBe(1400);
  94    expect(d.shipping_cents).toBe(490);
  95  });
  96  
  97  test('POST /auth/login success', async () => {
  98    const r = await fetch('http://localhost:3001/auth/login', {
  99      method: 'POST',
 100      headers: { 'Content-Type': 'application/json' },
 101      body: JSON.stringify({ email: 'admin@zlattea.com', password: 'pa55word' }),
 102    });
 103    expect(r.status).toBe(200);
 104    const d = await r.json();
 105    expect(d).toHaveProperty('token');
 106    expect(d.user.email).toBe('admin@zlattea.com');
 107  });
 108  
 109  test('POST /auth/login wrong password', async () => {
 110    const r = await fetch('http://localhost:3001/auth/login', {
 111      method: 'POST',
 112      headers: { 'Content-Type': 'application/json' },
 113      body: JSON.stringify({ email: 'admin@zlattea.com', password: 'wrong' }),
 114    });
 115    expect(r.status).toBe(401);
 116  });
 117  
 118  test('GET /auth/session returns user', async () => {
 119    const login = await fetch('http://localhost:3001/auth/login', {
 120      method: 'POST',
 121      headers: { 'Content-Type': 'application/json' },
 122      body: JSON.stringify({ email: 'admin@zlattea.com', password: 'pa55word' }),
 123    });
 124    const { token } = await login.json();
 125    const r = await fetch('http://localhost:3001/auth/session', {
 126      headers: { 'Authorization': 'Bearer ' + token },
 127    });
 128    expect(r.status).toBe(200);
 129    const d = await r.json();
 130    expect(d.user.email).toBe('admin@zlattea.com');
 131    expect(d.user.is_admin).toBe(1);
 132  });
 133  
 134  test('GET /auth/session returns null without token', async () => {
 135    const r = await fetch('http://localhost:3001/auth/session');
 136    const d = await r.json();
 137    expect(d.user).toBeNull();
 138  });
 139  
 140  test('POST /auth/logout', async () => {
 141    const login = await fetch('http://localhost:3001/auth/login', {
 142      method: 'POST',
 143      headers: { 'Content-Type': 'application/json' },
 144      body: JSON.stringify({ email: 'admin@zlattea.com', password: 'pa55word' }),
 145    });
 146    const { token } = await login.json();
 147    await fetch('http://localhost:3001/auth/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + token } });
 148    const r = await fetch('http://localhost:3001/auth/session', { headers: { 'Authorization': 'Bearer ' + token } });
 149    expect((await r.json()).user).toBeNull();
 150  });
 151  
 152  test('Nostr full flow (challenge -> sign -> login)', async () => {
 153    const chRes = await fetch('http://localhost:3001/auth/nostr-challenge');
 154    const { challenge } = await chRes.json();
 155    const { schnorr } = require('@noble/curves/secp256k1.js');
 156    const crypto = require('crypto');
 157    const priv = crypto.randomBytes(32);
 158    const pub = Buffer.from(schnorr.getPublicKey(priv)).toString('hex');
 159    const now = Math.floor(Date.now() / 1000);
 160    const ev = { kind: 22242, pubkey: pub, created_at: now, tags: [['challenge', challenge]], content: '' };
 161    const id = crypto.createHash('sha256').update(new TextEncoder().encode(JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content]))).digest();
 162    ev.sig = Buffer.from(schnorr.sign(id, priv)).toString('hex');
 163    const r = await fetch('http://localhost:3001/auth/nostr', {
 164      method: 'POST',
 165      headers: { 'Content-Type': 'application/json' },
 166      body: JSON.stringify({ event: ev }),
 167    });
 168    expect(r.status).toBe(200);
 169    const d = await r.json();
 170    expect(d).toHaveProperty('token');
 171    expect(d.user.pubkey).toBe(pub);
 172  });
 173  
 174  test('Nostr bad pubkey rejected', async () => {
 175    const chRes = await fetch('http://localhost:3001/auth/nostr-challenge');
 176    const { challenge } = await chRes.json();
 177    const r = await fetch('http://localhost:3001/auth/nostr', {
 178      method: 'POST',
 179      headers: { 'Content-Type': 'application/json' },
 180      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) } }),
 181    });
 182    expect(r.status).toBe(401);
 183  });
 184  
 185  test('checkout cod', async () => {
 186    const r = await fetch('http://localhost:3001/checkout', {
 187      method: 'POST',
 188      headers: { 'Content-Type': 'application/json' },
 189      body: JSON.stringify({
 190        items: [{ slug: 'menta', qty: 1 }],
 191        email: 'cod@test.com',
 192        shipping: { name: 'T', phone: '123', city: 'София', address: 'ул.1' },
 193        courier: 'speedy',
 194        paymentMethod: 'cod',
 195      }),
 196    });
 197    expect(r.status).toBe(200);
 198    const d = await r.json();
 199    expect(d.orderId).toBeTruthy();
 200    expect(d.viewToken).toBeTruthy();
 201    expect(d.status).toBe('pending');
 202    expect(d.payment.type).toBe('cod');
 203  });
 204  
 205  test('checkout with lightning mock', async () => {
 206    const r = await fetch('http://localhost:3001/checkout', {
 207      method: 'POST',
 208      headers: { 'Content-Type': 'application/json' },
 209      body: JSON.stringify({
 210        items: [{ slug: 'menta', qty: 1 }],
 211        email: 'ln@test.com',
 212        shipping: { name: 'T' },
 213        paymentMethod: 'lightning',
 214      }),
 215    });
 216    expect(r.status).toBe(200);
 217    const d = await r.json();
 218    expect(d.payment.pr).toBeTruthy();
 219  });
 220  
 221  test('checkout with stripe mock', async () => {
 222    const r = await fetch('http://localhost:3001/checkout', {
 223      method: 'POST',
 224      headers: { 'Content-Type': 'application/json' },
 225      body: JSON.stringify({
 226        items: [{ slug: 'menta', qty: 1 }],
 227        email: 'stripe@test.com',
 228        shipping: { name: 'T' },
 229        paymentMethod: 'stripe',
 230      }),
 231    });
 232    expect(r.status).toBe(200);
 233    const d = await r.json();
 234    expect(d.payment.clientSecret).toBeTruthy();
 235  });
 236  
 237  test('checkout rejects empty items', async () => {
 238    const r = await fetch('http://localhost:3001/checkout', {
 239      method: 'POST',
 240      headers: { 'Content-Type': 'application/json' },
 241      body: JSON.stringify({ items: [], email: 't@t.com', shipping: { name: 'T' }, paymentMethod: 'cod' }),
 242    });
 243    expect(r.status).toBe(400);
 244  });
 245  
 246  test('order view with token', async () => {
 247    const create = await fetch('http://localhost:3001/checkout', {
 248      method: 'POST',
 249      headers: { 'Content-Type': 'application/json' },
 250      body: JSON.stringify({ items: [{ slug: 'menta', qty: 1 }], email: 't@test.com', shipping: { name: 'T' }, paymentMethod: 'cod' }),
 251    });
 252    const { orderId, viewToken } = await create.json();
 253    const r = await fetch(`http://localhost:3001/orders/${orderId}?t=${viewToken}`);
 254    expect(r.status).toBe(200);
 255    const d = await r.json();
 256    expect(d.id).toBe(orderId);
 257    expect(d.items.length).toBe(1);
 258  });
 259  
 260  test('order view without token returns 404', async () => {
 261    const r = await fetch('http://localhost:3001/orders/00000000-0000-0000-0000-000000000000');
 262    expect(r.status).toBe(404);
 263  });
 264  
 265  // static page checks (dist must be built)
 266  import { readFileSync } from 'fs';
 267  test('about page built', () => {
 268    const html = readFileSync(join(projectDir, 'dist', 'client', 'about', 'index.html'), 'utf-8');
 269    expect(html).toContain('За нас');
 270  });
 271  test('cart page built', () => {
 272    const html = readFileSync(join(projectDir, 'dist', 'client', 'cart', 'index.html'), 'utf-8');
 273    expect(html).toContain('Количка');
 274  });
 275