elpaypg 0.0.0-stage → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +8 -0
- package/README.md +176 -2
- package/package.json +21 -4
- package/public/elpay-checkout.js +769 -0
- package/public/elpay-sms-gateway.apk +0 -0
- package/public/elpay-sms-gateway.apk.sha256 +1 -0
- package/public/elpay.js +785 -0
- package/public/fonts/dm-sans.woff2 +0 -0
- package/public/fonts/sora.woff2 +0 -0
- package/public/views/checkout.html +2839 -0
- package/public/views/partials/auth.html +110 -0
- package/public/views/partials/footer.html +54 -0
- package/public/views/partials/header.html +101 -0
- package/public/views/partials/modals.html +179 -0
- package/public/views/partials/payment-methods.html +526 -0
- package/public/views/partials/sidebar.html +75 -0
- package/public/views/partials/splash.html +35 -0
- package/src/__tests__/smoke.test.js +8 -0
- package/src/auth-guard.js +95 -0
- package/src/index.js +137 -0
- package/src/router.js +46 -0
- package/src/routes/admin.routes.js +2421 -0
- package/src/routes/checkout.routes.js +2000 -0
- package/src/routes/customer.routes.js +650 -0
- package/src/routes/device.routes.js +303 -0
- package/src/routes/payments.routes.js +446 -0
- package/src/routes/pollers.routes.js +653 -0
- package/src/services/observer-poller.service.js +369 -0
- package/src/services/tunnel-manager.js +130 -0
- package/src/services/whatsapp-berry.service.js +743 -0
- package/src/vendor/common/__tests__/common.test.js +297 -0
- package/src/vendor/common/__tests__/common.test.ts +160 -0
- package/src/vendor/common/contracts.js +64 -0
- package/src/vendor/common/contracts.ts +188 -0
- package/src/vendor/common/crypto.js +132 -0
- package/src/vendor/common/crypto.ts +112 -0
- package/src/vendor/common/enums.js +91 -0
- package/src/vendor/common/enums.ts +91 -0
- package/src/vendor/common/index.js +12 -0
- package/src/vendor/common/index.ts +8 -0
- package/src/vendor/common/money.js +62 -0
- package/src/vendor/common/money.ts +56 -0
- package/src/vendor/common/observation.js +27 -0
- package/src/vendor/common/observation.ts +52 -0
- package/src/vendor/common/runtime-config.js +246 -0
- package/src/vendor/common/secrets.js +374 -0
- package/src/vendor/common/state-machine.d.ts +1 -0
- package/src/vendor/common/state-machine.js +1 -0
- package/src/vendor/common/state-transitions.js +85 -0
- package/src/vendor/common/tunnel-command.js +59 -0
- package/src/vendor/common/upi-providers.js +389 -0
- package/src/vendor/common/upi-providers.ts +400 -0
- package/src/vendor/common/upi.js +69 -0
- package/src/vendor/common/upi.ts +61 -0
- package/src/vendor/core/__tests__/core.test.js +683 -0
- package/src/vendor/core/__tests__/queue-timing.test.js +237 -0
- package/src/vendor/core/__tests__/webhook-engine.test.js +243 -0
- package/src/vendor/core/attribution-engine.js +855 -0
- package/src/vendor/core/bank-email-parser.js +105 -0
- package/src/vendor/core/bank-sms-parser.js +183 -0
- package/src/vendor/core/device-security.js +506 -0
- package/src/vendor/core/index.js +13 -0
- package/src/vendor/core/normalizer.js +165 -0
- package/src/vendor/core/qr-lib.js +8 -0
- package/src/vendor/core/qr.js +62 -0
- package/src/vendor/core/queue-engine.js +153 -0
- package/src/vendor/core/recovery-engine.js +159 -0
- package/src/vendor/core/state-machine.js +42 -0
- package/src/vendor/core/timing-engine.js +272 -0
- package/src/vendor/core/verification-pipeline.js +1164 -0
- package/src/vendor/core/webhook-engine.js +280 -0
- package/src/vendor/db/__tests__/auth-matrix.test.js +564 -0
- package/src/vendor/db/__tests__/customer-reset.test.js +95 -0
- package/src/vendor/db/__tests__/db.test.js +265 -0
- package/src/vendor/db/__tests__/schema-parity.test.js +137 -0
- package/src/vendor/db/__tests__/sqlite_verify.test.js +15 -0
- package/src/vendor/db/database.js +181 -0
- package/src/vendor/db/index.js +12 -0
- package/src/vendor/db/migrate.js +210 -0
- package/src/vendor/db/repositories/audit-log.repository.js +68 -0
- package/src/vendor/db/repositories/customer-session.repository.js +181 -0
- package/src/vendor/db/repositories/customer.repository.js +636 -0
- package/src/vendor/db/repositories/device.repository.js +285 -0
- package/src/vendor/db/repositories/merchant.repository.js +158 -0
- package/src/vendor/db/repositories/observation.repository.js +124 -0
- package/src/vendor/db/repositories/payment-session.repository.js +618 -0
- package/src/vendor/db/repositories/receiving-account.repository.js +244 -0
- package/src/vendor/db/repositories/support-ticket.repository.js +140 -0
- package/src/vendor/db/schema.sql +266 -0
- package/src/vendor/db/sqlite-schema.sql +270 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import http from 'node:http';
|
|
4
|
+
import { Database, runMigrations } from '../index.js';
|
|
5
|
+
import { MerchantRepository } from '../repositories/merchant.repository.js';
|
|
6
|
+
import { ReceivingAccountRepository } from '../repositories/receiving-account.repository.js';
|
|
7
|
+
import { CustomerRepository } from '../repositories/customer.repository.js';
|
|
8
|
+
import { CustomerSessionRepository } from '../repositories/customer-session.repository.js';
|
|
9
|
+
import { PaymentSessionRepository } from '../repositories/payment-session.repository.js';
|
|
10
|
+
import { ObservationRepository } from '../repositories/observation.repository.js';
|
|
11
|
+
import { AuditLogRepository } from '../repositories/audit-log.repository.js';
|
|
12
|
+
import { QueueEngine } from '../../core/index.js';
|
|
13
|
+
import { TimingEngine } from '../../core/index.js';
|
|
14
|
+
import { AttributionEngine } from '../../core/index.js';
|
|
15
|
+
import { WebhookEngine } from '../../core/index.js';
|
|
16
|
+
import { generateQrSvg } from '../../core/index.js';
|
|
17
|
+
import {
|
|
18
|
+
buildUpiUri,
|
|
19
|
+
buildUpiIntentUri,
|
|
20
|
+
getUpiProviders,
|
|
21
|
+
findUpiProviderById,
|
|
22
|
+
PaymentStatus,
|
|
23
|
+
PaymentMethod,
|
|
24
|
+
normalizeEmail,
|
|
25
|
+
normalizeMobile,
|
|
26
|
+
isValidEmail,
|
|
27
|
+
isValidMobile,
|
|
28
|
+
isValidPassword
|
|
29
|
+
} from '../../common/index.js';
|
|
30
|
+
import { startServer as startPaymentApi } from '../../../../apps/payment-api/src/index.js';
|
|
31
|
+
import { CoinWalletStoreServer } from '../../../../apps/coin-wallet-store/src/server.js';
|
|
32
|
+
import { ElpayPaymentClient } from '@elpay/merchant-sdk';
|
|
33
|
+
import { authHeadersFor } from '../../../../scripts/test-support.js';
|
|
34
|
+
|
|
35
|
+
test('Full 53-Point Customer Identity, Authentication & Payment Invariant Matrix', async () => {
|
|
36
|
+
const testDb = new Database({ inMemory: true });
|
|
37
|
+
runMigrations(testDb);
|
|
38
|
+
|
|
39
|
+
const merchantRepo = new MerchantRepository(testDb);
|
|
40
|
+
const accountRepo = new ReceivingAccountRepository(testDb);
|
|
41
|
+
const customerRepo = new CustomerRepository(testDb);
|
|
42
|
+
const customerSessionRepo = new CustomerSessionRepository(testDb, customerRepo);
|
|
43
|
+
const sessionRepo = new PaymentSessionRepository(testDb);
|
|
44
|
+
const obsRepo = new ObservationRepository(testDb);
|
|
45
|
+
const auditRepo = new AuditLogRepository(testDb);
|
|
46
|
+
const queueEngine = new QueueEngine({ sessionRepo, accountRepo });
|
|
47
|
+
const timingEngine = new TimingEngine({ sessionRepo, accountRepo });
|
|
48
|
+
const attributionEngine = new AttributionEngine({ sessionRepo, obsRepo });
|
|
49
|
+
const webhookEngine = new WebhookEngine({ db: testDb, merchantRepo });
|
|
50
|
+
|
|
51
|
+
const merchant = merchantRepo.create({
|
|
52
|
+
businessName: 'Nova Commerce Ltd',
|
|
53
|
+
slug: 'nova-store',
|
|
54
|
+
contactEmail: 'support@novacommerce.com',
|
|
55
|
+
contactPhone: '+919876543210',
|
|
56
|
+
webhookUrl: 'https://novacommerce.com/webhook',
|
|
57
|
+
webhookSecret: 'sec_test_novacommerce_secret_key',
|
|
58
|
+
branding: {
|
|
59
|
+
appName: 'NovaPay',
|
|
60
|
+
primaryColor: '#0284c7',
|
|
61
|
+
logoUrl: 'https://novacommerce.com/logo.png',
|
|
62
|
+
enabledPaymentMethods: ['UPI', 'CASH_COUNTER', 'CARD_COUNTER']
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const receivingAccount = accountRepo.create({
|
|
67
|
+
merchantId: merchant.id,
|
|
68
|
+
upiId: 'novastore@hdfcbank',
|
|
69
|
+
payeeName: 'Nova Commerce India',
|
|
70
|
+
isMerchantEnabled: true,
|
|
71
|
+
isActive: true
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const apiInstance = await startPaymentApi(6984, testDb);
|
|
75
|
+
const apiBase = `http://localhost:${apiInstance.port}`;
|
|
76
|
+
|
|
77
|
+
const demoStore = new CoinWalletStoreServer({
|
|
78
|
+
gatewayApiUrl: apiBase,
|
|
79
|
+
merchantId: merchant.id,
|
|
80
|
+
webhookSecret: merchant.webhookSecret
|
|
81
|
+
});
|
|
82
|
+
const demoServer = demoStore.createServer();
|
|
83
|
+
await new Promise(resolve => demoServer.listen(5984, resolve));
|
|
84
|
+
const demoBase = 'http://localhost:5984';
|
|
85
|
+
|
|
86
|
+
function httpReq(method, url, body = null, headers = {}) {
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const u = new URL(url);
|
|
89
|
+
const postData = body ? JSON.stringify(body) : null;
|
|
90
|
+
const reqHeaders = {
|
|
91
|
+
'Content-Type': 'application/json',
|
|
92
|
+
...authHeadersFor(url),
|
|
93
|
+
...headers
|
|
94
|
+
};
|
|
95
|
+
if (postData) {
|
|
96
|
+
reqHeaders['Content-Length'] = Buffer.byteLength(postData);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const req = http.request(
|
|
100
|
+
{
|
|
101
|
+
hostname: u.hostname,
|
|
102
|
+
port: u.port,
|
|
103
|
+
path: u.pathname + u.search,
|
|
104
|
+
method,
|
|
105
|
+
headers: reqHeaders
|
|
106
|
+
},
|
|
107
|
+
res => {
|
|
108
|
+
let text = '';
|
|
109
|
+
res.on('data', chunk => { text += chunk; });
|
|
110
|
+
res.on('end', () => {
|
|
111
|
+
let json = null;
|
|
112
|
+
try { json = JSON.parse(text); } catch {}
|
|
113
|
+
resolve({ status: res.statusCode, headers: res.headers, text, body: json });
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
req.on('error', reject);
|
|
118
|
+
if (postData) req.write(postData);
|
|
119
|
+
req.end();
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
// --- AUTH CREATION (1 - 6) ---
|
|
125
|
+
// 1. Signup with valid name/email/mobile/password
|
|
126
|
+
const reg1 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
|
|
127
|
+
name: 'Gaurav Sharma',
|
|
128
|
+
email: 'Gaurav.Sharma@Example.COM',
|
|
129
|
+
mobileNumber: '+91 9876543210',
|
|
130
|
+
password: 'StrongPassword123!'
|
|
131
|
+
});
|
|
132
|
+
assert.equal(reg1.status, 201);
|
|
133
|
+
assert.ok(reg1.body.token);
|
|
134
|
+
assert.equal(reg1.body.customer.name, 'Gaurav Sharma');
|
|
135
|
+
assert.equal(reg1.body.customer.email, 'gaurav.sharma@example.com');
|
|
136
|
+
assert.equal(reg1.body.customer.mobileNumber, '9876543210');
|
|
137
|
+
|
|
138
|
+
// 2. Password is hashed
|
|
139
|
+
const custInDb = testDb.queryOne('SELECT * FROM customers WHERE id = ?', [reg1.body.customer.id]);
|
|
140
|
+
assert.ok(custInDb.password_hash);
|
|
141
|
+
assert.notEqual(custInDb.password_hash, 'StrongPassword123!');
|
|
142
|
+
assert.ok(custInDb.password_hash.includes(':'));
|
|
143
|
+
|
|
144
|
+
// 3. Plaintext password is never persisted or returned
|
|
145
|
+
assert.equal(reg1.body.password, undefined);
|
|
146
|
+
assert.equal(reg1.body.customer.password, undefined);
|
|
147
|
+
assert.equal(reg1.body.customer.passwordHash, undefined);
|
|
148
|
+
assert.equal(JSON.stringify(custInDb).includes('StrongPassword123!'), false);
|
|
149
|
+
|
|
150
|
+
// 4. Duplicate email is rejected
|
|
151
|
+
const dupEmail = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
|
|
152
|
+
name: 'Another Person',
|
|
153
|
+
email: 'gaurav.sharma@example.com',
|
|
154
|
+
mobileNumber: '9123456780',
|
|
155
|
+
password: 'AnotherPassword123!'
|
|
156
|
+
});
|
|
157
|
+
assert.equal(dupEmail.status, 409);
|
|
158
|
+
|
|
159
|
+
// 5. Duplicate mobile is rejected
|
|
160
|
+
const dupMobile = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
|
|
161
|
+
name: 'Another Person',
|
|
162
|
+
email: 'another.unique@example.com',
|
|
163
|
+
mobileNumber: '9876543210',
|
|
164
|
+
password: 'AnotherPassword123!'
|
|
165
|
+
});
|
|
166
|
+
assert.equal(dupMobile.status, 409);
|
|
167
|
+
|
|
168
|
+
// 5.5 Email normalization
|
|
169
|
+
assert.equal(normalizeEmail(' USER.Name+tag@DOMAIN.Co.In '), 'user.name+tag@domain.co.in');
|
|
170
|
+
assert.equal(isValidEmail('not-an-email'), false);
|
|
171
|
+
assert.equal(isValidEmail('valid.user@sub.domain.org'), true);
|
|
172
|
+
|
|
173
|
+
// 6. Mobile normalization
|
|
174
|
+
assert.equal(normalizeMobile('+91 98765-43210'), '9876543210');
|
|
175
|
+
assert.equal(normalizeMobile('09876543210'), '9876543210');
|
|
176
|
+
assert.equal(normalizeMobile('919876543210'), '9876543210');
|
|
177
|
+
assert.equal(isValidMobile('9876543210'), true);
|
|
178
|
+
assert.equal(isValidMobile('12345'), false);
|
|
179
|
+
|
|
180
|
+
// --- LOGIN (7 - 10) ---
|
|
181
|
+
// 7. Login with valid credentials (email & mobile)
|
|
182
|
+
const loginEmail = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
|
|
183
|
+
identifier: 'gaurav.sharma@example.com',
|
|
184
|
+
password: 'StrongPassword123!'
|
|
185
|
+
});
|
|
186
|
+
assert.equal(loginEmail.status, 200);
|
|
187
|
+
assert.ok(loginEmail.body.token);
|
|
188
|
+
|
|
189
|
+
const loginMobile = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
|
|
190
|
+
identifier: '9876543210',
|
|
191
|
+
password: 'StrongPassword123!'
|
|
192
|
+
});
|
|
193
|
+
assert.equal(loginMobile.status, 200);
|
|
194
|
+
assert.ok(loginMobile.body.token);
|
|
195
|
+
|
|
196
|
+
// 8. Invalid password rejected
|
|
197
|
+
const badPass = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
|
|
198
|
+
identifier: 'gaurav.sharma@example.com',
|
|
199
|
+
password: 'WrongPassword999!'
|
|
200
|
+
});
|
|
201
|
+
assert.equal(badPass.status, 401);
|
|
202
|
+
|
|
203
|
+
// 9. Unknown account rejected safely
|
|
204
|
+
const unknownAcc = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
|
|
205
|
+
identifier: 'nonexistent@example.com',
|
|
206
|
+
password: 'AnyPassword123!'
|
|
207
|
+
});
|
|
208
|
+
assert.equal(unknownAcc.status, 401);
|
|
209
|
+
|
|
210
|
+
// 10. Valid session can be restored
|
|
211
|
+
const restoreSession = await httpReq('GET', `${apiBase}/api/v1/customer/me`, null, {
|
|
212
|
+
'Authorization': `Bearer ${loginEmail.body.token}`
|
|
213
|
+
});
|
|
214
|
+
assert.equal(restoreSession.status, 200);
|
|
215
|
+
assert.equal(restoreSession.body.authenticated, true);
|
|
216
|
+
assert.equal(restoreSession.body.customer.id, reg1.body.customer.id);
|
|
217
|
+
|
|
218
|
+
// --- AUTO LOGIN (11 - 14) ---
|
|
219
|
+
// 11 & 12. Return to checkout with valid remembered session
|
|
220
|
+
const autoVerify = customerSessionRepo.findByToken(loginEmail.body.token);
|
|
221
|
+
assert.ok(autoVerify);
|
|
222
|
+
assert.equal(autoVerify.customer.name, 'Gaurav Sharma');
|
|
223
|
+
|
|
224
|
+
// 13. Payment session does NOT need recreation on page refresh
|
|
225
|
+
const paymentClient = new ElpayPaymentClient({
|
|
226
|
+
apiUrl: apiBase,
|
|
227
|
+
merchantId: merchant.id,
|
|
228
|
+
webhookSecret: merchant.webhookSecret
|
|
229
|
+
});
|
|
230
|
+
const p1 = await paymentClient.createPayment({
|
|
231
|
+
orderId: 'ORD-AUTO-01',
|
|
232
|
+
amount: '150.00',
|
|
233
|
+
returnUrl: 'https://novacommerce.com/complete'
|
|
234
|
+
});
|
|
235
|
+
assert.ok(p1.status === 'INITIATED' || p1.status === 'ACTIVE');
|
|
236
|
+
const p1StateA = await httpReq('GET', `${apiBase}/api/v1/checkout/${p1.paymentId}/state`);
|
|
237
|
+
const p1StateB = await httpReq('GET', `${apiBase}/api/v1/checkout/${p1.paymentId}/state`);
|
|
238
|
+
assert.equal(p1StateA.body.paymentId, p1.paymentId);
|
|
239
|
+
assert.equal(p1StateB.body.paymentId, p1.paymentId);
|
|
240
|
+
sessionRepo.transitionToTerminal(p1.paymentId, PaymentStatus.CANCELLED);
|
|
241
|
+
|
|
242
|
+
// 14. Expired auth session requires reauthentication
|
|
243
|
+
const expiredSess = customerSessionRepo.create({
|
|
244
|
+
customerId: reg1.body.customer.id,
|
|
245
|
+
ttlHours: -1
|
|
246
|
+
});
|
|
247
|
+
assert.equal(customerSessionRepo.findByToken(expiredSess.token), null);
|
|
248
|
+
const meExpired = await httpReq('GET', `${apiBase}/api/v1/customer/me`, null, {
|
|
249
|
+
'Authorization': `Bearer ${expiredSess.token}`
|
|
250
|
+
});
|
|
251
|
+
assert.equal(meExpired.status, 401);
|
|
252
|
+
|
|
253
|
+
// --- PAYMENT GATE (15 - 19) ---
|
|
254
|
+
// 15, 16, 17. Unauthenticated checkout does not start session, timer or queue
|
|
255
|
+
const unauthPayment = await paymentClient.createPayment({
|
|
256
|
+
orderId: 'ORD-GATE-01',
|
|
257
|
+
amount: '200.00',
|
|
258
|
+
autoActivate: false
|
|
259
|
+
});
|
|
260
|
+
const unauthSession = sessionRepo.findById(unauthPayment.paymentId);
|
|
261
|
+
assert.ok(unauthSession.status === PaymentStatus.INITIATED || unauthSession.status === PaymentStatus.QUEUED);
|
|
262
|
+
assert.equal(unauthSession.expiresAt, null);
|
|
263
|
+
assert.equal(unauthSession.customerId, null);
|
|
264
|
+
const accountStateBefore = accountRepo.findById(receivingAccount.id);
|
|
265
|
+
assert.equal(accountStateBefore.isSlotBusy, false);
|
|
266
|
+
|
|
267
|
+
// 18. Customer authenticates successfully
|
|
268
|
+
const authSess = customerSessionRepo.create({ customerId: reg1.body.customer.id });
|
|
269
|
+
assert.ok(authSess.token);
|
|
270
|
+
|
|
271
|
+
// 19. Payment session then starts
|
|
272
|
+
const bindRes = await httpReq('POST', `${apiBase}/api/v1/checkout/${unauthPayment.paymentId}/bind-customer`, {
|
|
273
|
+
token: authSess.token
|
|
274
|
+
});
|
|
275
|
+
assert.equal(bindRes.status, 200);
|
|
276
|
+
assert.equal(bindRes.body.status, PaymentStatus.ACTIVE);
|
|
277
|
+
assert.ok(bindRes.body.expiresAt);
|
|
278
|
+
|
|
279
|
+
const activatedSession = sessionRepo.findById(unauthPayment.paymentId);
|
|
280
|
+
assert.equal(activatedSession.status, PaymentStatus.ACTIVE);
|
|
281
|
+
assert.equal(activatedSession.customerId, reg1.body.customer.id);
|
|
282
|
+
const accountStateAfter = accountRepo.findById(receivingAccount.id);
|
|
283
|
+
assert.equal(accountStateAfter.isSlotBusy, true);
|
|
284
|
+
|
|
285
|
+
// --- ACCOUNT SWITCHING (20 - 26) ---
|
|
286
|
+
// 20, 21, 22. 2-Account browser limit enforcement
|
|
287
|
+
const reg2 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
|
|
288
|
+
name: 'Pooja Verma',
|
|
289
|
+
email: 'pooja.verma@example.com',
|
|
290
|
+
mobileNumber: '9888877777',
|
|
291
|
+
password: 'Password987!'
|
|
292
|
+
});
|
|
293
|
+
assert.equal(reg2.status, 201);
|
|
294
|
+
|
|
295
|
+
function simulateSave(store, cust, token) {
|
|
296
|
+
const entry = { id: cust.id, name: cust.name, email: cust.email, mobileNumber: cust.mobileNumber, token, lastUsed: Date.now() };
|
|
297
|
+
const idx = store.findIndex(a => a.id === cust.id);
|
|
298
|
+
if (idx >= 0) store[idx] = entry;
|
|
299
|
+
else if (store.length >= 2) {
|
|
300
|
+
store.sort((a, b) => a.lastUsed - b.lastUsed);
|
|
301
|
+
store[0] = entry;
|
|
302
|
+
} else {
|
|
303
|
+
store.push(entry);
|
|
304
|
+
}
|
|
305
|
+
return store.slice(0, 2);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
let accountsStore = [];
|
|
309
|
+
accountsStore = simulateSave(accountsStore, reg1.body.customer, reg1.body.token);
|
|
310
|
+
assert.equal(accountsStore.length, 1);
|
|
311
|
+
accountsStore = simulateSave(accountsStore, reg2.body.customer, reg2.body.token);
|
|
312
|
+
assert.equal(accountsStore.length, 2);
|
|
313
|
+
|
|
314
|
+
const reg3 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
|
|
315
|
+
name: 'Third User',
|
|
316
|
+
email: 'third.user@example.com',
|
|
317
|
+
mobileNumber: '9666655555',
|
|
318
|
+
password: 'Password654!'
|
|
319
|
+
});
|
|
320
|
+
accountsStore = simulateSave(accountsStore, reg3.body.customer, reg3.body.token);
|
|
321
|
+
assert.equal(accountsStore.length, 2);
|
|
322
|
+
|
|
323
|
+
// 23 & 24. Switch from Account A to Account B, and Account B to Account A
|
|
324
|
+
const switchResB = await httpReq('POST', `${apiBase}/api/v1/customer/switch-account`, {
|
|
325
|
+
token: reg2.body.token
|
|
326
|
+
});
|
|
327
|
+
assert.equal(switchResB.status, 200);
|
|
328
|
+
assert.equal(switchResB.body.customer.email, 'pooja.verma@example.com');
|
|
329
|
+
|
|
330
|
+
const switchResA = await httpReq('POST', `${apiBase}/api/v1/customer/switch-account`, {
|
|
331
|
+
token: reg1.body.token
|
|
332
|
+
});
|
|
333
|
+
assert.equal(switchResA.status, 200);
|
|
334
|
+
assert.equal(switchResA.body.customer.email, 'gaurav.sharma@example.com');
|
|
335
|
+
|
|
336
|
+
// 25. Expired account session asks for reauthentication
|
|
337
|
+
const switchExpired = await httpReq('POST', `${apiBase}/api/v1/customer/switch-account`, {
|
|
338
|
+
token: expiredSess.token
|
|
339
|
+
});
|
|
340
|
+
assert.equal(switchExpired.status, 401);
|
|
341
|
+
|
|
342
|
+
// 26. Switching account does not mutate another account's data
|
|
343
|
+
const c1 = customerRepo.findById(reg1.body.customer.id);
|
|
344
|
+
const c2 = customerRepo.findById(reg2.body.customer.id);
|
|
345
|
+
assert.equal(c1.name, 'Gaurav Sharma');
|
|
346
|
+
assert.equal(c2.name, 'Pooja Verma');
|
|
347
|
+
assert.notEqual(c1.email, c2.email);
|
|
348
|
+
|
|
349
|
+
// --- LOGOUT (27 - 31) ---
|
|
350
|
+
// 27. Logout invalidates current session
|
|
351
|
+
const logoutRes = await httpReq('POST', `${apiBase}/api/v1/customer/logout`, {
|
|
352
|
+
token: reg1.body.token
|
|
353
|
+
});
|
|
354
|
+
assert.equal(logoutRes.status, 200);
|
|
355
|
+
const afterLogout = await httpReq('GET', `${apiBase}/api/v1/customer/me`, null, {
|
|
356
|
+
'Authorization': `Bearer ${reg1.body.token}`
|
|
357
|
+
});
|
|
358
|
+
assert.equal(afterLogout.status, 401);
|
|
359
|
+
|
|
360
|
+
// 28. Saved account remains available
|
|
361
|
+
assert.equal(accountsStore.length, 2);
|
|
362
|
+
|
|
363
|
+
// 29. Logout does not delete customer database record
|
|
364
|
+
assert.ok(customerRepo.findById(reg1.body.customer.id));
|
|
365
|
+
|
|
366
|
+
// 30. User can log back into saved account
|
|
367
|
+
const reLogin = await httpReq('POST', `${apiBase}/api/v1/customer/login`, {
|
|
368
|
+
identifier: 'gaurav.sharma@example.com',
|
|
369
|
+
password: 'StrongPassword123!'
|
|
370
|
+
});
|
|
371
|
+
assert.equal(reLogin.status, 200);
|
|
372
|
+
|
|
373
|
+
// 31. User can sign in/create another account
|
|
374
|
+
const reg4 = await httpReq('POST', `${apiBase}/api/v1/customer/register`, {
|
|
375
|
+
name: 'Fourth Customer',
|
|
376
|
+
email: 'fourth@example.com',
|
|
377
|
+
mobileNumber: '9555544444',
|
|
378
|
+
password: 'Password555!'
|
|
379
|
+
});
|
|
380
|
+
assert.equal(reg4.status, 201);
|
|
381
|
+
|
|
382
|
+
// --- PAYMENT INTEGRITY (32 - 36) ---
|
|
383
|
+
// 32, 33, 34, 35. Identity immutability and anti-hijacking
|
|
384
|
+
const payActive = sessionRepo.findById(unauthPayment.paymentId);
|
|
385
|
+
assert.equal(payActive.customerId, reg1.body.customer.id);
|
|
386
|
+
|
|
387
|
+
const hijackRes = await httpReq('POST', `${apiBase}/api/v1/checkout/${unauthPayment.paymentId}/bind-customer`, {
|
|
388
|
+
token: reg2.body.token
|
|
389
|
+
});
|
|
390
|
+
assert.equal(hijackRes.status, 409);
|
|
391
|
+
|
|
392
|
+
const tamperRes = await httpReq('POST', `${apiBase}/api/v1/checkout/${unauthPayment.paymentId}/bind-customer`, {
|
|
393
|
+
customerId: reg2.body.customer.id
|
|
394
|
+
});
|
|
395
|
+
assert.equal(tamperRes.status, 409);
|
|
396
|
+
assert.equal(sessionRepo.findById(unauthPayment.paymentId).customerId, reg1.body.customer.id);
|
|
397
|
+
|
|
398
|
+
// 36. Existing payment verification continues working
|
|
399
|
+
const obs = attributionEngine.processObservation({
|
|
400
|
+
receivingAccountId: receivingAccount.id,
|
|
401
|
+
source: 'ANDROID_OBSERVER',
|
|
402
|
+
amountPaise: 20000n,
|
|
403
|
+
observedAt: new Date(),
|
|
404
|
+
senderVpa: 'payer@upi',
|
|
405
|
+
bankReferenceNumber: 'UTR_MATRIX_36',
|
|
406
|
+
fingerprintHash: 'fp_matrix_' + Date.now(),
|
|
407
|
+
rawPayload: {}
|
|
408
|
+
});
|
|
409
|
+
assert.equal(obs.attributed, true);
|
|
410
|
+
assert.equal(obs.paymentSessionId, unauthPayment.paymentId);
|
|
411
|
+
assert.equal(obs.outcomeStatus, PaymentStatus.SUCCESS);
|
|
412
|
+
|
|
413
|
+
// --- DEMO STORE (37 - 40) ---
|
|
414
|
+
// 37 & 38. Demo Purchase redirects without storefront auth
|
|
415
|
+
const demoBuy = await httpReq('POST', `${demoBase}/api/checkout`, {
|
|
416
|
+
packName: 'Gamer Pack',
|
|
417
|
+
coins: 500,
|
|
418
|
+
price: 500
|
|
419
|
+
});
|
|
420
|
+
assert.equal(demoBuy.status, 200);
|
|
421
|
+
assert.ok(demoBuy.body.checkoutUrl);
|
|
422
|
+
|
|
423
|
+
// 39. Checkout authentication appears
|
|
424
|
+
const demoState = await httpReq('GET', `${apiBase}/api/v1/checkout/${demoBuy.body.paymentId}/state`);
|
|
425
|
+
assert.equal(demoState.body.customer, null);
|
|
426
|
+
assert.equal(demoState.body.status, 'INITIATED');
|
|
427
|
+
|
|
428
|
+
// 40. Payment begins only after authentication
|
|
429
|
+
const bindDemo = await httpReq('POST', `${apiBase}/api/v1/checkout/${demoBuy.body.paymentId}/bind-customer`, {
|
|
430
|
+
token: reLogin.body.token
|
|
431
|
+
});
|
|
432
|
+
assert.equal(bindDemo.status, 200);
|
|
433
|
+
assert.equal(bindDemo.body.status, PaymentStatus.ACTIVE);
|
|
434
|
+
assert.ok(bindDemo.body.expiresAt);
|
|
435
|
+
|
|
436
|
+
// Complete demo payment to free slot for next test
|
|
437
|
+
sessionRepo.transitionToTerminal(demoBuy.body.paymentId, PaymentStatus.SUCCESS);
|
|
438
|
+
testDb.execute('UPDATE payment_receiving_accounts SET is_slot_busy = 0, current_active_session_id = NULL WHERE id = ?', [receivingAccount.id]);
|
|
439
|
+
|
|
440
|
+
// --- MERCHANT INTEGRATION (41 - 43) ---
|
|
441
|
+
// 41, 42, 43. Generic merchant integration creates payment without customer auth
|
|
442
|
+
const genericPay = await paymentClient.createPayment({
|
|
443
|
+
orderId: 'EXT-MERCHANT-ORD-101',
|
|
444
|
+
amount: '50.00',
|
|
445
|
+
returnUrl: 'https://thirdparty.com/return'
|
|
446
|
+
});
|
|
447
|
+
assert.ok(genericPay.paymentId);
|
|
448
|
+
assert.ok(genericPay.checkoutUrl);
|
|
449
|
+
assert.equal(genericPay.status, 'INITIATED');
|
|
450
|
+
|
|
451
|
+
const genericBind = await httpReq('POST', `${apiBase}/api/v1/checkout/${genericPay.paymentId}/bind-customer`, {
|
|
452
|
+
token: reLogin.body.token
|
|
453
|
+
});
|
|
454
|
+
assert.equal(genericBind.status, 200);
|
|
455
|
+
assert.equal(genericBind.body.status, 'ACTIVE');
|
|
456
|
+
assert.equal(genericBind.body.customer.name, 'Gaurav Sharma');
|
|
457
|
+
|
|
458
|
+
// Complete generic payment to free slot for queue tests
|
|
459
|
+
sessionRepo.transitionToTerminal(genericPay.paymentId, PaymentStatus.SUCCESS);
|
|
460
|
+
testDb.execute('UPDATE payment_receiving_accounts SET is_slot_busy = 0, current_active_session_id = NULL WHERE id = ?', [receivingAccount.id]);
|
|
461
|
+
|
|
462
|
+
// --- REGRESSION (44 - 53) ---
|
|
463
|
+
// 44. UPI QR works
|
|
464
|
+
const upiUri = buildUpiUri({
|
|
465
|
+
payeeVpa: receivingAccount.upiId,
|
|
466
|
+
payeeName: receivingAccount.payeeName,
|
|
467
|
+
amountPaise: 5000n,
|
|
468
|
+
transactionRef: 'REG-UPI-44',
|
|
469
|
+
currency: 'INR'
|
|
470
|
+
});
|
|
471
|
+
assert.equal(upiUri, 'upi://pay?pa=novastore%40hdfcbank&pn=Nova+Commerce+India&am=50.00&tn=REG-UPI-44');
|
|
472
|
+
const qrSvg = generateQrSvg(upiUri, 220);
|
|
473
|
+
assert.ok(qrSvg.includes('<svg') && qrSvg.includes('</svg>'));
|
|
474
|
+
|
|
475
|
+
// 45. UPI provider buttons work
|
|
476
|
+
const providers = getUpiProviders();
|
|
477
|
+
assert.equal(providers.length, 30);
|
|
478
|
+
const gpay = findUpiProviderById('google-pay');
|
|
479
|
+
const intent = buildUpiIntentUri({
|
|
480
|
+
payeeVpa: receivingAccount.upiId,
|
|
481
|
+
payeeName: receivingAccount.payeeName,
|
|
482
|
+
amountPaise: 5000n,
|
|
483
|
+
transactionRef: 'REG-UPI-45',
|
|
484
|
+
packageName: gpay.packageName
|
|
485
|
+
});
|
|
486
|
+
assert.ok(intent.startsWith('intent://pay?'));
|
|
487
|
+
assert.ok(intent.includes('package=' + gpay.packageName));
|
|
488
|
+
assert.equal(intent.includes('cu='), false);
|
|
489
|
+
|
|
490
|
+
// 46. Existing merchant/non-merchant UPI behavior works
|
|
491
|
+
const nonMerchantAccount = accountRepo.create({
|
|
492
|
+
merchantId: merchant.id,
|
|
493
|
+
upiId: 'personal.store@okhdfcbank',
|
|
494
|
+
payeeName: 'Personal Store Account',
|
|
495
|
+
isMerchantEnabled: false,
|
|
496
|
+
isActive: false
|
|
497
|
+
});
|
|
498
|
+
assert.equal(nonMerchantAccount.isMerchantEnabled, false);
|
|
499
|
+
|
|
500
|
+
// 47. Queue logic works
|
|
501
|
+
const qPay1 = sessionRepo.create({
|
|
502
|
+
merchantId: merchant.id,
|
|
503
|
+
receivingAccountId: receivingAccount.id,
|
|
504
|
+
orderId: 'QUEUE-ORD-1',
|
|
505
|
+
amountPaise: 1000n,
|
|
506
|
+
status: PaymentStatus.INITIATED
|
|
507
|
+
});
|
|
508
|
+
const qRes1 = queueEngine.enqueueOrActivate(qPay1.id, receivingAccount.id);
|
|
509
|
+
assert.equal(qRes1.status, PaymentStatus.ACTIVE);
|
|
510
|
+
|
|
511
|
+
const qPay2 = sessionRepo.create({
|
|
512
|
+
merchantId: merchant.id,
|
|
513
|
+
receivingAccountId: receivingAccount.id,
|
|
514
|
+
orderId: 'QUEUE-ORD-2',
|
|
515
|
+
amountPaise: 1000n,
|
|
516
|
+
status: PaymentStatus.INITIATED
|
|
517
|
+
});
|
|
518
|
+
const qRes2 = queueEngine.enqueueOrActivate(qPay2.id, receivingAccount.id);
|
|
519
|
+
assert.equal(qRes2.status, PaymentStatus.QUEUED);
|
|
520
|
+
assert.equal(qRes2.queuePosition, 1);
|
|
521
|
+
|
|
522
|
+
// 48. Timing logic works
|
|
523
|
+
const proc = timingEngine.handlePaymentDone(qPay1.id);
|
|
524
|
+
assert.equal(proc.status, PaymentStatus.PROCESSING);
|
|
525
|
+
assert.ok(proc.processingExpiresAt);
|
|
526
|
+
|
|
527
|
+
// 49. Verification works
|
|
528
|
+
const qObs = attributionEngine.processObservation({
|
|
529
|
+
receivingAccountId: receivingAccount.id,
|
|
530
|
+
source: 'GMAIL_OBSERVER',
|
|
531
|
+
amountPaise: 1000n,
|
|
532
|
+
observedAt: new Date(),
|
|
533
|
+
bankReferenceNumber: 'UTR_QUEUE_DONE_01',
|
|
534
|
+
fingerprintHash: 'fp_q_' + Date.now(),
|
|
535
|
+
rawPayload: {}
|
|
536
|
+
});
|
|
537
|
+
assert.equal(qObs.attributed, true);
|
|
538
|
+
assert.equal(qObs.paymentSessionId, qPay1.id);
|
|
539
|
+
|
|
540
|
+
// 50. Webhook delivery works
|
|
541
|
+
const whResult = await webhookEngine.dispatch('SUCCESS', sessionRepo.findById(qPay1.id));
|
|
542
|
+
assert.ok(whResult.deliveryId);
|
|
543
|
+
|
|
544
|
+
// 51. Merchant dashboard works
|
|
545
|
+
const dashSettings = await httpReq('GET', `${apiBase}/api/v1/admin/merchant-settings?merchantId=${merchant.id}`);
|
|
546
|
+
assert.equal(dashSettings.status, 200);
|
|
547
|
+
assert.equal(dashSettings.body.businessName, 'Nova Commerce Ltd');
|
|
548
|
+
|
|
549
|
+
// 52. Observer services work
|
|
550
|
+
const pollerActive = await httpReq('GET', `${apiBase}/api/v1/pollers/active-window`);
|
|
551
|
+
assert.equal(pollerActive.status, 200);
|
|
552
|
+
assert.ok(Array.isArray(pollerActive.body.sessions));
|
|
553
|
+
|
|
554
|
+
// 53. Control center works
|
|
555
|
+
const health = await httpReq('GET', `${apiBase}/health`);
|
|
556
|
+
assert.equal(health.status, 200);
|
|
557
|
+
assert.equal(health.body.status, 'UP');
|
|
558
|
+
|
|
559
|
+
} finally {
|
|
560
|
+
await apiInstance.close();
|
|
561
|
+
await new Promise(resolve => demoServer.close(resolve));
|
|
562
|
+
testDb.close();
|
|
563
|
+
}
|
|
564
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { Database, runMigrations } from '../index.js';
|
|
6
|
+
|
|
7
|
+
test('Production & Development Database Safe Customer Reset and Zero Residue Invariant', () => {
|
|
8
|
+
// 1. PROD RESET & VERIFICATION
|
|
9
|
+
const prodPath = path.resolve('data/elpay_pg.sqlite');
|
|
10
|
+
if (!fs.existsSync(prodPath)) {
|
|
11
|
+
const parentDir = path.dirname(prodPath);
|
|
12
|
+
if (!fs.existsSync(parentDir)) {
|
|
13
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
14
|
+
}
|
|
15
|
+
const initDb = new Database({ dbPath: prodPath });
|
|
16
|
+
runMigrations(initDb);
|
|
17
|
+
initDb.close();
|
|
18
|
+
}
|
|
19
|
+
assert.ok(fs.existsSync(prodPath), `Production DB must exist at ${prodPath}`);
|
|
20
|
+
|
|
21
|
+
const prodBackupDir = path.resolve('data/backups');
|
|
22
|
+
if (!fs.existsSync(prodBackupDir)) {
|
|
23
|
+
fs.mkdirSync(prodBackupDir, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
const prodBackupPath = path.join(prodBackupDir, `prod_pre_auth_reset_${Date.now()}.sqlite`);
|
|
26
|
+
fs.copyFileSync(prodPath, prodBackupPath);
|
|
27
|
+
assert.ok(fs.existsSync(prodBackupPath), 'Production backup must be created');
|
|
28
|
+
|
|
29
|
+
const prodDb = new Database({ dbPath: prodPath });
|
|
30
|
+
const prodMerchantsBefore = prodDb.query('SELECT count(*) as c FROM merchants')[0].c;
|
|
31
|
+
const prodAccountsBefore = prodDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
|
|
32
|
+
|
|
33
|
+
try { prodDb.execute('UPDATE payment_sessions SET customer_id = NULL WHERE customer_id IS NOT NULL;'); } catch {}
|
|
34
|
+
try { prodDb.execute('DELETE FROM ticket_messages;'); } catch {}
|
|
35
|
+
try { prodDb.execute('DELETE FROM support_tickets;'); } catch {}
|
|
36
|
+
try { prodDb.execute('DROP TABLE IF EXISTS customer_sessions;'); } catch {}
|
|
37
|
+
try { prodDb.execute('DROP TABLE IF EXISTS customers;'); } catch {}
|
|
38
|
+
|
|
39
|
+
runMigrations(prodDb);
|
|
40
|
+
|
|
41
|
+
prodDb.execute('DELETE FROM customer_sessions;');
|
|
42
|
+
prodDb.execute('DELETE FROM customers;');
|
|
43
|
+
|
|
44
|
+
const prodCustomers = prodDb.query('SELECT count(*) as c FROM customers')[0].c;
|
|
45
|
+
const prodCustomerSessions = prodDb.query('SELECT count(*) as c FROM customer_sessions')[0].c;
|
|
46
|
+
const prodMerchantsAfter = prodDb.query('SELECT count(*) as c FROM merchants')[0].c;
|
|
47
|
+
const prodAccountsAfter = prodDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
|
|
48
|
+
|
|
49
|
+
assert.equal(prodCustomers, 0, 'Production customer count must strictly equal 0');
|
|
50
|
+
assert.equal(prodCustomerSessions, 0, 'Production customer session count must strictly equal 0');
|
|
51
|
+
assert.equal(prodMerchantsAfter, prodMerchantsBefore, 'Production merchants must remain 100% intact');
|
|
52
|
+
assert.equal(prodAccountsAfter, prodAccountsBefore, 'Production receiving accounts must remain 100% intact');
|
|
53
|
+
prodDb.close();
|
|
54
|
+
|
|
55
|
+
// 2. DEV RESET & VERIFICATION
|
|
56
|
+
const devPath = process.env.DEV_DB_PATH || path.resolve('data/elpay_pg_dev.sqlite');
|
|
57
|
+
if (fs.existsSync(devPath)) {
|
|
58
|
+
const devBackupDir = path.resolve('data/backups');
|
|
59
|
+
if (!fs.existsSync(devBackupDir)) {
|
|
60
|
+
fs.mkdirSync(devBackupDir, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
const devBackupPath = path.join(devBackupDir, `dev_pre_auth_reset_${Date.now()}.sqlite`);
|
|
63
|
+
fs.copyFileSync(devPath, devBackupPath);
|
|
64
|
+
assert.ok(fs.existsSync(devBackupPath), 'Dev backup must be created');
|
|
65
|
+
|
|
66
|
+
const devDb = new Database({ dbPath: devPath });
|
|
67
|
+
const devMerchantsBefore = devDb.query('SELECT count(*) as c FROM merchants')[0].c;
|
|
68
|
+
const devAccountsBefore = devDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
|
|
69
|
+
const devObsBefore = devDb.query('SELECT count(*) as c FROM payment_observations')[0].c;
|
|
70
|
+
|
|
71
|
+
try { devDb.execute('UPDATE payment_sessions SET customer_id = NULL WHERE customer_id IS NOT NULL;'); } catch {}
|
|
72
|
+
try { devDb.execute('DELETE FROM ticket_messages;'); } catch {}
|
|
73
|
+
try { devDb.execute('DELETE FROM support_tickets;'); } catch {}
|
|
74
|
+
try { devDb.execute('DROP TABLE IF EXISTS customer_sessions;'); } catch {}
|
|
75
|
+
try { devDb.execute('DROP TABLE IF EXISTS customers;'); } catch {}
|
|
76
|
+
|
|
77
|
+
runMigrations(devDb);
|
|
78
|
+
|
|
79
|
+
devDb.execute('DELETE FROM customer_sessions;');
|
|
80
|
+
devDb.execute('DELETE FROM customers;');
|
|
81
|
+
|
|
82
|
+
const devCustomers = devDb.query('SELECT count(*) as c FROM customers')[0].c;
|
|
83
|
+
const devCustomerSessions = devDb.query('SELECT count(*) as c FROM customer_sessions')[0].c;
|
|
84
|
+
const devMerchantsAfter = devDb.query('SELECT count(*) as c FROM merchants')[0].c;
|
|
85
|
+
const devAccountsAfter = devDb.query('SELECT count(*) as c FROM payment_receiving_accounts')[0].c;
|
|
86
|
+
const devObsAfter = devDb.query('SELECT count(*) as c FROM payment_observations')[0].c;
|
|
87
|
+
|
|
88
|
+
assert.equal(devCustomers, 0, 'Dev customer count must strictly equal 0');
|
|
89
|
+
assert.equal(devCustomerSessions, 0, 'Dev customer session count must strictly equal 0');
|
|
90
|
+
assert.equal(devMerchantsAfter, devMerchantsBefore, 'Dev merchants must remain 100% intact');
|
|
91
|
+
assert.equal(devAccountsAfter, devAccountsBefore, 'Dev receiving accounts must remain 100% intact');
|
|
92
|
+
assert.equal(devObsAfter, devObsBefore, 'Dev observations must remain 100% intact');
|
|
93
|
+
devDb.close();
|
|
94
|
+
}
|
|
95
|
+
});
|