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