@nfcard/validation 0.20.0 → 0.22.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/dist/index.d.ts +586 -176
- package/dist/index.js +26 -5
- package/package.json +21 -15
- package/src/booking.test.ts +85 -0
- package/src/elements.test.ts +76 -0
- package/src/index.ts +0 -0
- package/src/printability.ts +5 -1
- package/src/tipJar.test.ts +77 -77
package/dist/index.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
// src/index.ts
|
|
2
2
|
import { z } from "zod";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isTipProviderId,
|
|
5
|
+
normalizeTipHandle,
|
|
6
|
+
isBookingProviderId,
|
|
7
|
+
normalizeBookingHandle
|
|
8
|
+
} from "@nfcard/types";
|
|
4
9
|
|
|
5
10
|
// src/printability.ts
|
|
6
11
|
var CARD_W_MM = 85.6;
|
|
@@ -413,7 +418,7 @@ function elementPrintability(el, ctx) {
|
|
|
413
418
|
if (el.sizeMm * el.transform.scale < QR_MIN_SIZE_MM - EPS) {
|
|
414
419
|
push("qrTooSmall", "The QR code is below the scannable size floor.");
|
|
415
420
|
}
|
|
416
|
-
if (!el.url || !el.url.trim()) {
|
|
421
|
+
if (el.mode !== "redirect" && (!el.url || !el.url.trim())) {
|
|
417
422
|
push("qrEmptyUrl", "The QR code has no target URL.");
|
|
418
423
|
}
|
|
419
424
|
break;
|
|
@@ -608,10 +613,11 @@ var baseElementFields = {
|
|
|
608
613
|
locked: z.boolean().optional(),
|
|
609
614
|
colour: z.string().optional()
|
|
610
615
|
};
|
|
616
|
+
var qrModeSchema = z.enum(["redirect", "raw"]).optional();
|
|
611
617
|
var cardElementSchema = z.discriminatedUnion("type", [
|
|
612
618
|
z.object({ ...baseElementFields, type: z.literal("chip"), shape: chipSidesSchema }),
|
|
613
619
|
z.object({ ...baseElementFields, type: z.literal("text"), text: z.string(), sizeMm: z.number().positive(), weight: z.number(), role: elementRoleSchema, fontKey: z.string().optional(), italic: z.boolean().optional(), underline: z.boolean().optional() }),
|
|
614
|
-
z.object({ ...baseElementFields, type: z.literal("qr"), url: z.string().url("invalidUrl"), sizeMm: z.number().positive(), frameRole: elementRoleSchema, moduleRole: elementRoleSchema }),
|
|
620
|
+
z.object({ ...baseElementFields, type: z.literal("qr"), url: z.string().url("invalidUrl").or(z.literal("")).optional(), mode: qrModeSchema, sizeMm: z.number().positive(), frameRole: elementRoleSchema, moduleRole: elementRoleSchema }),
|
|
615
621
|
z.object({ ...baseElementFields, type: z.literal("image"), assetRef: z.string().min(1), widthMm: z.number().positive(), heightMm: z.number().positive(), role: elementRoleSchema, bgRemoved: z.boolean().optional(), svgOverrides: z.array(z.string().optional()).optional() }),
|
|
616
622
|
z.object({ ...baseElementFields, type: z.literal("logo"), assetRef: z.string().min(1), widthMm: z.number().positive(), heightMm: z.number().positive(), role: elementRoleSchema })
|
|
617
623
|
]);
|
|
@@ -645,7 +651,10 @@ var cardDesignSchema = z.object({
|
|
|
645
651
|
})
|
|
646
652
|
)
|
|
647
653
|
}).optional(),
|
|
648
|
-
|
|
654
|
+
// NFCARD-561 -- `mode` makes "point to profile" DATA, not a string match; when it is
|
|
655
|
+
// 'redirect' the url is absent and the API substitutes the card's own /r/{code}?src=qr.
|
|
656
|
+
// Optional throughout: prod holds persisted designs with neither field.
|
|
657
|
+
qr: z.object({ enabled: z.boolean(), url: z.string().url("invalidUrl").or(z.literal("")).optional(), mode: qrModeSchema }).optional(),
|
|
649
658
|
logo: z.object({ svg: z.string().optional(), svgPath: z.string().optional() }).optional(),
|
|
650
659
|
wordmark: z.boolean().optional(),
|
|
651
660
|
contentFace: z.enum(["top", "bottom"]).optional(),
|
|
@@ -745,6 +754,15 @@ var tipJarSchema = z.unknown().transform((raw) => {
|
|
|
745
754
|
const label = typeof rec.label === "string" ? sanitizeText(rec.label, 40) : "";
|
|
746
755
|
return label ? { provider: rec.provider, handle, label } : { provider: rec.provider, handle };
|
|
747
756
|
});
|
|
757
|
+
var bookingSchema = z.unknown().transform((raw) => {
|
|
758
|
+
if (!raw || typeof raw !== "object") return void 0;
|
|
759
|
+
const rec = raw;
|
|
760
|
+
if (!isBookingProviderId(rec.provider)) return void 0;
|
|
761
|
+
const handle = normalizeBookingHandle(rec.provider, rec.handle);
|
|
762
|
+
if (!handle) return void 0;
|
|
763
|
+
const label = typeof rec.label === "string" ? sanitizeText(rec.label, 40) : "";
|
|
764
|
+
return label ? { provider: rec.provider, handle, label } : { provider: rec.provider, handle };
|
|
765
|
+
});
|
|
748
766
|
var digitalConfigSchema = z.object({
|
|
749
767
|
templateId: z.enum(["minimal", "bold", "card", "aurora", "classic", "spotlight", "marquee", "riso"]),
|
|
750
768
|
accentColor: z.string().regex(/^#[0-9A-Fa-f]{6}$/),
|
|
@@ -764,7 +782,8 @@ var digitalConfigSchema = z.object({
|
|
|
764
782
|
// public renderer additionally gates them on the owner's tier (free →
|
|
765
783
|
// none). Optional, so existing profiles/configs validate unchanged.
|
|
766
784
|
ctaButtons: ctaButtonsSchema.optional(),
|
|
767
|
-
tipJar: tipJarSchema.optional()
|
|
785
|
+
tipJar: tipJarSchema.optional(),
|
|
786
|
+
booking: bookingSchema.optional()
|
|
768
787
|
});
|
|
769
788
|
var entrySourceSchema = z.object({
|
|
770
789
|
output: z.enum(["with-card", "digital-only"]).optional(),
|
|
@@ -976,6 +995,7 @@ export {
|
|
|
976
995
|
TEXT_LINE_FACTOR_EM,
|
|
977
996
|
TEXT_MIN_EM_MM,
|
|
978
997
|
adminCardCreateSchema,
|
|
998
|
+
bookingSchema,
|
|
979
999
|
cardAssetSchema,
|
|
980
1000
|
cardDesignMaterialSchema,
|
|
981
1001
|
cardDesignPatternSchema,
|
|
@@ -1016,6 +1036,7 @@ export {
|
|
|
1016
1036
|
profileUpdateSchema,
|
|
1017
1037
|
profileUserDataSchema,
|
|
1018
1038
|
profileVisibilitySchema,
|
|
1039
|
+
qrModeSchema,
|
|
1019
1040
|
sanitizeHttpUrl,
|
|
1020
1041
|
sanitizeText,
|
|
1021
1042
|
signupWithProfileSchema,
|
package/package.json
CHANGED
|
@@ -1,39 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nfcard/validation",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Shared Zod validation schemas for the NFCard product family.",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
7
|
-
"types": "./
|
|
6
|
+
"main": "./src/index.ts",
|
|
7
|
+
"types": "./src/index.ts",
|
|
8
8
|
"exports": {
|
|
9
|
-
".":
|
|
10
|
-
"import": "./dist/index.js",
|
|
11
|
-
"types": "./dist/index.d.ts"
|
|
12
|
-
}
|
|
9
|
+
".": "./src/index.ts"
|
|
13
10
|
},
|
|
14
11
|
"files": [
|
|
15
12
|
"dist",
|
|
16
13
|
"src"
|
|
17
14
|
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "vitest run",
|
|
17
|
+
"test:watch": "vitest",
|
|
18
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
19
|
+
"prepublishOnly": "pnpm build"
|
|
20
|
+
},
|
|
18
21
|
"dependencies": {
|
|
19
22
|
"zod": "^3.24.0",
|
|
20
|
-
"@nfcard/types": "
|
|
23
|
+
"@nfcard/types": "workspace:*"
|
|
21
24
|
},
|
|
22
25
|
"devDependencies": {
|
|
23
26
|
"tsup": "^8.0.0"
|
|
24
27
|
},
|
|
25
28
|
"publishConfig": {
|
|
26
29
|
"access": "public",
|
|
27
|
-
"registry": "https://registry.npmjs.org/"
|
|
30
|
+
"registry": "https://registry.npmjs.org/",
|
|
31
|
+
"main": "./dist/index.js",
|
|
32
|
+
"types": "./dist/index.d.ts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"import": "./dist/index.js",
|
|
36
|
+
"types": "./dist/index.d.ts"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
28
39
|
},
|
|
29
40
|
"repository": {
|
|
30
41
|
"type": "git",
|
|
31
42
|
"url": "https://bitbucket.org/fox-hole/nfcard-shared.git",
|
|
32
43
|
"directory": "validation"
|
|
33
|
-
},
|
|
34
|
-
"scripts": {
|
|
35
|
-
"test": "vitest run",
|
|
36
|
-
"test:watch": "vitest",
|
|
37
|
-
"build": "tsup src/index.ts --format esm --dts --clean"
|
|
38
44
|
}
|
|
39
|
-
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { bookingSchema, digitalConfigSchema } from './index'
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
templateId: 'classic' as const,
|
|
6
|
+
accentColor: '#123456',
|
|
7
|
+
fontKey: 'inter',
|
|
8
|
+
showAvatar: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('bookingSchema', () => {
|
|
12
|
+
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 'kiss-fodraszat' })).toEqual({
|
|
14
|
+
provider: 'salonic',
|
|
15
|
+
handle: 'kiss-fodraszat',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes a pasted booking-page URL down to the subdomain handle', () => {
|
|
20
|
+
expect(
|
|
21
|
+
bookingSchema.parse({
|
|
22
|
+
provider: 'salonic',
|
|
23
|
+
handle: 'https://kiss-fodraszat.salonic.hu/booking?x=1',
|
|
24
|
+
}),
|
|
25
|
+
).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('lowercases the handle', () => {
|
|
29
|
+
expect(bookingSchema.parse({ provider: 'reservio', handle: 'HighBrow' })).toEqual({
|
|
30
|
+
provider: 'reservio',
|
|
31
|
+
handle: 'highbrow',
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
36
|
+
const out = bookingSchema.parse({
|
|
37
|
+
provider: 'salonic',
|
|
38
|
+
handle: 'kiss-fodraszat',
|
|
39
|
+
label: '<b>Foglalj</b> ' + 'x'.repeat(60),
|
|
40
|
+
})
|
|
41
|
+
expect(out?.provider).toBe('salonic')
|
|
42
|
+
expect(out?.label).not.toContain('<')
|
|
43
|
+
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
47
|
+
expect(bookingSchema.parse(undefined)).toBeUndefined()
|
|
48
|
+
expect(bookingSchema.parse(null)).toBeUndefined()
|
|
49
|
+
expect(bookingSchema.parse('nonsense')).toBeUndefined()
|
|
50
|
+
expect(bookingSchema.parse({})).toBeUndefined()
|
|
51
|
+
expect(bookingSchema.parse({ provider: 'booksy', handle: 'szalon' })).toBeUndefined()
|
|
52
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 'www' })).toBeUndefined() // reserved
|
|
53
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: '-bad' })).toBeUndefined() // bad DNS label
|
|
54
|
+
expect(
|
|
55
|
+
bookingSchema.parse({ provider: 'salonic', handle: 'https://evil.com/szalon' }),
|
|
56
|
+
).toBeUndefined()
|
|
57
|
+
expect(
|
|
58
|
+
bookingSchema.parse({ provider: 'salonic', handle: 'javascript:alert(1)' }),
|
|
59
|
+
).toBeUndefined()
|
|
60
|
+
expect(bookingSchema.parse({ provider: 'salonic', handle: 123 })).toBeUndefined()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('digitalConfigSchema — booking field', () => {
|
|
65
|
+
it('round-trips a valid booking', () => {
|
|
66
|
+
const parsed = digitalConfigSchema.parse({
|
|
67
|
+
...baseConfig,
|
|
68
|
+
booking: { provider: 'salonic', handle: 'kiss-fodraszat' },
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.booking).toEqual({ provider: 'salonic', handle: 'kiss-fodraszat' })
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('drops an invalid booking without failing the whole config save', () => {
|
|
74
|
+
const parsed = digitalConfigSchema.parse({
|
|
75
|
+
...baseConfig,
|
|
76
|
+
booking: { provider: 'salonic', handle: 'https://evil.com/x' },
|
|
77
|
+
})
|
|
78
|
+
expect(parsed.booking).toBeUndefined()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('is optional — a config without a booking parses fine', () => {
|
|
82
|
+
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
83
|
+
expect(parsed.booking).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
})
|
package/src/elements.test.ts
CHANGED
|
@@ -270,3 +270,79 @@ describe('per-element override fields survive validation (#1 P0 contract)', ()
|
|
|
270
270
|
expect(a).toMatchObject({ rasterBgDataUrl: 'data:,bg', rasterAspect: 1.5 })
|
|
271
271
|
})
|
|
272
272
|
})
|
|
273
|
+
|
|
274
|
+
// ── NFCARD-561: qr.mode makes "point to profile" data, not a string match ──
|
|
275
|
+
//
|
|
276
|
+
// The API used to detect it with a QR_PROFILE_TARGETS sentinel set
|
|
277
|
+
// ('' / https://nfcard.hu / http://nfcard.hu). Any near-miss — a www. host, a
|
|
278
|
+
// trailing path, a stale buyer-typed value — printed verbatim, and a printed
|
|
279
|
+
// card cannot be corrected afterwards.
|
|
280
|
+
|
|
281
|
+
describe('qr.mode (NFCARD-561)', () => {
|
|
282
|
+
it("accepts mode 'redirect' with NO url — the target is resolved at card creation", () => {
|
|
283
|
+
const res = cardDesignSchema.safeParse(
|
|
284
|
+
withElements([chipEl(), { ...qrEl(), url: undefined, mode: 'redirect' }]),
|
|
285
|
+
)
|
|
286
|
+
expect(res.success).toBe(true)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
it("accepts mode 'redirect' with an empty-string url (the pre-561 free-layout spelling)", () => {
|
|
290
|
+
const res = cardDesignSchema.safeParse(
|
|
291
|
+
withElements([chipEl(), { ...qrEl(), url: '', mode: 'redirect' }]),
|
|
292
|
+
)
|
|
293
|
+
expect(res.success).toBe(true)
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it("accepts mode 'raw' alongside a buyer-typed url", () => {
|
|
297
|
+
const res = cardDesignSchema.safeParse(
|
|
298
|
+
withElements([chipEl(), { ...qrEl(), url: 'https://acme.example/team', mode: 'raw' }]),
|
|
299
|
+
)
|
|
300
|
+
expect(res.success).toBe(true)
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
it('rejects an unknown mode', () => {
|
|
304
|
+
const res = cardDesignSchema.safeParse(
|
|
305
|
+
withElements([chipEl(), { ...qrEl(), mode: 'profile' }]),
|
|
306
|
+
)
|
|
307
|
+
expect(res.success).toBe(false)
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
it('still rejects a malformed url whatever the mode', () => {
|
|
311
|
+
const res = cardDesignSchema.safeParse(
|
|
312
|
+
withElements([chipEl(), { ...qrEl(), url: 'not-a-url', mode: 'raw' }]),
|
|
313
|
+
)
|
|
314
|
+
expect(codes(res)).toContain('invalidUrl')
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('a mode-less QR element still validates (pre-561 designs are untouched)', () => {
|
|
318
|
+
expect(cardDesignSchema.safeParse(withElements([chipEl(), qrEl()])).success).toBe(true)
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
it('carries mode through the legacy qr channel too', () => {
|
|
322
|
+
const parsed = cardDesignSchema.parse({
|
|
323
|
+
...legacyDesign,
|
|
324
|
+
qr: { enabled: true, mode: 'redirect' },
|
|
325
|
+
}) as { qr: Record<string, unknown> }
|
|
326
|
+
expect(parsed.qr).toMatchObject({ enabled: true, mode: 'redirect' })
|
|
327
|
+
})
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
describe('printability — a redirect QR needs no url (NFCARD-561)', () => {
|
|
331
|
+
const at = (over = {}) =>
|
|
332
|
+
({ ...qrEl(), transform: { xMm: 0, yMm: 0, rotationDeg: 0, scale: 1, z: 2 }, ...over }) as never
|
|
333
|
+
|
|
334
|
+
it("does not raise qrEmptyUrl for mode 'redirect' with no url", () => {
|
|
335
|
+
const codes = elementPrintability(at({ url: undefined, mode: 'redirect' })).map((v) => v.code)
|
|
336
|
+
expect(codes).not.toContain('qrEmptyUrl')
|
|
337
|
+
})
|
|
338
|
+
|
|
339
|
+
it("still raises qrEmptyUrl for mode 'raw' with no url", () => {
|
|
340
|
+
const codes = elementPrintability(at({ url: '', mode: 'raw' })).map((v) => v.code)
|
|
341
|
+
expect(codes).toContain('qrEmptyUrl')
|
|
342
|
+
})
|
|
343
|
+
|
|
344
|
+
it('still raises qrEmptyUrl for a mode-less QR with no url (legacy behaviour)', () => {
|
|
345
|
+
const codes = elementPrintability(at({ url: '' })).map((v) => v.code)
|
|
346
|
+
expect(codes).toContain('qrEmptyUrl')
|
|
347
|
+
})
|
|
348
|
+
})
|
package/src/index.ts
CHANGED
|
Binary file
|
package/src/printability.ts
CHANGED
|
@@ -294,7 +294,11 @@ export function elementPrintability(el: CardElement, ctx: PrintabilityCtx): Viol
|
|
|
294
294
|
if (el.sizeMm * el.transform.scale < QR_MIN_SIZE_MM - EPS) {
|
|
295
295
|
push('qrTooSmall', 'The QR code is below the scannable size floor.')
|
|
296
296
|
}
|
|
297
|
-
|
|
297
|
+
// NFCARD-561 — an absent url is CORRECT for a "point to profile" QR: the
|
|
298
|
+
// card's own /r/{shortCode}?src=qr is substituted at card creation, since
|
|
299
|
+
// the short code does not exist at design time. Only a `raw` (or legacy,
|
|
300
|
+
// mode-less) QR actually needs a buyer-supplied target.
|
|
301
|
+
if (el.mode !== 'redirect' && (!el.url || !el.url.trim())) {
|
|
298
302
|
push('qrEmptyUrl', 'The QR code has no target URL.')
|
|
299
303
|
}
|
|
300
304
|
break
|
package/src/tipJar.test.ts
CHANGED
|
@@ -1,77 +1,77 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import { tipJarSchema, digitalConfigSchema } from './index'
|
|
3
|
-
|
|
4
|
-
const baseConfig = {
|
|
5
|
-
templateId: 'classic' as const,
|
|
6
|
-
accentColor: '#123456',
|
|
7
|
-
fontKey: 'inter',
|
|
8
|
-
showAvatar: true,
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
describe('tipJarSchema', () => {
|
|
12
|
-
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
|
|
14
|
-
provider: 'kofi',
|
|
15
|
-
handle: 'ricsi',
|
|
16
|
-
})
|
|
17
|
-
})
|
|
18
|
-
|
|
19
|
-
it('normalizes a pasted full URL down to the handle', () => {
|
|
20
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
|
|
21
|
-
{ provider: 'kofi', handle: 'ricsi' },
|
|
22
|
-
)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('lowercases revolut handles', () => {
|
|
26
|
-
expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
|
|
27
|
-
provider: 'revolut',
|
|
28
|
-
handle: 'ricsi',
|
|
29
|
-
})
|
|
30
|
-
})
|
|
31
|
-
|
|
32
|
-
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
33
|
-
const out = tipJarSchema.parse({
|
|
34
|
-
provider: 'kofi',
|
|
35
|
-
handle: 'ricsi',
|
|
36
|
-
label: '<b>Támogass</b> ' + 'x'.repeat(60),
|
|
37
|
-
})
|
|
38
|
-
expect(out?.provider).toBe('kofi')
|
|
39
|
-
expect(out?.label).not.toContain('<')
|
|
40
|
-
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
44
|
-
expect(tipJarSchema.parse(undefined)).toBeUndefined()
|
|
45
|
-
expect(tipJarSchema.parse(null)).toBeUndefined()
|
|
46
|
-
expect(tipJarSchema.parse('nonsense')).toBeUndefined()
|
|
47
|
-
expect(tipJarSchema.parse({})).toBeUndefined()
|
|
48
|
-
expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
|
|
49
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
|
|
50
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
|
|
51
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
|
|
52
|
-
expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('digitalConfigSchema — tipJar field', () => {
|
|
57
|
-
it('round-trips a valid tipJar', () => {
|
|
58
|
-
const parsed = digitalConfigSchema.parse({
|
|
59
|
-
...baseConfig,
|
|
60
|
-
tipJar: { provider: 'revolut', handle: 'ricsi' },
|
|
61
|
-
})
|
|
62
|
-
expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('drops an invalid tipJar without failing the whole config save', () => {
|
|
66
|
-
const parsed = digitalConfigSchema.parse({
|
|
67
|
-
...baseConfig,
|
|
68
|
-
tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
|
|
69
|
-
})
|
|
70
|
-
expect(parsed.tipJar).toBeUndefined()
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
it('is optional — a config without a tipJar parses fine', () => {
|
|
74
|
-
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
75
|
-
expect(parsed.tipJar).toBeUndefined()
|
|
76
|
-
})
|
|
77
|
-
})
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { tipJarSchema, digitalConfigSchema } from './index'
|
|
3
|
+
|
|
4
|
+
const baseConfig = {
|
|
5
|
+
templateId: 'classic' as const,
|
|
6
|
+
accentColor: '#123456',
|
|
7
|
+
fontKey: 'inter',
|
|
8
|
+
showAvatar: true,
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
describe('tipJarSchema', () => {
|
|
12
|
+
it('accepts a valid provider + handle and stores only { provider, handle }', () => {
|
|
13
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'ricsi' })).toEqual({
|
|
14
|
+
provider: 'kofi',
|
|
15
|
+
handle: 'ricsi',
|
|
16
|
+
})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('normalizes a pasted full URL down to the handle', () => {
|
|
20
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://ko-fi.com/ricsi?x=1' })).toEqual(
|
|
21
|
+
{ provider: 'kofi', handle: 'ricsi' },
|
|
22
|
+
)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('lowercases revolut handles', () => {
|
|
26
|
+
expect(tipJarSchema.parse({ provider: 'revolut', handle: 'RicSi' })).toEqual({
|
|
27
|
+
provider: 'revolut',
|
|
28
|
+
handle: 'ricsi',
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('keeps a sanitized custom label, capped at 40 chars', () => {
|
|
33
|
+
const out = tipJarSchema.parse({
|
|
34
|
+
provider: 'kofi',
|
|
35
|
+
handle: 'ricsi',
|
|
36
|
+
label: '<b>Támogass</b> ' + 'x'.repeat(60),
|
|
37
|
+
})
|
|
38
|
+
expect(out?.provider).toBe('kofi')
|
|
39
|
+
expect(out?.label).not.toContain('<')
|
|
40
|
+
expect((out?.label ?? '').length).toBeLessThanOrEqual(40)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('drops everything invalid to undefined instead of throwing (never 400)', () => {
|
|
44
|
+
expect(tipJarSchema.parse(undefined)).toBeUndefined()
|
|
45
|
+
expect(tipJarSchema.parse(null)).toBeUndefined()
|
|
46
|
+
expect(tipJarSchema.parse('nonsense')).toBeUndefined()
|
|
47
|
+
expect(tipJarSchema.parse({})).toBeUndefined()
|
|
48
|
+
expect(tipJarSchema.parse({ provider: 'stripe', handle: 'ricsi' })).toBeUndefined()
|
|
49
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'x' })).toBeUndefined() // too short
|
|
50
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'https://evil.com/ricsi' })).toBeUndefined()
|
|
51
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 'javascript:alert(1)' })).toBeUndefined()
|
|
52
|
+
expect(tipJarSchema.parse({ provider: 'kofi', handle: 123 })).toBeUndefined()
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('digitalConfigSchema — tipJar field', () => {
|
|
57
|
+
it('round-trips a valid tipJar', () => {
|
|
58
|
+
const parsed = digitalConfigSchema.parse({
|
|
59
|
+
...baseConfig,
|
|
60
|
+
tipJar: { provider: 'revolut', handle: 'ricsi' },
|
|
61
|
+
})
|
|
62
|
+
expect(parsed.tipJar).toEqual({ provider: 'revolut', handle: 'ricsi' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('drops an invalid tipJar without failing the whole config save', () => {
|
|
66
|
+
const parsed = digitalConfigSchema.parse({
|
|
67
|
+
...baseConfig,
|
|
68
|
+
tipJar: { provider: 'kofi', handle: 'https://evil.com/x' },
|
|
69
|
+
})
|
|
70
|
+
expect(parsed.tipJar).toBeUndefined()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('is optional — a config without a tipJar parses fine', () => {
|
|
74
|
+
const parsed = digitalConfigSchema.parse(baseConfig)
|
|
75
|
+
expect(parsed.tipJar).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|