@clipr/worker 0.0.5 → 0.0.10

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.
@@ -0,0 +1,469 @@
1
+ import type { ShortUrl } from '@clipr/core';
2
+ import { beforeEach, describe, expect, it } from 'vitest';
3
+ import app from '../index.js';
4
+ import { createMockKV } from '../test-utils.js';
5
+
6
+ const AUTH = { Authorization: 'Bearer test-token' };
7
+ const ENV = (kv: KVNamespace) => ({
8
+ URLS: kv,
9
+ API_TOKEN: 'test-token',
10
+ BASE_URL: 'https://test.sh',
11
+ });
12
+
13
+ async function seedKV(kv: KVNamespace, entry: ShortUrl): Promise<void> {
14
+ await kv.put(`url:${entry.slug}`, JSON.stringify(entry));
15
+ const raw = await kv.get('_url_index', 'text');
16
+ const index: string[] = raw ? JSON.parse(raw) : [];
17
+ index.push(entry.slug);
18
+ await kv.put('_url_index', JSON.stringify(index));
19
+ }
20
+
21
+ function makeEntry(slug: string, url = 'https://example.com'): ShortUrl {
22
+ return { slug, url, createdAt: new Date().toISOString() };
23
+ }
24
+
25
+ // --- POST /api/shorten ---
26
+ describe('POST /api/shorten', () => {
27
+ let kv: KVNamespace;
28
+
29
+ beforeEach(() => {
30
+ kv = createMockKV();
31
+ });
32
+
33
+ it('creates a short URL with auto-generated slug', async () => {
34
+ const res = await app.request(
35
+ '/api/shorten',
36
+ {
37
+ method: 'POST',
38
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
39
+ body: JSON.stringify({ url: 'https://example.com' }),
40
+ },
41
+ ENV(kv),
42
+ );
43
+ expect(res.status).toBe(201);
44
+ const body = await res.json();
45
+ expect(body.slug).toBeDefined();
46
+ expect(body.shortUrl).toContain('https://test.sh/');
47
+ expect(body.url).toBe('https://example.com');
48
+ });
49
+
50
+ it('creates a short URL with a custom slug', async () => {
51
+ const res = await app.request(
52
+ '/api/shorten',
53
+ {
54
+ method: 'POST',
55
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({ url: 'https://example.com', slug: 'my-link' }),
57
+ },
58
+ ENV(kv),
59
+ );
60
+ expect(res.status).toBe(201);
61
+ const body = await res.json();
62
+ expect(body.slug).toBe('my-link');
63
+ });
64
+
65
+ it('rejects invalid URL', async () => {
66
+ const res = await app.request(
67
+ '/api/shorten',
68
+ {
69
+ method: 'POST',
70
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ url: 'not-a-url' }),
72
+ },
73
+ ENV(kv),
74
+ );
75
+ expect(res.status).toBe(400);
76
+ const body = await res.json();
77
+ expect(body.error).toMatch(/Invalid URL/);
78
+ });
79
+
80
+ it('rejects missing url field', async () => {
81
+ const res = await app.request(
82
+ '/api/shorten',
83
+ {
84
+ method: 'POST',
85
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
86
+ body: JSON.stringify({}),
87
+ },
88
+ ENV(kv),
89
+ );
90
+ expect(res.status).toBe(400);
91
+ const body = await res.json();
92
+ expect(body.error).toMatch(/Missing required field/);
93
+ });
94
+
95
+ it('rejects invalid JSON body', async () => {
96
+ const res = await app.request(
97
+ '/api/shorten',
98
+ {
99
+ method: 'POST',
100
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
101
+ body: 'not json',
102
+ },
103
+ ENV(kv),
104
+ );
105
+ expect(res.status).toBe(400);
106
+ const body = await res.json();
107
+ expect(body.error).toMatch(/Invalid JSON/);
108
+ });
109
+
110
+ it('allows request without auth (public endpoint)', async () => {
111
+ const res = await app.request(
112
+ '/api/shorten',
113
+ {
114
+ method: 'POST',
115
+ headers: { 'Content-Type': 'application/json' },
116
+ body: JSON.stringify({ url: 'https://example.com/public-test' }),
117
+ },
118
+ ENV(kv),
119
+ );
120
+ expect(res.status).toBe(201);
121
+ });
122
+
123
+ it('rejects duplicate custom slug', async () => {
124
+ await seedKV(kv, makeEntry('taken'));
125
+ const res = await app.request(
126
+ '/api/shorten',
127
+ {
128
+ method: 'POST',
129
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ url: 'https://example.com', slug: 'taken' }),
131
+ },
132
+ ENV(kv),
133
+ );
134
+ expect(res.status).toBe(409);
135
+ });
136
+
137
+ it('rejects invalid custom slug format', async () => {
138
+ const res = await app.request(
139
+ '/api/shorten',
140
+ {
141
+ method: 'POST',
142
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
143
+ body: JSON.stringify({ url: 'https://example.com', slug: 'A!' }),
144
+ },
145
+ ENV(kv),
146
+ );
147
+ expect(res.status).toBe(400);
148
+ const body = await res.json();
149
+ expect(body.error).toMatch(/Invalid slug/);
150
+ });
151
+
152
+ it('stores optional fields (description, tags, utm)', async () => {
153
+ const res = await app.request(
154
+ '/api/shorten',
155
+ {
156
+ method: 'POST',
157
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({
159
+ url: 'https://example.com',
160
+ slug: 'full',
161
+ description: 'A test link',
162
+ tags: ['marketing'],
163
+ utm: { utm_source: 'twitter' },
164
+ }),
165
+ },
166
+ ENV(kv),
167
+ );
168
+ expect(res.status).toBe(201);
169
+ // Verify the entry was stored with metadata
170
+ const raw = await kv.get('url:full', 'text');
171
+ const stored = JSON.parse(raw!) as ShortUrl;
172
+ expect(stored.description).toBe('A test link');
173
+ expect(stored.tags).toEqual(['marketing']);
174
+ expect(stored.utm?.utm_source).toBe('twitter');
175
+ });
176
+ });
177
+
178
+ // --- GET /api/links ---
179
+ describe('GET /api/links', () => {
180
+ let kv: KVNamespace;
181
+
182
+ beforeEach(async () => {
183
+ kv = createMockKV();
184
+ await seedKV(kv, makeEntry('aaa', 'https://a.com'));
185
+ await seedKV(kv, makeEntry('bbb', 'https://b.com'));
186
+ });
187
+
188
+ it('lists all links', async () => {
189
+ const res = await app.request('/api/links', { headers: AUTH }, ENV(kv));
190
+ expect(res.status).toBe(200);
191
+ const body = await res.json();
192
+ expect(body).toHaveLength(2);
193
+ });
194
+
195
+ it('returns empty array when no links exist', async () => {
196
+ const emptyKv = createMockKV();
197
+ const res = await app.request('/api/links', { headers: AUTH }, ENV(emptyKv));
198
+ expect(res.status).toBe(200);
199
+ const body = await res.json();
200
+ expect(body).toEqual([]);
201
+ });
202
+
203
+ it('filters by search query', async () => {
204
+ const res = await app.request('/api/links?search=aaa', { headers: AUTH }, ENV(kv));
205
+ expect(res.status).toBe(200);
206
+ const body = await res.json();
207
+ expect(body).toHaveLength(1);
208
+ expect(body[0].slug).toBe('aaa');
209
+ });
210
+
211
+ it('applies limit parameter', async () => {
212
+ const res = await app.request('/api/links?limit=1', { headers: AUTH }, ENV(kv));
213
+ expect(res.status).toBe(200);
214
+ const body = await res.json();
215
+ expect(body).toHaveLength(1);
216
+ });
217
+
218
+ it('filters by tag', async () => {
219
+ await seedKV(kv, { ...makeEntry('tagged'), tags: ['promo'] });
220
+ const res = await app.request('/api/links?tag=promo', { headers: AUTH }, ENV(kv));
221
+ expect(res.status).toBe(200);
222
+ const body = await res.json();
223
+ expect(body).toHaveLength(1);
224
+ expect(body[0].slug).toBe('tagged');
225
+ });
226
+ });
227
+
228
+ // --- GET /api/links/:code ---
229
+ describe('GET /api/links/:code', () => {
230
+ let kv: KVNamespace;
231
+
232
+ beforeEach(async () => {
233
+ kv = createMockKV();
234
+ await seedKV(kv, makeEntry('mylink', 'https://example.com'));
235
+ });
236
+
237
+ it('returns a single link', async () => {
238
+ const res = await app.request('/api/links/mylink', { headers: AUTH }, ENV(kv));
239
+ expect(res.status).toBe(200);
240
+ const body = await res.json();
241
+ expect(body.slug).toBe('mylink');
242
+ expect(body.url).toBe('https://example.com');
243
+ });
244
+
245
+ it('returns 404 for missing link', async () => {
246
+ const res = await app.request('/api/links/nonexistent', { headers: AUTH }, ENV(kv));
247
+ expect(res.status).toBe(404);
248
+ const body = await res.json();
249
+ expect(body.error).toMatch(/not found/);
250
+ });
251
+ });
252
+
253
+ // --- PUT /api/links/:code ---
254
+ describe('PUT /api/links/:code', () => {
255
+ let kv: KVNamespace;
256
+
257
+ beforeEach(async () => {
258
+ kv = createMockKV();
259
+ await seedKV(kv, makeEntry('editable', 'https://old.com'));
260
+ });
261
+
262
+ it('updates an existing link', async () => {
263
+ const res = await app.request(
264
+ '/api/links/editable',
265
+ {
266
+ method: 'PUT',
267
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
268
+ body: JSON.stringify({ url: 'https://new.com', description: 'Updated' }),
269
+ },
270
+ ENV(kv),
271
+ );
272
+ expect(res.status).toBe(200);
273
+ const body = await res.json();
274
+ expect(body.url).toBe('https://new.com');
275
+ expect(body.description).toBe('Updated');
276
+ // slug and createdAt remain unchanged
277
+ expect(body.slug).toBe('editable');
278
+ });
279
+
280
+ it('returns 404 for missing link', async () => {
281
+ const res = await app.request(
282
+ '/api/links/ghost',
283
+ {
284
+ method: 'PUT',
285
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
286
+ body: JSON.stringify({ url: 'https://new.com' }),
287
+ },
288
+ ENV(kv),
289
+ );
290
+ expect(res.status).toBe(404);
291
+ });
292
+
293
+ it('returns 400 for invalid JSON body', async () => {
294
+ const res = await app.request(
295
+ '/api/links/editable',
296
+ {
297
+ method: 'PUT',
298
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
299
+ body: 'not json',
300
+ },
301
+ ENV(kv),
302
+ );
303
+ expect(res.status).toBe(400);
304
+ const body = await res.json();
305
+ expect(body.error).toMatch(/Invalid JSON/);
306
+ });
307
+ });
308
+
309
+ // --- DELETE /api/links/:code ---
310
+ describe('DELETE /api/links/:code', () => {
311
+ let kv: KVNamespace;
312
+
313
+ beforeEach(async () => {
314
+ kv = createMockKV();
315
+ await seedKV(kv, makeEntry('delme', 'https://example.com'));
316
+ });
317
+
318
+ it('deletes an existing link', async () => {
319
+ const res = await app.request('/api/links/delme', { method: 'DELETE', headers: AUTH }, ENV(kv));
320
+ expect(res.status).toBe(200);
321
+ const body = await res.json();
322
+ expect(body.ok).toBe(true);
323
+ expect(body.deleted).toBe('delme');
324
+
325
+ // Verify deleted
326
+ const check = await app.request('/api/links/delme', { headers: AUTH }, ENV(kv));
327
+ expect(check.status).toBe(404);
328
+ });
329
+
330
+ it('returns 404 for missing link', async () => {
331
+ const res = await app.request('/api/links/ghost', { method: 'DELETE', headers: AUTH }, ENV(kv));
332
+ expect(res.status).toBe(404);
333
+ });
334
+ });
335
+
336
+ // --- GET /api/stats/:code ---
337
+ describe('GET /api/stats/:code', () => {
338
+ let kv: KVNamespace;
339
+
340
+ beforeEach(async () => {
341
+ kv = createMockKV();
342
+ await seedKV(kv, makeEntry('tracked', 'https://example.com'));
343
+ });
344
+
345
+ it('returns empty stats for a link with no clicks', async () => {
346
+ const res = await app.request('/api/stats/tracked', { headers: AUTH }, ENV(kv));
347
+ expect(res.status).toBe(200);
348
+ const body = await res.json();
349
+ expect(body.code).toBe('tracked');
350
+ expect(body.total).toBe(0);
351
+ expect(body.daily).toEqual({});
352
+ expect(body.geo).toEqual({});
353
+ expect(body.device).toEqual({});
354
+ expect(body.referrer).toEqual({});
355
+ });
356
+
357
+ it('returns 404 for non-existent link', async () => {
358
+ const res = await app.request('/api/stats/missing', { headers: AUTH }, ENV(kv));
359
+ expect(res.status).toBe(404);
360
+ });
361
+
362
+ it('returns accumulated stats', async () => {
363
+ // Seed some stats directly in KV
364
+ await kv.put('stats:tracked:total', '5');
365
+ await kv.put('stats:tracked:daily:2025-01-01', '3');
366
+ await kv.put('stats:tracked:daily:2025-01-02', '2');
367
+ await kv.put('geo:tracked:US', '4');
368
+ await kv.put('device:tracked:mobile', '2');
369
+ await kv.put('referrer:tracked:google.com', '3');
370
+
371
+ const res = await app.request('/api/stats/tracked', { headers: AUTH }, ENV(kv));
372
+ expect(res.status).toBe(200);
373
+ const body = await res.json();
374
+ expect(body.total).toBe(5);
375
+ expect(body.daily['2025-01-01']).toBe(3);
376
+ expect(body.daily['2025-01-02']).toBe(2);
377
+ expect(body.geo.US).toBe(4);
378
+ expect(body.device.mobile).toBe(2);
379
+ expect(body.referrer['google.com']).toBe(3);
380
+ });
381
+ });
382
+
383
+ // --- GET /api/export ---
384
+ describe('GET /api/export', () => {
385
+ it('exports all links as JSON array', async () => {
386
+ const kv = createMockKV();
387
+ await seedKV(kv, makeEntry('exp1', 'https://a.com'));
388
+ await seedKV(kv, makeEntry('exp2', 'https://b.com'));
389
+
390
+ const res = await app.request('/api/export', { headers: AUTH }, ENV(kv));
391
+ expect(res.status).toBe(200);
392
+ expect(res.headers.get('Content-Disposition')).toContain('clipr-export.json');
393
+ const body = await res.json();
394
+ expect(body).toHaveLength(2);
395
+ expect(body.map((e: ShortUrl) => e.slug).sort()).toEqual(['exp1', 'exp2']);
396
+ });
397
+
398
+ it('exports empty array when no links', async () => {
399
+ const kv = createMockKV();
400
+ const res = await app.request('/api/export', { headers: AUTH }, ENV(kv));
401
+ expect(res.status).toBe(200);
402
+ const body = await res.json();
403
+ expect(body).toEqual([]);
404
+ });
405
+ });
406
+
407
+ // --- POST /api/import ---
408
+ describe('POST /api/import', () => {
409
+ let kv: KVNamespace;
410
+
411
+ beforeEach(() => {
412
+ kv = createMockKV();
413
+ });
414
+
415
+ it('imports links from a JSON array', async () => {
416
+ const entries = [
417
+ { slug: 'imp1', url: 'https://a.com', createdAt: '2025-01-01T00:00:00Z' },
418
+ { slug: 'imp2', url: 'https://b.com', createdAt: '2025-01-02T00:00:00Z' },
419
+ ];
420
+ const res = await app.request(
421
+ '/api/import',
422
+ {
423
+ method: 'POST',
424
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
425
+ body: JSON.stringify(entries),
426
+ },
427
+ ENV(kv),
428
+ );
429
+ expect(res.status).toBe(200);
430
+ const body = await res.json();
431
+ expect(body.imported).toBe(2);
432
+ expect(body.total).toBe(2);
433
+ expect(body.errors).toEqual([]);
434
+ });
435
+
436
+ it('skips entries missing slug or url', async () => {
437
+ const entries = [
438
+ { slug: 'good', url: 'https://a.com', createdAt: '2025-01-01T00:00:00Z' },
439
+ { slug: '', url: 'https://b.com', createdAt: '2025-01-01T00:00:00Z' },
440
+ { slug: 'no-url', url: '', createdAt: '2025-01-01T00:00:00Z' },
441
+ ];
442
+ const res = await app.request(
443
+ '/api/import',
444
+ {
445
+ method: 'POST',
446
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
447
+ body: JSON.stringify(entries),
448
+ },
449
+ ENV(kv),
450
+ );
451
+ expect(res.status).toBe(200);
452
+ const body = await res.json();
453
+ expect(body.imported).toBe(1);
454
+ expect(body.errors).toHaveLength(2);
455
+ });
456
+
457
+ it('rejects invalid JSON', async () => {
458
+ const res = await app.request(
459
+ '/api/import',
460
+ {
461
+ method: 'POST',
462
+ headers: { ...AUTH, 'Content-Type': 'application/json' },
463
+ body: 'not json',
464
+ },
465
+ ENV(kv),
466
+ );
467
+ expect(res.status).toBe(400);
468
+ });
469
+ });
package/src/test-utils.ts CHANGED
@@ -14,8 +14,12 @@ export function createMockKV(): KVNamespace {
14
14
  store.delete(key);
15
15
  return Promise.resolve();
16
16
  },
17
- list(): Promise<KVNamespaceListResult<unknown, string>> {
18
- const keys = [...store.keys()].map((name) => ({ name }));
17
+ list(opts?: { prefix?: string }): Promise<KVNamespaceListResult<unknown, string>> {
18
+ let storeKeys = [...store.keys()];
19
+ if (opts?.prefix) {
20
+ storeKeys = storeKeys.filter((k) => k.startsWith(opts.prefix!));
21
+ }
22
+ const keys = storeKeys.map((name) => ({ name }));
19
23
  return Promise.resolve({
20
24
  keys,
21
25
  list_complete: true,
package/.dev.vars DELETED
@@ -1,2 +0,0 @@
1
- API_TOKEN=01fa30b3faff1f2141c5598ab333b6b8f52dedcaaf316646ed1b5f8cb85b0ead
2
- BASE_URL=https://clipr.fyniti.co.uk
package/dist/README.md DELETED
@@ -1 +0,0 @@
1
- This folder contains the built output assets for the worker "clipr" generated at 2026-03-31T08:59:31.922Z.