@tribe-nest/forge 3.4.0 → 3.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tribe-nest/forge",
3
- "version": "3.4.0",
3
+ "version": "3.9.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -0,0 +1,122 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ forgetWaitlistEntry,
4
+ getRememberedWaitlistEntries,
5
+ isEventWaitlistOpen,
6
+ isLiveWaitlistEntry,
7
+ isTicketSoldOut,
8
+ rememberWaitlistEntry,
9
+ ticketSeatsRemaining,
10
+ } from "../useEventWaitlist";
11
+ import type { IEvent, ITicket } from "../../../types/models";
12
+
13
+ /**
14
+ * Events 2.6 — the pure decisions behind the buyer-facing waitlist.
15
+ *
16
+ * Only the total functions are exercised here; everything else in the module is
17
+ * a thin transport whose behaviour belongs to the API's own specs.
18
+ */
19
+
20
+ const tier = (overrides: Partial<ITicket> = {}): ITicket =>
21
+ ({
22
+ id: "t1",
23
+ title: "Standing",
24
+ description: "",
25
+ price: 25,
26
+ quantity: 100,
27
+ order: 0,
28
+ sold: 0,
29
+ maxPerPerson: 4,
30
+ ...overrides,
31
+ }) as ITicket;
32
+
33
+ describe("ticketSeatsRemaining", () => {
34
+ it("counts what is left, floored at zero", () => {
35
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 40 }))).toBe(60);
36
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 100 }))).toBe(0);
37
+ // Oversold (a refund race, a manual capacity cut) is still zero, never negative.
38
+ expect(ticketSeatsRemaining(tier({ quantity: 100, sold: 120 }))).toBe(0);
39
+ });
40
+ });
41
+
42
+ describe("isTicketSoldOut — where a join control may be drawn", () => {
43
+ it("is false while anything is left to sell", () => {
44
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 9 }))).toBe(false);
45
+ });
46
+
47
+ it("is true once nothing is left", () => {
48
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10 }))).toBe(true);
49
+ });
50
+
51
+ it("never offers a queue for an archived tier", () => {
52
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, archivedAt: "2026-01-01T00:00:00Z" }))).toBe(false);
53
+ });
54
+
55
+ it("never offers a queue once the sale window has closed", () => {
56
+ const now = new Date("2026-08-01T12:00:00Z");
57
+ // Waiting for a release that can no longer be sold is waiting for an email
58
+ // that will never arrive.
59
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, expiresAt: "2026-07-31T00:00:00Z" }), now)).toBe(false);
60
+ expect(isTicketSoldOut(tier({ quantity: 10, sold: 10, expiresAt: "2026-08-02T00:00:00Z" }), now)).toBe(true);
61
+ });
62
+ });
63
+
64
+ describe("isEventWaitlistOpen", () => {
65
+ const withStatus = (status: IEvent["status"]) => ({ status });
66
+
67
+ it("accepts the statuses a sign-up can still mean something on", () => {
68
+ expect(isEventWaitlistOpen(withStatus("scheduled"))).toBe(true);
69
+ expect(isEventWaitlistOpen(withStatus("on_sale"))).toBe(true);
70
+ expect(isEventWaitlistOpen(withStatus("sold_out"))).toBe(true);
71
+ });
72
+
73
+ it("refuses a show that is over or called off", () => {
74
+ expect(isEventWaitlistOpen(withStatus("cancelled"))).toBe(false);
75
+ expect(isEventWaitlistOpen(withStatus("completed"))).toBe(false);
76
+ expect(isEventWaitlistOpen(withStatus("draft"))).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("isLiveWaitlistEntry", () => {
81
+ it("counts a held offer as being on the list", () => {
82
+ expect(isLiveWaitlistEntry({ status: "waiting" })).toBe(true);
83
+ // An open offer IS a place in the queue, just a better one.
84
+ expect(isLiveWaitlistEntry({ status: "notified" })).toBe(true);
85
+ });
86
+
87
+ it("excludes every terminal status", () => {
88
+ expect(isLiveWaitlistEntry({ status: "converted" })).toBe(false);
89
+ expect(isLiveWaitlistEntry({ status: "left" })).toBe(false);
90
+ expect(isLiveWaitlistEntry({ status: "expired" })).toBe(false);
91
+ });
92
+ });
93
+
94
+ describe("the anonymous joiner's remembered capability ids", () => {
95
+ beforeEach(() => {
96
+ for (const entry of getRememberedWaitlistEntries()) forgetWaitlistEntry(entry.entryId);
97
+ });
98
+
99
+ it("remembers an entry and narrows by event", () => {
100
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
101
+ rememberWaitlistEntry({ eventId: "e2", entryId: "b" });
102
+
103
+ expect(getRememberedWaitlistEntries().map((e) => e.entryId)).toEqual(["a", "b"]);
104
+ expect(getRememberedWaitlistEntries("e1").map((e) => e.entryId)).toEqual(["a"]);
105
+ });
106
+
107
+ it("does not duplicate a re-join of the same entry", () => {
108
+ // Joining is idempotent server-side, so the SAME id comes back a second
109
+ // time — it must not become two places in the UI.
110
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
111
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
112
+
113
+ expect(getRememberedWaitlistEntries("e1")).toHaveLength(1);
114
+ });
115
+
116
+ it("forgets an entry once it is left", () => {
117
+ rememberWaitlistEntry({ eventId: "e1", entryId: "a" });
118
+ forgetWaitlistEntry("a");
119
+
120
+ expect(getRememberedWaitlistEntries("e1")).toEqual([]);
121
+ });
122
+ });
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, beforeEach } from "vitest";
2
+ import {
3
+ forgetPassTransfer,
4
+ getRememberedPassTransfers,
5
+ isPendingPassTransfer,
6
+ rememberPassTransfer,
7
+ } from "../usePassTransfers";
8
+ import { myTicketPassIds } from "../useMyTickets";
9
+
10
+ /**
11
+ * Events 2.2 — the pure decisions behind the buyer-facing transfer surface.
12
+ *
13
+ * Only the total functions are exercised; the hooks themselves are thin
14
+ * transport whose behaviour belongs to the API's own specs. The local store is
15
+ * the one thing here with real logic, and it exists because there is NO
16
+ * buyer-readable list endpoint — the transfer history is operator-only — so a
17
+ * bug in it is a pending transfer the sender can never withdraw.
18
+ */
19
+
20
+ const clear = () => {
21
+ for (const entry of getRememberedPassTransfers()) forgetPassTransfer(entry.transferId);
22
+ };
23
+
24
+ beforeEach(clear);
25
+
26
+ describe("isPendingPassTransfer", () => {
27
+ it("is true only for the one status a cancel can act on", () => {
28
+ expect(isPendingPassTransfer({ status: "pending" })).toBe(true);
29
+ for (const status of ["claimed", "cancelled", "expired"]) {
30
+ expect(isPendingPassTransfer({ status })).toBe(false);
31
+ }
32
+ });
33
+ });
34
+
35
+ describe("the remembered-send store", () => {
36
+ it("keeps a send and reads it back, narrowed to its pass", () => {
37
+ rememberPassTransfer({ passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null });
38
+ rememberPassTransfer({ passId: "TN-2", transferId: "b", toEmail: "z@y.com", expiresAt: null });
39
+
40
+ expect(getRememberedPassTransfers()).toHaveLength(2);
41
+ expect(getRememberedPassTransfers("TN-1")).toEqual([
42
+ { passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null },
43
+ ]);
44
+ });
45
+
46
+ it("keeps at most ONE row per pass — the database allows no more", () => {
47
+ // A partial unique index enforces one live transfer per pass, so a second
48
+ // remembered row for the same pass could only ever be stale. The newest
49
+ // send wins; a stale id would offer a cancel that answers 400.
50
+ rememberPassTransfer({ passId: "TN-1", transferId: "old", toEmail: "x@y.com", expiresAt: null });
51
+ rememberPassTransfer({ passId: "TN-1", transferId: "new", toEmail: "q@y.com", expiresAt: null });
52
+
53
+ expect(getRememberedPassTransfers("TN-1")).toEqual([
54
+ { passId: "TN-1", transferId: "new", toEmail: "q@y.com", expiresAt: null },
55
+ ]);
56
+ });
57
+
58
+ it("forgets by transfer id and leaves the others alone", () => {
59
+ rememberPassTransfer({ passId: "TN-1", transferId: "a", toEmail: "x@y.com", expiresAt: null });
60
+ rememberPassTransfer({ passId: "TN-2", transferId: "b", toEmail: "z@y.com", expiresAt: null });
61
+
62
+ forgetPassTransfer("a");
63
+
64
+ expect(getRememberedPassTransfers().map((entry) => entry.transferId)).toEqual(["b"]);
65
+ // Forgetting something that was never there is a no-op, not a throw — a
66
+ // cancel whose row had already been dropped must still resolve.
67
+ expect(() => forgetPassTransfer("a")).not.toThrow();
68
+ });
69
+ });
70
+
71
+ describe("the ids a transfer can be addressed by", () => {
72
+ it("only ever sends a TN-… pass id, which is what the endpoint accepts", () => {
73
+ // The send endpoint is `/public/events/passes/:passId/transfers` and 404s on
74
+ // an order or order-item id. `myTicketPassIds` is the only source the portal
75
+ // has, so this is the contract the affordance is drawn from.
76
+ const ids = myTicketPassIds({
77
+ items: [
78
+ { id: "order-item-uuid", quantity: 2, price: 25, ticketTitle: "GA", passes: [{ id: "TN-11" }, { id: "TN-12" }] },
79
+ { id: "another-item", quantity: 1, price: 25, ticketTitle: "GA", passes: [{ id: "not-a-pass" }] },
80
+ ],
81
+ });
82
+
83
+ expect(ids).toEqual(["TN-11", "TN-12"]);
84
+ });
85
+
86
+ it("yields nothing when the API build emits no passes — draw no affordance", () => {
87
+ expect(myTicketPassIds({ items: [{ id: "i", quantity: 1, price: 10, ticketTitle: null }] })).toEqual([]);
88
+ });
89
+ });
@@ -0,0 +1,159 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { hasWalletPass, isEventPassId, type WalletPassStatus } from "../useWalletPass";
3
+ import { myTicketPassIds, type MyTicket } from "../useMyTickets";
4
+
5
+ /**
6
+ * Events 2.3 — the pure decisions behind the wallet affordance.
7
+ *
8
+ * `hasWalletPass` is the single predicate that decides whether a buyer ever
9
+ * learns the feature exists, so it gets exhaustive coverage of the ways it must
10
+ * answer NO. Everything else in the module is transport, and belongs to the
11
+ * API's own specs.
12
+ */
13
+
14
+ const status = (overrides: Partial<WalletPassStatus> = {}): WalletPassStatus => ({
15
+ passId: "TN-1001",
16
+ available: true,
17
+ reason: null,
18
+ apple: { available: true, downloadUrl: "/public/events/passes/TN-1001/wallet/apple?profileId=p1" },
19
+ google: { available: true, saveUrl: "https://pay.google.com/gp/v/save/jwt" },
20
+ ...overrides,
21
+ });
22
+
23
+ describe("hasWalletPass", () => {
24
+ it("is false for anything that is not a settled, positive answer", () => {
25
+ // No answer yet, or a query that 401'd / 404'd / failed. Absence of data is
26
+ // absence of the feature — never a placeholder.
27
+ expect(hasWalletPass(undefined)).toBe(false);
28
+ expect(hasWalletPass(null)).toBe(false);
29
+ });
30
+
31
+ it("is false for the state production is actually in today", () => {
32
+ // No signing credentials configured. This is a 200 — a successful "the
33
+ // feature is off" — and it must be indistinguishable from the feature not
34
+ // existing at all.
35
+ expect(
36
+ hasWalletPass(
37
+ status({
38
+ available: false,
39
+ reason: "not_configured",
40
+ apple: { available: false, downloadUrl: null },
41
+ google: { available: false, saveUrl: null },
42
+ }),
43
+ ),
44
+ ).toBe(false);
45
+ });
46
+
47
+ it("is false for every other refusal the API can give", () => {
48
+ for (const reason of ["rotating_qr", "order_not_active"] as const) {
49
+ expect(
50
+ hasWalletPass(
51
+ status({
52
+ available: false,
53
+ reason,
54
+ apple: { available: false, downloadUrl: null },
55
+ google: { available: false, saveUrl: null },
56
+ }),
57
+ ),
58
+ ).toBe(false);
59
+ }
60
+ });
61
+
62
+ it("is false when the API says available but neither provider produced anything", () => {
63
+ // Belt and braces: a provider can throw AFTER the credential gate passed
64
+ // (a certificate that parses but will not sign), and the API degrades the
65
+ // two providers independently. `available` alone is not enough to draw on.
66
+ expect(
67
+ hasWalletPass(
68
+ status({ apple: { available: false, downloadUrl: null }, google: { available: false, saveUrl: null } }),
69
+ ),
70
+ ).toBe(false);
71
+ });
72
+
73
+ it("is true as soon as EITHER provider has something real", () => {
74
+ expect(hasWalletPass(status({ google: { available: false, saveUrl: null } }))).toBe(true);
75
+ expect(hasWalletPass(status({ apple: { available: false, downloadUrl: null } }))).toBe(true);
76
+ expect(hasWalletPass(status())).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe("isEventPassId", () => {
81
+ it("accepts only `TN-` plus digits, matching the API's own guard", () => {
82
+ expect(isEventPassId("TN-1")).toBe(true);
83
+ expect(isEventPassId("TN-000000123")).toBe(true);
84
+ });
85
+
86
+ it("rejects the ids a caller is most likely to reach for by mistake", () => {
87
+ // An order id and an order-item id are what `useMyTickets` actually hands
88
+ // back; sending either would be a guaranteed 404.
89
+ expect(isEventPassId("0f9c8b7a-1111-2222-3333-444455556666")).toBe(false);
90
+ expect(isEventPassId("TN-")).toBe(false);
91
+ expect(isEventPassId("TN-12a")).toBe(false);
92
+ expect(isEventPassId("tn-12")).toBe(false);
93
+ expect(isEventPassId(" TN-12")).toBe(false);
94
+ expect(isEventPassId(undefined)).toBe(false);
95
+ expect(isEventPassId(null)).toBe(false);
96
+ expect(isEventPassId(12)).toBe(false);
97
+ });
98
+ });
99
+
100
+ const ticket = (overrides: Partial<MyTicket> = {}): MyTicket =>
101
+ ({
102
+ id: "order-1",
103
+ items: [],
104
+ ...overrides,
105
+ }) as MyTicket;
106
+
107
+ describe("myTicketPassIds", () => {
108
+ it("is empty when the payload carries no passes at all", () => {
109
+ // An older API build, before `/public/events/my-tickets` nested passes
110
+ // under each item. The affordance must simply not appear rather than error.
111
+ expect(
112
+ myTicketPassIds(
113
+ ticket({ items: [{ id: "item-1", quantity: 2, price: 10, ticketTitle: "General Admission" }] }),
114
+ ),
115
+ ).toEqual([]);
116
+ });
117
+
118
+ it("reads pass ids from the order level", () => {
119
+ expect(myTicketPassIds(ticket({ passes: [{ id: "TN-1" }, { id: "TN-2" }] }))).toEqual(["TN-1", "TN-2"]);
120
+ });
121
+
122
+ it("reads pass ids nested under items — the shape the API actually emits", () => {
123
+ expect(
124
+ myTicketPassIds(
125
+ ticket({
126
+ items: [
127
+ { id: "item-1", quantity: 1, price: 10, ticketTitle: "GA", passes: [{ id: "TN-3" }] },
128
+ { id: "item-2", quantity: 1, price: 20, ticketTitle: "VIP", passes: [{ id: "TN-4" }] },
129
+ ],
130
+ }),
131
+ ),
132
+ ).toEqual(["TN-3", "TN-4"]);
133
+ });
134
+
135
+ it("de-duplicates across both shapes, so a payload carrying both draws one set of buttons", () => {
136
+ expect(
137
+ myTicketPassIds(
138
+ ticket({
139
+ passes: [{ id: "TN-5" }],
140
+ items: [{ id: "item-1", quantity: 1, price: 10, ticketTitle: "GA", passes: [{ id: "TN-5" }] }],
141
+ }),
142
+ ),
143
+ ).toEqual(["TN-5"]);
144
+ });
145
+
146
+ it("drops anything that is not a pass id rather than sending a request that must 404", () => {
147
+ expect(
148
+ myTicketPassIds(
149
+ ticket({
150
+ passes: [{ id: "order-1" }, { id: "" }, { id: "TN-6" }] as never,
151
+ }),
152
+ ),
153
+ ).toEqual(["TN-6"]);
154
+ });
155
+
156
+ it("survives a payload with no items array at all", () => {
157
+ expect(myTicketPassIds({ items: undefined as never, passes: undefined })).toEqual([]);
158
+ });
159
+ });