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