@bash-app/bash-common 30.383.0 → 30.386.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.
Files changed (107) hide show
  1. package/dist/__tests__/accessScreening.test.js +39 -1
  2. package/dist/__tests__/accessScreening.test.js.map +1 -1
  3. package/dist/__tests__/applicationFunnel.test.d.ts +2 -0
  4. package/dist/__tests__/applicationFunnel.test.d.ts.map +1 -0
  5. package/dist/__tests__/applicationFunnel.test.js +126 -0
  6. package/dist/__tests__/applicationFunnel.test.js.map +1 -0
  7. package/dist/__tests__/definitionsHelpers.test.js +20 -1
  8. package/dist/__tests__/definitionsHelpers.test.js.map +1 -1
  9. package/dist/__tests__/fundingMeter.test.d.ts +2 -0
  10. package/dist/__tests__/fundingMeter.test.d.ts.map +1 -0
  11. package/dist/__tests__/fundingMeter.test.js +129 -0
  12. package/dist/__tests__/fundingMeter.test.js.map +1 -0
  13. package/dist/__tests__/giveaway.test.d.ts +2 -0
  14. package/dist/__tests__/giveaway.test.d.ts.map +1 -0
  15. package/dist/__tests__/giveaway.test.js +65 -0
  16. package/dist/__tests__/giveaway.test.js.map +1 -0
  17. package/dist/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
  18. package/dist/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
  19. package/dist/__tests__/promoterCompetitionUtils.test.js +262 -0
  20. package/dist/__tests__/promoterCompetitionUtils.test.js.map +1 -0
  21. package/dist/accessScreening.d.ts +18 -3
  22. package/dist/accessScreening.d.ts.map +1 -1
  23. package/dist/accessScreening.js +26 -0
  24. package/dist/accessScreening.js.map +1 -1
  25. package/dist/applicationFunnel.d.ts +58 -0
  26. package/dist/applicationFunnel.d.ts.map +1 -0
  27. package/dist/applicationFunnel.js +147 -0
  28. package/dist/applicationFunnel.js.map +1 -0
  29. package/dist/definitions.d.ts +146 -5
  30. package/dist/definitions.d.ts.map +1 -1
  31. package/dist/definitions.js +138 -5
  32. package/dist/definitions.js.map +1 -1
  33. package/dist/extendedSchemas.d.ts +174 -0
  34. package/dist/extendedSchemas.d.ts.map +1 -1
  35. package/dist/extendedSchemas.js +15 -0
  36. package/dist/extendedSchemas.js.map +1 -1
  37. package/dist/fundingMeter.d.ts +66 -0
  38. package/dist/fundingMeter.d.ts.map +1 -0
  39. package/dist/fundingMeter.js +99 -0
  40. package/dist/fundingMeter.js.map +1 -0
  41. package/dist/giveaway.d.ts +71 -0
  42. package/dist/giveaway.d.ts.map +1 -0
  43. package/dist/giveaway.js +117 -0
  44. package/dist/giveaway.js.map +1 -0
  45. package/dist/index.d.ts +5 -0
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +6 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/utils/__tests__/bashHostIdentity.test.d.ts +2 -0
  50. package/dist/utils/__tests__/bashHostIdentity.test.d.ts.map +1 -0
  51. package/dist/utils/__tests__/bashHostIdentity.test.js +39 -0
  52. package/dist/utils/__tests__/bashHostIdentity.test.js.map +1 -0
  53. package/dist/utils/__tests__/paymentUtils.test.js +28 -1
  54. package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
  55. package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts +2 -0
  56. package/dist/utils/__tests__/promoterCompetitionUtils.test.d.ts.map +1 -0
  57. package/dist/utils/__tests__/promoterCompetitionUtils.test.js +104 -0
  58. package/dist/utils/__tests__/promoterCompetitionUtils.test.js.map +1 -0
  59. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js +56 -1
  60. package/dist/utils/__tests__/serviceAvailabilityDisplay.test.js.map +1 -1
  61. package/dist/utils/__tests__/slugUtils.test.js +31 -2
  62. package/dist/utils/__tests__/slugUtils.test.js.map +1 -1
  63. package/dist/utils/bashHostIdentity.d.ts +18 -0
  64. package/dist/utils/bashHostIdentity.d.ts.map +1 -0
  65. package/dist/utils/bashHostIdentity.js +17 -0
  66. package/dist/utils/bashHostIdentity.js.map +1 -0
  67. package/dist/utils/paymentUtils.d.ts +18 -1
  68. package/dist/utils/paymentUtils.d.ts.map +1 -1
  69. package/dist/utils/paymentUtils.js +29 -2
  70. package/dist/utils/paymentUtils.js.map +1 -1
  71. package/dist/utils/promoterCompetitionUtils.d.ts +89 -0
  72. package/dist/utils/promoterCompetitionUtils.d.ts.map +1 -0
  73. package/dist/utils/promoterCompetitionUtils.js +158 -0
  74. package/dist/utils/promoterCompetitionUtils.js.map +1 -0
  75. package/dist/utils/serviceAvailabilityDisplay.d.ts +28 -2
  76. package/dist/utils/serviceAvailabilityDisplay.d.ts.map +1 -1
  77. package/dist/utils/serviceAvailabilityDisplay.js +39 -0
  78. package/dist/utils/serviceAvailabilityDisplay.js.map +1 -1
  79. package/dist/utils/slugUtils.d.ts +2 -0
  80. package/dist/utils/slugUtils.d.ts.map +1 -1
  81. package/dist/utils/slugUtils.js +5 -1
  82. package/dist/utils/slugUtils.js.map +1 -1
  83. package/package.json +1 -1
  84. package/prisma/schema.prisma +465 -29
  85. package/src/__tests__/accessScreening.test.ts +69 -1
  86. package/src/__tests__/applicationFunnel.test.ts +197 -0
  87. package/src/__tests__/definitionsHelpers.test.ts +24 -0
  88. package/src/__tests__/fundingMeter.test.ts +143 -0
  89. package/src/__tests__/giveaway.test.ts +111 -0
  90. package/src/__tests__/promoterCompetitionUtils.test.ts +334 -0
  91. package/src/accessScreening.ts +49 -0
  92. package/src/applicationFunnel.ts +225 -0
  93. package/src/definitions.ts +182 -5
  94. package/src/extendedSchemas.ts +131 -0
  95. package/src/fundingMeter.ts +188 -0
  96. package/src/giveaway.ts +182 -0
  97. package/src/index.ts +14 -0
  98. package/src/utils/__tests__/bashHostIdentity.test.ts +57 -0
  99. package/src/utils/__tests__/paymentUtils.test.ts +36 -0
  100. package/src/utils/__tests__/promoterCompetitionUtils.test.ts +128 -0
  101. package/src/utils/__tests__/serviceAvailabilityDisplay.test.ts +73 -0
  102. package/src/utils/__tests__/slugUtils.test.ts +46 -0
  103. package/src/utils/bashHostIdentity.ts +30 -0
  104. package/src/utils/paymentUtils.ts +47 -2
  105. package/src/utils/promoterCompetitionUtils.ts +235 -0
  106. package/src/utils/serviceAvailabilityDisplay.ts +82 -2
  107. package/src/utils/slugUtils.ts +11 -0
