wiki-plugin-shoppe 0.0.19 → 0.0.21
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/CLAUDE.md +56 -0
- package/client/shoppe.js +7 -5
- package/package.json +6 -2
- package/server/server.js +570 -46
- package/server/templates/appointment-booking.html +458 -0
- package/server/templates/generic-address-stripe.html +55 -58
- package/server/templates/subscription-membership.html +290 -0
- package/server/templates/subscription-subscribe.html +258 -0
- package/test/test.js +376 -0
package/test/test.js
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// ── Set up a temp HOME before the server module loads, so DATA_DIR
|
|
4
|
+
// (~/.shoppe) is isolated from any real shoppe data on this machine.
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
|
|
9
|
+
const TEST_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'shoppe-test-'));
|
|
10
|
+
process.env.HOME = TEST_HOME;
|
|
11
|
+
|
|
12
|
+
// ── Dependencies ─────────────────────────────────────────────────────────────
|
|
13
|
+
const express = require('express');
|
|
14
|
+
const AdmZip = require('adm-zip');
|
|
15
|
+
const fetch = require('node-fetch');
|
|
16
|
+
const FormData = require('form-data');
|
|
17
|
+
const assert = require('assert');
|
|
18
|
+
const { should } = require('chai');
|
|
19
|
+
should();
|
|
20
|
+
|
|
21
|
+
const { startServer } = require('../server/server.js');
|
|
22
|
+
|
|
23
|
+
// ── Config ────────────────────────────────────────────────────────────────────
|
|
24
|
+
const TEST_PORT = 9977;
|
|
25
|
+
const BASE_URL = `http://localhost:${TEST_PORT}`;
|
|
26
|
+
const SANORA_URL = process.env.SANORA_URL || `http://localhost:${process.env.SANORA_PORT || 7243}`;
|
|
27
|
+
|
|
28
|
+
// Shared state
|
|
29
|
+
let httpServer;
|
|
30
|
+
let tenant = null; // populated after register
|
|
31
|
+
let sanoraAvailable = false;
|
|
32
|
+
|
|
33
|
+
// ── Sanora connectivity check ─────────────────────────────────────────────────
|
|
34
|
+
async function checkSanora() {
|
|
35
|
+
try {
|
|
36
|
+
const resp = await fetch(`${SANORA_URL}/products/ping`, { timeout: 2000 });
|
|
37
|
+
// Any response (even 404) means Sanora is up
|
|
38
|
+
return resp.status < 600;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ── Test archive factory ──────────────────────────────────────────────────────
|
|
45
|
+
function buildTestArchive(uuid, emojicode) {
|
|
46
|
+
const zip = new AdmZip();
|
|
47
|
+
|
|
48
|
+
zip.addFile('manifest.json', Buffer.from(JSON.stringify({
|
|
49
|
+
uuid, emojicode, name: 'Integration Test Shoppe'
|
|
50
|
+
})));
|
|
51
|
+
|
|
52
|
+
// book
|
|
53
|
+
zip.addFile('books/Test Book/info.json', Buffer.from(JSON.stringify({
|
|
54
|
+
title: 'Test Book', description: 'A test ebook', price: 999
|
|
55
|
+
})));
|
|
56
|
+
zip.addFile('books/Test Book/test-book.epub', Buffer.from('fake epub'));
|
|
57
|
+
zip.addFile('books/Test Book/cover.jpg', Buffer.from('fake jpg'));
|
|
58
|
+
|
|
59
|
+
// music album
|
|
60
|
+
zip.addFile('music/Test Album/info.json', Buffer.from(JSON.stringify({
|
|
61
|
+
title: 'Test Album', description: 'A test album', price: 599
|
|
62
|
+
})));
|
|
63
|
+
zip.addFile('music/Test Album/cover.jpg', Buffer.from('fake jpg'));
|
|
64
|
+
zip.addFile('music/Test Album/01-track.mp3', Buffer.from('fake mp3'));
|
|
65
|
+
|
|
66
|
+
// post
|
|
67
|
+
zip.addFile('posts/01-Hello Post/post.md', Buffer.from([
|
|
68
|
+
'+++',
|
|
69
|
+
'title = "Hello Post"',
|
|
70
|
+
'date = "2026-01-01"',
|
|
71
|
+
'+++',
|
|
72
|
+
'',
|
|
73
|
+
'# Hello',
|
|
74
|
+
'Test post content.',
|
|
75
|
+
].join('\n')));
|
|
76
|
+
|
|
77
|
+
// album
|
|
78
|
+
zip.addFile('albums/Vacation 2025/photo1.jpg', Buffer.from('fake jpg'));
|
|
79
|
+
|
|
80
|
+
// physical product with shipping
|
|
81
|
+
zip.addFile('products/01-Widget/info.json', Buffer.from(JSON.stringify({
|
|
82
|
+
title: 'Test Widget', description: 'A physical thing', price: 2500, shipping: 500
|
|
83
|
+
})));
|
|
84
|
+
zip.addFile('products/01-Widget/hero.jpg', Buffer.from('fake jpg'));
|
|
85
|
+
|
|
86
|
+
// appointment — all 7 days so slots always generate
|
|
87
|
+
zip.addFile('appointments/Test Session/info.json', Buffer.from(JSON.stringify({
|
|
88
|
+
title: 'Test Session',
|
|
89
|
+
description: 'One hour session',
|
|
90
|
+
price: 10000,
|
|
91
|
+
duration: 60,
|
|
92
|
+
timezone: 'America/New_York',
|
|
93
|
+
advanceDays: 14,
|
|
94
|
+
availability: [
|
|
95
|
+
{ day: 'Monday', slots: ['09:00', '10:00', '11:00'] },
|
|
96
|
+
{ day: 'Tuesday', slots: ['09:00', '10:00', '11:00'] },
|
|
97
|
+
{ day: 'Wednesday', slots: ['09:00', '10:00', '11:00'] },
|
|
98
|
+
{ day: 'Thursday', slots: ['09:00', '10:00', '11:00'] },
|
|
99
|
+
{ day: 'Friday', slots: ['09:00', '10:00', '11:00'] },
|
|
100
|
+
{ day: 'Saturday', slots: ['10:00', '11:00'] },
|
|
101
|
+
{ day: 'Sunday', slots: ['10:00', '11:00'] },
|
|
102
|
+
]
|
|
103
|
+
})));
|
|
104
|
+
zip.addFile('appointments/Test Session/cover.jpg', Buffer.from('fake jpg'));
|
|
105
|
+
|
|
106
|
+
// subscription tier with exclusive content
|
|
107
|
+
zip.addFile('subscriptions/Bronze Tier/info.json', Buffer.from(JSON.stringify({
|
|
108
|
+
title: 'Bronze Tier',
|
|
109
|
+
description: 'Entry-level supporter',
|
|
110
|
+
price: 500,
|
|
111
|
+
renewalDays: 30,
|
|
112
|
+
benefits: ['Monthly exclusive track', 'Early access']
|
|
113
|
+
})));
|
|
114
|
+
zip.addFile('subscriptions/Bronze Tier/cover.jpg', Buffer.from('fake jpg'));
|
|
115
|
+
zip.addFile('subscriptions/Bronze Tier/bonus.mp3', Buffer.from('fake exclusive mp3'));
|
|
116
|
+
|
|
117
|
+
return zip.toBuffer();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
|
121
|
+
before(async function() {
|
|
122
|
+
// If SANORA_URL env var is set, write it to config before server starts
|
|
123
|
+
if (process.env.SANORA_URL) {
|
|
124
|
+
const cfgDir = path.join(TEST_HOME, '.shoppe');
|
|
125
|
+
fs.mkdirSync(cfgDir, { recursive: true });
|
|
126
|
+
fs.writeFileSync(path.join(cfgDir, 'config.json'),
|
|
127
|
+
JSON.stringify({ sanoraUrl: process.env.SANORA_URL }));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const app = express();
|
|
131
|
+
app.use(express.json());
|
|
132
|
+
app.securityhandler = { isAuthorized: () => true };
|
|
133
|
+
|
|
134
|
+
await startServer({ app });
|
|
135
|
+
await new Promise(resolve => {
|
|
136
|
+
httpServer = app.listen(TEST_PORT, resolve);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
sanoraAvailable = await checkSanora();
|
|
140
|
+
if (!sanoraAvailable) {
|
|
141
|
+
console.log('\n ⚠️ Sanora not running — skipping upload/purchase tests.');
|
|
142
|
+
console.log(` Start Sanora or set SANORA_URL=... to run the full suite.\n`);
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
after(done => {
|
|
147
|
+
httpServer.close(() => {
|
|
148
|
+
fs.rmSync(TEST_HOME, { recursive: true, force: true });
|
|
149
|
+
done();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
154
|
+
async function uploadArchive(zipBuffer) {
|
|
155
|
+
const form = new FormData();
|
|
156
|
+
form.append('archive', zipBuffer, { filename: 'test.zip', contentType: 'application/zip' });
|
|
157
|
+
return fetch(`${BASE_URL}/plugin/shoppe/upload`, {
|
|
158
|
+
method: 'POST', body: form, headers: form.getHeaders()
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Always-on tests (no Sanora needed) ───────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
it('should return 404 for an unknown shoppe identifier', async () => {
|
|
165
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/00000000-0000-0000-0000-000000000000`);
|
|
166
|
+
resp.status.should.equal(404);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('should reject an archive whose manifest uuid/emojicode are missing', async () => {
|
|
170
|
+
const zip = new AdmZip();
|
|
171
|
+
zip.addFile('manifest.json', Buffer.from(JSON.stringify({ name: 'No UUID Shoppe' })));
|
|
172
|
+
const resp = await uploadArchive(zip.toBuffer());
|
|
173
|
+
const data = await resp.json();
|
|
174
|
+
data.success.should.equal(false);
|
|
175
|
+
data.error.should.match(/uuid|emojicode/i);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should reject an archive whose uuid does not match any registered tenant', async () => {
|
|
179
|
+
const zip = new AdmZip();
|
|
180
|
+
zip.addFile('manifest.json', Buffer.from(JSON.stringify({
|
|
181
|
+
uuid: '00000000-0000-0000-0000-000000000000',
|
|
182
|
+
emojicode: '🛍️🎨🎁🌟💎🐉📚🔥',
|
|
183
|
+
name: 'Ghost Shoppe'
|
|
184
|
+
})));
|
|
185
|
+
const resp = await uploadArchive(zip.toBuffer());
|
|
186
|
+
const data = await resp.json();
|
|
187
|
+
data.success.should.equal(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should generate appointment slots from a schedule object', () => {
|
|
191
|
+
// Test the slot generation logic directly without needing Sanora.
|
|
192
|
+
// We reconstruct the same algorithm used in generateAvailableSlots.
|
|
193
|
+
const DAY_NAMES = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];
|
|
194
|
+
const schedule = {
|
|
195
|
+
timezone: 'UTC',
|
|
196
|
+
advanceDays: 7,
|
|
197
|
+
duration: 60,
|
|
198
|
+
availability: [
|
|
199
|
+
{ day: 'Monday', slots: ['09:00', '10:00'] },
|
|
200
|
+
{ day: 'Tuesday', slots: ['14:00'] },
|
|
201
|
+
]
|
|
202
|
+
};
|
|
203
|
+
const bookedSlots = new Set();
|
|
204
|
+
const results = [];
|
|
205
|
+
const now = new Date();
|
|
206
|
+
|
|
207
|
+
const dateFmt = new Intl.DateTimeFormat('en-CA', { timeZone: 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' });
|
|
208
|
+
const weekdayFmt = new Intl.DateTimeFormat('en-US', { timeZone: 'UTC', weekday: 'long' });
|
|
209
|
+
|
|
210
|
+
for (let d = 0; d < 7; d++) {
|
|
211
|
+
const day = new Date(now.getTime() + d * 86400000);
|
|
212
|
+
const dateStr = dateFmt.format(day);
|
|
213
|
+
const weekdayStr = weekdayFmt.format(day).toLowerCase();
|
|
214
|
+
const avail = schedule.availability.find(a => a.day.toLowerCase() === weekdayStr);
|
|
215
|
+
if (!avail) continue;
|
|
216
|
+
const slots = avail.slots
|
|
217
|
+
.map(t => `${dateStr}T${t}`)
|
|
218
|
+
.filter(s => !bookedSlots.has(s));
|
|
219
|
+
if (slots.length) results.push({ date: dateStr, slots });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
results.should.be.an('array');
|
|
223
|
+
results.length.should.be.at.least(1, 'expected at least one slot day in the next 7 days');
|
|
224
|
+
results[0].slots[0].should.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/, 'slot format should be YYYY-MM-DDTHH:MM');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── Sanora-dependent tests ────────────────────────────────────────────────────
|
|
228
|
+
// These require Sanora (and optionally Addie) to be running.
|
|
229
|
+
// Run with: SANORA_URL=https://dev.allyabase.com/plugin/allyabase/sanora npm test
|
|
230
|
+
|
|
231
|
+
describe('Sanora integration', function() {
|
|
232
|
+
before(function() {
|
|
233
|
+
if (!sanoraAvailable) this.skip();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('should register a new tenant', async () => {
|
|
237
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/register`, {
|
|
238
|
+
method: 'POST',
|
|
239
|
+
headers: { 'Content-Type': 'application/json' },
|
|
240
|
+
body: JSON.stringify({ name: 'Test Shoppe' })
|
|
241
|
+
});
|
|
242
|
+
const data = await resp.json();
|
|
243
|
+
if (!data.success) console.error(' error:', data.error);
|
|
244
|
+
data.success.should.equal(true);
|
|
245
|
+
data.tenant.uuid.should.be.a('string');
|
|
246
|
+
data.tenant.emojicode.should.be.a('string');
|
|
247
|
+
|
|
248
|
+
tenant = data.tenant;
|
|
249
|
+
console.log(` tenant: ${tenant.emojicode} (${tenant.uuid})`);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should list tenants including the new one', async () => {
|
|
253
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/tenants`);
|
|
254
|
+
const data = await resp.json();
|
|
255
|
+
data.success.should.equal(true);
|
|
256
|
+
const found = data.tenants.find(t => t.uuid === tenant.uuid);
|
|
257
|
+
found.should.exist;
|
|
258
|
+
found.name.should.equal('Test Shoppe');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should upload an archive with all content categories', async () => {
|
|
262
|
+
const resp = await uploadArchive(buildTestArchive(tenant.uuid, tenant.emojicode));
|
|
263
|
+
const data = await resp.json();
|
|
264
|
+
if (!data.success) console.error(' error:', data.error);
|
|
265
|
+
data.success.should.equal(true);
|
|
266
|
+
|
|
267
|
+
const r = data.results;
|
|
268
|
+
r.books.length.should.equal(1, 'expected 1 book');
|
|
269
|
+
r.music.length.should.equal(1, 'expected 1 music item');
|
|
270
|
+
r.posts.length.should.equal(1, 'expected 1 post');
|
|
271
|
+
r.albums.length.should.equal(1, 'expected 1 album');
|
|
272
|
+
r.products.length.should.equal(1, 'expected 1 product');
|
|
273
|
+
r.appointments.length.should.equal(1, 'expected 1 appointment');
|
|
274
|
+
r.subscriptions.length.should.equal(1, 'expected 1 subscription tier');
|
|
275
|
+
|
|
276
|
+
console.log(` 📚${r.books.length} 🎵${r.music.length} 📝${r.posts.length} 🖼️${r.albums.length} 📦${r.products.length} 📅${r.appointments.length} 🎁${r.subscriptions.length}`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('should return all goods categories from the goods endpoint', async () => {
|
|
280
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/goods`);
|
|
281
|
+
const data = await resp.json();
|
|
282
|
+
data.success.should.equal(true);
|
|
283
|
+
data.goods.books.length.should.be.at.least(1, 'missing books');
|
|
284
|
+
data.goods.music.length.should.be.at.least(1, 'missing music');
|
|
285
|
+
data.goods.posts.length.should.be.at.least(1, 'missing posts');
|
|
286
|
+
data.goods.albums.length.should.be.at.least(1, 'missing albums');
|
|
287
|
+
data.goods.products.length.should.be.at.least(1, 'missing products');
|
|
288
|
+
data.goods.appointments.length.should.be.at.least(1, 'missing appointments');
|
|
289
|
+
data.goods.subscriptions.length.should.be.at.least(1, 'missing subscriptions');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should serve the shoppe HTML page with all tabs', async () => {
|
|
293
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}`);
|
|
294
|
+
resp.status.should.equal(200);
|
|
295
|
+
const html = await resp.text();
|
|
296
|
+
html.should.include('Integration Test Shoppe');
|
|
297
|
+
html.should.include('tab-btn');
|
|
298
|
+
html.should.include('membership');
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('should return appointment slots for the uploaded session', async () => {
|
|
302
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/book/Test%20Session/slots`);
|
|
303
|
+
const data = await resp.json();
|
|
304
|
+
data.available.should.be.an('array');
|
|
305
|
+
data.available.length.should.be.at.least(1, 'expected at least one day with slots in the next 14 days');
|
|
306
|
+
data.available[0].slots[0].should.match(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/);
|
|
307
|
+
data.timezone.should.be.a('string');
|
|
308
|
+
console.log(` ${data.available.length} days available, first: ${data.available[0].date} (${data.available[0].slots.length} slots)`);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
it('should serve the appointment booking page', async () => {
|
|
312
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/book/Test%20Session`);
|
|
313
|
+
resp.status.should.equal(200);
|
|
314
|
+
const html = await resp.text();
|
|
315
|
+
html.should.include('Test Session');
|
|
316
|
+
html.should.include('/slots');
|
|
317
|
+
html.should.include('purchase/intent');
|
|
318
|
+
html.should.include('Confirm Booking'); // free booking label (price=0 in test archive)
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it('should serve the subscription sign-up page with benefits', async () => {
|
|
322
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/subscribe/Bronze%20Tier`);
|
|
323
|
+
resp.status.should.equal(200);
|
|
324
|
+
const html = await resp.text();
|
|
325
|
+
html.should.include('Bronze Tier');
|
|
326
|
+
html.should.include('Monthly exclusive track');
|
|
327
|
+
html.should.include('purchase/intent');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('should serve the membership portal page', async () => {
|
|
331
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/membership`);
|
|
332
|
+
resp.status.should.equal(200);
|
|
333
|
+
const html = await resp.text();
|
|
334
|
+
html.should.include('membership/check');
|
|
335
|
+
html.should.include('recovery-key');
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should report no active subscriptions for an unknown recovery key', async () => {
|
|
339
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/membership/check`, {
|
|
340
|
+
method: 'POST',
|
|
341
|
+
headers: { 'Content-Type': 'application/json' },
|
|
342
|
+
body: JSON.stringify({ recoveryKey: 'no-such-key-xyz-99' })
|
|
343
|
+
});
|
|
344
|
+
const data = await resp.json();
|
|
345
|
+
data.subscriptions.should.be.an('array');
|
|
346
|
+
const bronze = data.subscriptions.find(s => s.title === 'Bronze Tier');
|
|
347
|
+
bronze.should.exist;
|
|
348
|
+
bronze.active.should.equal(false);
|
|
349
|
+
bronze.benefits.should.deep.equal(['Monthly exclusive track', 'Early access']);
|
|
350
|
+
bronze.exclusiveArtifacts.should.deep.equal([]);
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ── Stripe purchase flow (requires STRIPE_TEST_KEY + Sanora + Addie) ─────────
|
|
355
|
+
const STRIPE_TEST_KEY = process.env.STRIPE_TEST_KEY;
|
|
356
|
+
const describeStripe = (STRIPE_TEST_KEY && sanoraAvailable) ? describe : describe.skip;
|
|
357
|
+
|
|
358
|
+
describeStripe('Stripe purchase flow (requires STRIPE_TEST_KEY + Sanora)', function() {
|
|
359
|
+
it('should create a Stripe payment intent for a subscription', async () => {
|
|
360
|
+
const goods = await (await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/goods`)).json();
|
|
361
|
+
const sub = goods.goods.subscriptions[0];
|
|
362
|
+
const resp = await fetch(`${BASE_URL}/plugin/shoppe/${tenant.uuid}/purchase/intent`, {
|
|
363
|
+
method: 'POST',
|
|
364
|
+
headers: { 'Content-Type': 'application/json' },
|
|
365
|
+
body: JSON.stringify({
|
|
366
|
+
recoveryKey: 'test-recovery-key-stripe',
|
|
367
|
+
productId: sub.productId || sub.title,
|
|
368
|
+
title: sub.title
|
|
369
|
+
})
|
|
370
|
+
});
|
|
371
|
+
const data = await resp.json();
|
|
372
|
+
if (data.error) console.error(' error:', data.error);
|
|
373
|
+
data.clientSecret.should.be.a('string');
|
|
374
|
+
data.publishableKey.should.be.a('string');
|
|
375
|
+
});
|
|
376
|
+
});
|