@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.js CHANGED
@@ -1,6 +1,11 @@
1
1
  // src/index.ts
2
2
  import { z } from "zod";
3
- import { isTipProviderId, normalizeTipHandle } from "@nfcard/types";
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
- qr: z.object({ enabled: z.boolean(), url: z.string().url("invalidUrl") }).optional(),
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.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Shared Zod validation schemas for the NFCard product family.",
5
5
  "type": "module",
6
- "main": "./dist/index.js",
7
- "types": "./dist/index.d.ts",
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": "0.14.0"
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
+ })
@@ -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
@@ -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
- if (!el.url || !el.url.trim()) {
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
@@ -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
+ })