@@ -1,4 +1,59 @@
1
- import { accessScreeningBlocksAttendeeActions } from "../accessScreening.js";
1
+ import {
2
+ accessScreeningApplies,
3
+ accessScreeningBlocksAttendeeActions,
4
+ accessScreeningBlocksAttendeeActionsForEvent,
5
+ eventNeedsAccessRequestQueue,
6
+ } from "../accessScreening.js";
7
+
8
+ describe("accessScreeningApplies", () => {
9
+ it("is on only when screening is on and Application is not", () => {
10
+ expect(
11
+ accessScreeningApplies({ accessScreeningEnabled: true })
12
+ ).toBe(true);
13
+ expect(
14
+ accessScreeningApplies({
15
+ accessScreeningEnabled: true,
16
+ applicationEnabled: true,
17
+ })
18
+ ).toBe(false);
19
+ expect(
20
+ accessScreeningApplies({
21
+ accessScreeningEnabled: true,
22
+ ticketTiers: [{ requiresApplication: true }],
23
+ })
24
+ ).toBe(false);
25
+ expect(
26
+ accessScreeningApplies({ accessScreeningEnabled: false })
27
+ ).toBe(false);
28
+ });
29
+ });
30
+
31
+ describe("eventNeedsAccessRequestQueue", () => {
32
+ it("shows the ticketed queue for screening or Application, not RSVP Application", () => {
33
+ expect(
34
+ eventNeedsAccessRequestQueue({ accessScreeningEnabled: true })
35
+ ).toBe(true);
36
+ expect(
37
+ eventNeedsAccessRequestQueue({
38
+ entryMode: "Ticketed",
39
+ applicationEnabled: true,
40
+ })
41
+ ).toBe(true);
42
+ expect(
43
+ eventNeedsAccessRequestQueue({
44
+ entryMode: "Rsvp",
45
+ applicationEnabled: true,
46
+ accessScreeningEnabled: true,
47
+ })
48
+ ).toBe(false);
49
+ expect(
50
+ eventNeedsAccessRequestQueue({
51
+ entryMode: "Rsvp",
52
+ accessScreeningEnabled: true,
53
+ })
54
+ ).toBe(true);
55
+ });
56
+ });
2
57
 
3
58
  describe("accessScreeningBlocksAttendeeActions", () => {
4
59
  it("does not block when screening is off", () => {
@@ -26,4 +81,17 @@ describe("accessScreeningBlocksAttendeeActions", () => {
26
81
  it("does not block ticket holders", () => {
27
82
  expect(accessScreeningBlocksAttendeeActions(true, "None", true)).toBe(false);
28
83
  });
84
+
85
+ it("does not block guests when Application supersedes leftover screening", () => {
86
+ expect(
87
+ accessScreeningBlocksAttendeeActionsForEvent(
88
+ {
89
+ accessScreeningEnabled: true,
90
+ applicationEnabled: true,
91
+ },
92
+ "None",
93
+ false
94
+ )
95
+ ).toBe(false);
96
+ });
29
97
  });
@@ -0,0 +1,197 @@
1
+ import {
2
+ applicationBlocksTierPurchase,
3
+ applicationsAreClosed,
4
+ evaluateApplicationFunnel,
5
+ eventHasApplication,
6
+ missingRequiredQuestionIds,
7
+ questionsForApplicationSurface,
8
+ rsvpApplicationEnabled,
9
+ selectedTiersRequireApplication,
10
+ visibleApplicationQuestions,
11
+ } from "../applicationFunnel.js";
12
+
13
+ const yesNo = {
14
+ id: "age",
15
+ type: "YesNo" as const,
16
+ required: true,
17
+ mismatchAction: "AutoDeny" as const,
18
+ acceptableOptions: ["Yes"],
19
+ };
20
+
21
+ describe("applicationFunnel", () => {
22
+ it("auto-denies a disallowed Yes/No answer", () => {
23
+ const result = evaluateApplicationFunnel(
24
+ [yesNo],
25
+ [{ questionId: "age", answer: "No" }]
26
+ );
27
+ expect(result.outcome).toBe("auto_deny");
28
+ expect(result.autoDenyQuestionIds).toEqual(["age"]);
29
+ });
30
+
31
+ it("flags a mismatch without auto-deny", () => {
32
+ const result = evaluateApplicationFunnel(
33
+ [
34
+ {
35
+ id: "single",
36
+ type: "SingleChoice",
37
+ mismatchAction: "FlagForReview",
38
+ acceptableOptions: ["Friend"],
39
+ },
40
+ ],
41
+ [{ questionId: "single", answer: "Other" }]
42
+ );
43
+ expect(result.outcome).toBe("flag");
44
+ expect(result.flaggedQuestionIds).toEqual(["single"]);
45
+ });
46
+
47
+ it("passes when the choice is acceptable", () => {
48
+ const result = evaluateApplicationFunnel(
49
+ [yesNo],
50
+ [{ questionId: "age", answer: "Yes" }]
51
+ );
52
+ expect(result.outcome).toBe("pass");
53
+ });
54
+
55
+ it("ignores free text for auto-deny", () => {
56
+ const result = evaluateApplicationFunnel(
57
+ [
58
+ {
59
+ id: "why",
60
+ type: "LongText",
61
+ mismatchAction: "AutoDeny",
62
+ acceptableOptions: ["never"],
63
+ },
64
+ ],
65
+ [{ questionId: "why", answer: "because" }]
66
+ );
67
+ expect(result.outcome).toBe("pass");
68
+ });
69
+
70
+ it("treats multi-choice as a mismatch when any selected option is not acceptable", () => {
71
+ const result = evaluateApplicationFunnel(
72
+ [
73
+ {
74
+ id: "dates",
75
+ type: "MultiChoice",
76
+ mismatchAction: "AutoDeny",
77
+ acceptableOptions: ["Women", "Men"],
78
+ },
79
+ ],
80
+ [{ questionId: "dates", answer: "Women, Aliens" }]
81
+ );
82
+ expect(result.outcome).toBe("auto_deny");
83
+ });
84
+
85
+ it("does not funnel when mismatchAction is None", () => {
86
+ const result = evaluateApplicationFunnel(
87
+ [
88
+ {
89
+ id: "song",
90
+ type: "SingleChoice",
91
+ mismatchAction: "None",
92
+ acceptableOptions: ["A"],
93
+ },
94
+ ],
95
+ [{ questionId: "song", answer: "B" }]
96
+ );
97
+ expect(result.outcome).toBe("pass");
98
+ });
99
+
100
+ it("lists missing required answers", () => {
101
+ expect(
102
+ missingRequiredQuestionIds(
103
+ [yesNo, { id: "opt", type: "ShortText", required: false }],
104
+ []
105
+ )
106
+ ).toEqual(["age"]);
107
+ });
108
+
109
+ it("filters questions to a ticket tier plus event-wide rows", () => {
110
+ const rows = [
111
+ { id: "all", ticketTierId: null },
112
+ { id: "ga", ticketTierId: "tier-ga" },
113
+ { id: "vip", ticketTierId: "tier-vip" },
114
+ ];
115
+ expect(questionsForApplicationSurface(rows, "tier-ga").map((q) => q.id)).toEqual(
116
+ ["all", "ga"]
117
+ );
118
+ expect(questionsForApplicationSurface(rows, null).map((q) => q.id)).toEqual([
119
+ "all",
120
+ ]);
121
+ });
122
+
123
+ it("detects event and RSVP application flags", () => {
124
+ expect(eventHasApplication({ applicationEnabled: false, ticketTiers: [] })).toBe(
125
+ false
126
+ );
127
+ expect(
128
+ eventHasApplication({
129
+ applicationEnabled: false,
130
+ ticketTiers: [{ requiresApplication: true }],
131
+ })
132
+ ).toBe(true);
133
+ expect(rsvpApplicationEnabled({ entryMode: "Rsvp", applicationEnabled: true })).toBe(
134
+ true
135
+ );
136
+ expect(
137
+ rsvpApplicationEnabled({ entryMode: "Ticketed", applicationEnabled: true })
138
+ ).toBe(false);
139
+ });
140
+
141
+ it("blocks purchase on application tiers until approved", () => {
142
+ expect(
143
+ applicationBlocksTierPurchase({ requiresApplication: true }, "Pending", false)
144
+ ).toBe(true);
145
+ expect(
146
+ applicationBlocksTierPurchase({ requiresApplication: true }, "Approved", false)
147
+ ).toBe(false);
148
+ expect(
149
+ applicationBlocksTierPurchase({ requiresApplication: true }, "Pending", true)
150
+ ).toBe(false);
151
+ expect(
152
+ applicationBlocksTierPurchase({ requiresApplication: false }, null, false)
153
+ ).toBe(false);
154
+ expect(
155
+ selectedTiersRequireApplication([
156
+ { requiresApplication: false },
157
+ { requiresApplication: true },
158
+ ])
159
+ ).toBe(true);
160
+ });
161
+
162
+ it("treats a past deadline as closed", () => {
163
+ expect(applicationsAreClosed("2020-01-01T00:00:00.000Z")).toBe(true);
164
+ expect(applicationsAreClosed(null)).toBe(false);
165
+ expect(applicationsAreClosed("not-a-date")).toBe(false);
166
+ });
167
+
168
+ it("hides conditional follow-ups until the parent matches", () => {
169
+ const parent = {
170
+ id: "plus",
171
+ type: "YesNo" as const,
172
+ required: true,
173
+ };
174
+ const child = {
175
+ id: "who",
176
+ type: "ShortText" as const,
177
+ required: true,
178
+ parentQuestionId: "plus",
179
+ showWhenAnswer: "Yes",
180
+ };
181
+ expect(
182
+ visibleApplicationQuestions([parent, child], [
183
+ { questionId: "plus", answer: "No" },
184
+ ]).map((q) => q.id)
185
+ ).toEqual(["plus"]);
186
+ expect(
187
+ missingRequiredQuestionIds([parent, child], [
188
+ { questionId: "plus", answer: "No" },
189
+ ])
190
+ ).toEqual([]);
191
+ expect(
192
+ visibleApplicationQuestions([parent, child], [
193
+ { questionId: "plus", answer: "Yes" },
194
+ ]).map((q) => q.id)
195
+ ).toEqual(["plus", "who"]);
196
+ });
197
+ });
@@ -3,6 +3,8 @@ import {
3
3
  createEmptyFilterFields,
4
4
  removeDataFromResult,
5
5
  canonicalBashEventRowKey,
6
+ homepageBashEventRowKey,
7
+ isUnsetBashEventType,
6
8
  PASSWORD_REQUIREMENTS_REGEX,
7
9
  PASSWORD_MIN_LENGTH,
8
10
  EMPTY_FILTER_FIELDS,
@@ -64,6 +66,28 @@ describe("definitions helpers", () => {
64
66
  });
65
67
  });
66
68
 
69
+ describe("homepageBashEventRowKey", () => {
70
+ test("maps blank and unset types to Other", () => {
71
+ expect(homepageBashEventRowKey("")).toBe("Other");
72
+ expect(homepageBashEventRowKey(" ")).toBe("Other");
73
+ expect(homepageBashEventRowKey(null)).toBe("Other");
74
+ expect(homepageBashEventRowKey(undefined)).toBe("Other");
75
+ });
76
+
77
+ test("keeps known types canonical", () => {
78
+ expect(homepageBashEventRowKey("House Party")).toBe("HouseParty");
79
+ expect(homepageBashEventRowKey("Other")).toBe("Other");
80
+ });
81
+ });
82
+
83
+ describe("isUnsetBashEventType", () => {
84
+ test("treats blank and Other as unset", () => {
85
+ expect(isUnsetBashEventType("")).toBe(true);
86
+ expect(isUnsetBashEventType("Other")).toBe(true);
87
+ expect(isUnsetBashEventType("Party")).toBe(false);
88
+ });
89
+ });
90
+
67
91
  describe("PASSWORD_REQUIREMENTS_REGEX", () => {
68
92
  test("requires min length and mixed character classes", () => {
69
93
  expect(PASSWORD_MIN_LENGTH).toBe(8);
@@ -0,0 +1,143 @@
1
+ import {
2
+ computeFundingMeterProgress,
3
+ contributionCountsAsCollected,
4
+ contributionCountsAsOpenPledge,
5
+ } from "../fundingMeter.js";
6
+
7
+ describe("fundingMeter progress math", () => {
8
+ const line = {
9
+ id: "ins",
10
+ name: "Insurance",
11
+ category: "Other",
12
+ totalAllocatedCents: 50000,
13
+ includeOnFundingMeter: true,
14
+ };
15
+
16
+ it("counts host-entered and Stripe Paid toward collected, not pledges", () => {
17
+ const progress = computeFundingMeterProgress({
18
+ showFundingMeter: true,
19
+ fundingAllowCommitments: true,
20
+ fundingShowPledgesOnMeter: false,
21
+ hostStripeConnected: true,
22
+ lines: [line],
23
+ contributions: [
24
+ {
25
+ budgetId: "ins",
26
+ amountCents: 10000,
27
+ kind: "Cash",
28
+ status: "Paid",
29
+ source: "Stripe",
30
+ displayName: "Alex",
31
+ note: null,
32
+ },
33
+ {
34
+ budgetId: "ins",
35
+ amountCents: 5000,
36
+ kind: "InKind",
37
+ status: "Fulfilled",
38
+ source: "HostEntered",
39
+ displayName: "Pearl Milk Tea",
40
+ note: "gift cards",
41
+ },
42
+ {
43
+ budgetId: "ins",
44
+ amountCents: 20000,
45
+ kind: "Cash",
46
+ status: "Pledged",
47
+ source: "PublicPledge",
48
+ displayName: "Sam",
49
+ note: null,
50
+ },
51
+ ],
52
+ });
53
+
54
+ expect(progress.collectedCents).toBe(15000);
55
+ expect(progress.pledgedCents).toBe(20000);
56
+ expect(progress.percentFunded).toBe(30);
57
+ expect(progress.lines[0].remainingCents).toBe(35000);
58
+ expect(progress.unallocatedCents).toBe(0);
59
+ });
60
+
61
+ it("keeps unallocated gifts out of percent funded and line remaining", () => {
62
+ const progress = computeFundingMeterProgress({
63
+ showFundingMeter: true,
64
+ fundingAllowCommitments: true,
65
+ fundingShowPledgesOnMeter: false,
66
+ hostStripeConnected: false,
67
+ lines: [line],
68
+ contributions: [
69
+ {
70
+ budgetId: null,
71
+ amountCents: 40000,
72
+ kind: "Cash",
73
+ status: "Paid",
74
+ source: "Stripe",
75
+ displayName: "Mystery",
76
+ note: null,
77
+ },
78
+ ],
79
+ });
80
+
81
+ expect(progress.collectedCents).toBe(0);
82
+ expect(progress.percentFunded).toBe(0);
83
+ expect(progress.unallocatedCents).toBe(40000);
84
+ expect(progress.lines[0].remainingCents).toBe(50000);
85
+ });
86
+
87
+ it("ignores declined and cancelled rows", () => {
88
+ expect(
89
+ contributionCountsAsCollected({
90
+ budgetId: "ins",
91
+ amountCents: 1000,
92
+ kind: "Cash",
93
+ status: "Cancelled",
94
+ source: "HostEntered",
95
+ displayName: "x",
96
+ note: null,
97
+ })
98
+ ).toBe(false);
99
+ expect(
100
+ contributionCountsAsOpenPledge({
101
+ budgetId: "ins",
102
+ amountCents: 1000,
103
+ kind: "Cash",
104
+ status: "Declined",
105
+ source: "PublicPledge",
106
+ displayName: "x",
107
+ note: null,
108
+ })
109
+ ).toBe(false);
110
+ });
111
+
112
+ it("excludes lines with includeOnFundingMeter false from the goal", () => {
113
+ const progress = computeFundingMeterProgress({
114
+ showFundingMeter: true,
115
+ fundingAllowCommitments: true,
116
+ fundingShowPledgesOnMeter: true,
117
+ hostStripeConnected: true,
118
+ lines: [
119
+ line,
120
+ {
121
+ id: "hidden",
122
+ name: "Internal",
123
+ category: null,
124
+ totalAllocatedCents: 99999,
125
+ includeOnFundingMeter: false,
126
+ },
127
+ ],
128
+ contributions: [
129
+ {
130
+ budgetId: "hidden",
131
+ amountCents: 99999,
132
+ kind: "Cash",
133
+ status: "Paid",
134
+ source: "HostEntered",
135
+ displayName: "Host",
136
+ note: null,
137
+ },
138
+ ],
139
+ });
140
+ expect(progress.goalCents).toBe(50000);
141
+ expect(progress.collectedCents).toBe(0);
142
+ });
143
+ });
@@ -0,0 +1,111 @@
1
+ import {
2
+ buildGiveawayAttendeeIdFromTicket,
3
+ giveawayActiveDistributedCount,
4
+ giveawayQualifyCopy,
5
+ giveawayRemainingCopy,
6
+ matchesCompetitionParticipant,
7
+ resolveGiveawayAttendeeFromScan,
8
+ ticketCountsAsCheckedIn,
9
+ ticketCountsAsGiveawayTicket,
10
+ } from "../giveaway.js";
11
+
12
+ describe("giveaway attendee identity", () => {
13
+ it("resolves ticket QR to ticket:{ticketId} for this event", () => {
14
+ const resolved = resolveGiveawayAttendeeFromScan(
15
+ "evt-1",
16
+ JSON.stringify({ eventId: "evt-1", ticketId: "t-9", userId: "u-2" })
17
+ );
18
+ expect(resolved).toEqual({
19
+ attendeeId: buildGiveawayAttendeeIdFromTicket("t-9"),
20
+ ticketId: "t-9",
21
+ userId: "u-2",
22
+ });
23
+ });
24
+
25
+ it("rejects a scan for a different event", () => {
26
+ expect(
27
+ resolveGiveawayAttendeeFromScan(
28
+ "evt-1",
29
+ JSON.stringify({ eventId: "evt-other", ticketId: "t-9" })
30
+ )
31
+ ).toBeNull();
32
+ });
33
+ });
34
+
35
+ describe("giveaway copy", () => {
36
+ it("uses Enter the contest wording for CompetitionParticipants", () => {
37
+ expect(
38
+ giveawayQualifyCopy({
39
+ eligibility: "CompetitionParticipants",
40
+ competitionName: "5K",
41
+ })
42
+ ).toBe("Enter 5K to qualify");
43
+ expect(
44
+ giveawayQualifyCopy({ eligibility: "CompetitionParticipants" })
45
+ ).toBe("Enter the contest to qualify");
46
+ });
47
+
48
+ it("covers the rest of the eligibility matrix", () => {
49
+ expect(giveawayQualifyCopy({ eligibility: "AnyoneGoing" })).toBe(
50
+ "Anyone going to this bash can qualify"
51
+ );
52
+ expect(giveawayQualifyCopy({ eligibility: "TicketHolders" })).toBe(
53
+ "Ticket holders qualify"
54
+ );
55
+ expect(giveawayQualifyCopy({ eligibility: "CheckedIn" })).toBe(
56
+ "Checked-in attendees qualify"
57
+ );
58
+ expect(
59
+ giveawayQualifyCopy({
60
+ eligibility: "SideQuestCompleters",
61
+ sideQuestTitle: "Find the booth",
62
+ })
63
+ ).toBe("Complete Find the booth to qualify");
64
+ expect(giveawayQualifyCopy({ eligibility: "SideQuestCompleters" })).toBe(
65
+ "Complete the side quest to qualify"
66
+ );
67
+ });
68
+
69
+ it("shows All distributed when remaining is 0", () => {
70
+ expect(giveawayRemainingCopy(0)).toBe("All distributed");
71
+ expect(giveawayRemainingCopy(43)).toBe("43 remaining");
72
+ });
73
+ });
74
+
75
+ describe("eligibility helpers", () => {
76
+ it("treats Pending tickets as ticket holders but not checked in", () => {
77
+ expect(ticketCountsAsGiveawayTicket("Pending")).toBe(true);
78
+ expect(ticketCountsAsCheckedIn({ status: "Pending", checkedInAt: null })).toBe(
79
+ false
80
+ );
81
+ expect(
82
+ ticketCountsAsCheckedIn({ status: "Pending", checkedInAt: new Date() })
83
+ ).toBe(true);
84
+ });
85
+
86
+ it("matches email-only competition participants", () => {
87
+ expect(
88
+ matchesCompetitionParticipant({
89
+ userId: "acct-1",
90
+ email: "Sam@Example.com",
91
+ participants: [
92
+ { status: "ACCEPTED", userId: null, email: "sam@example.com" },
93
+ ],
94
+ })
95
+ ).toBe(true);
96
+ expect(
97
+ matchesCompetitionParticipant({
98
+ userId: "acct-1",
99
+ email: "other@example.com",
100
+ participants: [
101
+ { status: "ACCEPTED", userId: null, email: "sam@example.com" },
102
+ ],
103
+ })
104
+ ).toBe(false);
105
+ });
106
+
107
+ it("treats quantityTotal - remaining as active inventory, not history", () => {
108
+ expect(giveawayActiveDistributedCount(100, 43)).toBe(57);
109
+ expect(giveawayActiveDistributedCount(100, 100)).toBe(0);
110
+ });
111
+ });