@tribe-nest/forge 3.29.0 → 3.34.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 +11 -3
- package/src/_tests/publishedResolvability.spec.ts +184 -0
- package/src/_tests/specsRunWorkspaceSource.spec.ts +116 -0
- package/src/_tests/workspaceAliases.ts +40 -0
- package/src/contexts/CartContext.tsx +17 -1
- package/src/contexts/PublicAuthContext.tsx +34 -5
- package/src/contexts/_tests/CartContext.spec.tsx +36 -0
- package/src/contexts/_tests/PublicAuthRefetch.spec.tsx +147 -0
- package/src/data/queries/useBroadcasts.ts +151 -0
- package/src/data/queries/useMyBookings.ts +18 -1
- package/src/i18n/_tests/translationKeys.spec.ts +15 -0
- package/src/i18n/de.json +97 -0
- package/src/i18n/en.json +97 -0
- package/src/ui/format/_tests/membershipPwyw.spec.ts +185 -0
- package/src/ui/format/_tests/pwyw.spec.ts +65 -8
- package/src/ui/format/membershipPwyw.ts +164 -0
- package/src/ui/format/pwyw.ts +37 -0
- package/src/ui/headless/broadcast/_tests/broadcastState.spec.ts +235 -0
- package/src/ui/headless/broadcast/broadcastState.ts +158 -0
- package/src/ui/headless/broadcast/useBroadcastWatch.ts +174 -21
- package/src/ui/headless/event/useEventCheckout.ts +8 -13
- package/src/ui/headless/index.ts +14 -0
- package/src/ui/headless/membership/useMembershipCheckout.ts +160 -32
- package/src/ui/index.ts +61 -0
- package/src/ui/media/BookingCallScreen.tsx +33 -0
- package/src/ui/media/CallHelpHint.tsx +87 -0
- package/src/ui/media/CallStage.tsx +633 -0
- package/src/ui/media/_tests/CallStage.spec.tsx +931 -0
- package/src/ui/media/_tests/bookingSession.spec.tsx +227 -0
- package/src/ui/media/_tests/callState.spec.ts +499 -0
- package/src/ui/media/_tests/fakeNode.ts +178 -0
- package/src/ui/media/bookingSession.tsx +182 -0
- package/src/ui/media/bookingWindow.ts +81 -0
- package/src/ui/media/callState.ts +360 -0
- package/src/ui/media/index.ts +135 -0
- package/src/ui/styled/AccountDashboard.tsx +193 -4
- package/src/ui/styled/BroadcastWatch.tsx +107 -0
- package/src/ui/styled/ForgotPasswordForm.tsx +5 -0
- package/src/ui/styled/LiveBroadcastList.tsx +171 -0
- package/src/ui/styled/LoginForm.tsx +10 -0
- package/src/ui/styled/MembershipCheckout.tsx +318 -45
- package/src/ui/styled/MembershipTiers.tsx +10 -3
- package/src/ui/styled/ResetPasswordForm.tsx +5 -0
- package/src/ui/styled/SignupForm.tsx +5 -0
- package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +166 -0
- package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +134 -0
- package/src/ui/styled/_tests/BroadcastPassValidation.spec.tsx +125 -0
- package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +364 -0
- package/src/ui/styled/broadcast/BroadcastPassValidation.tsx +187 -0
- package/src/ui/styled/broadcast/BroadcastPlayer.tsx +536 -0
- package/src/ui/styled/broadcast/BroadcastTicketPurchase.tsx +74 -0
- package/src/ui/styled/broadcast/EndedBroadcast.tsx +103 -0
- package/src/ui/styled/community/CommunityComposer.tsx +182 -3
- package/src/ui/styled/community/CommunityFeed.tsx +36 -51
- package/src/ui/styled/community/CommunityPostDetail.tsx +16 -2
- package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +281 -0
- package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +175 -0
- package/src/ui/styled/forge-utilities.css +832 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { renderHook } from "@testing-library/react";
|
|
4
|
+
import { MediaError } from "@tribe-nest/media-protocol";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
BOOKING_CALL_CLOSES_MINUTES_AFTER,
|
|
8
|
+
BOOKING_CALL_OPENS_MINUTES_BEFORE,
|
|
9
|
+
bookingCallWindow,
|
|
10
|
+
useBookingSessionCredentials,
|
|
11
|
+
type BookingCallSubject,
|
|
12
|
+
} from "../bookingSession";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The Forge context, stubbed at the seam the hook actually reads.
|
|
16
|
+
*
|
|
17
|
+
* `ForgeClientProvider` builds its Axios instance from an `apiUrl`, so there is
|
|
18
|
+
* no way to hand it a fake transport from outside; mocking `useForge` is the
|
|
19
|
+
* only join where a spec can see the request the hook makes.
|
|
20
|
+
*/
|
|
21
|
+
const { post, forgeContext } = vi.hoisted(() => {
|
|
22
|
+
const post = vi.fn();
|
|
23
|
+
// ONE object across renders, like the real provider's memoized client. A
|
|
24
|
+
// fresh one per render would change the hook's dependency and make the
|
|
25
|
+
// identity assertion below fail against a correct implementation.
|
|
26
|
+
return { post, forgeContext: { client: { post }, profileId: "11111111-1111-4111-8111-111111111111" } };
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
vi.mock("../../../provider/ForgeProvider", () => ({ useForge: () => forgeContext }));
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The booking half of the media barrel.
|
|
33
|
+
*
|
|
34
|
+
* Two things are worth testing here and nothing else is. `bookingCallWindow` is
|
|
35
|
+
* a total function that decides whether a Join control is drawn at all, and
|
|
36
|
+
* getting it wrong shows a client a button that 409s or hides one from a
|
|
37
|
+
* session that has started. `useBookingSessionCredentials` is a transport, and
|
|
38
|
+
* the property that matters is that it posts to the credential endpoint with
|
|
39
|
+
* NO body: the endpoint's schema is `.strict()`, so a field added here in a
|
|
40
|
+
* moment of helpfulness would 400 every call in production.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const MINUTE = 60_000;
|
|
44
|
+
const HOUR = 60 * MINUTE;
|
|
45
|
+
|
|
46
|
+
const booking = (overrides: Partial<BookingCallSubject> = {}): BookingCallSubject => ({
|
|
47
|
+
status: "confirmed",
|
|
48
|
+
sessionStartTime: new Date("2026-08-20T10:00:00.000Z").toISOString(),
|
|
49
|
+
sessionEndTime: new Date("2026-08-20T11:00:00.000Z").toISOString(),
|
|
50
|
+
location: { type: "video" },
|
|
51
|
+
...overrides,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const at = (iso: string) => new Date(iso);
|
|
55
|
+
|
|
56
|
+
describe("the join window, as literal numbers", () => {
|
|
57
|
+
/**
|
|
58
|
+
* The one place in this file that does NOT compute its expectation from the
|
|
59
|
+
* constants.
|
|
60
|
+
*
|
|
61
|
+
* Everything below asserts `opensAt` against
|
|
62
|
+
* `BOOKING_CALL_OPENS_MINUTES_BEFORE`, which is correct for what those tests
|
|
63
|
+
* are about (the arithmetic) and completely blind to the value: changing 15 to
|
|
64
|
+
* 60 leaves the whole suite green. The server carries its OWN pair of literals
|
|
65
|
+
* in `apps/backend/src/services/public/coaching/commands/bookingSession.ts`
|
|
66
|
+
* (`SESSION_JOIN_OPENS_MINUTES_BEFORE` / `SESSION_JOIN_CLOSES_MINUTES_AFTER`),
|
|
67
|
+
* whose spec had exactly the same self-referential shape, so a change on
|
|
68
|
+
* either side was invisible to both suites.
|
|
69
|
+
*
|
|
70
|
+
* The server's answer is the one that counts. When these two disagree the
|
|
71
|
+
* visible result is a Join button drawn 45 minutes before the server will mint
|
|
72
|
+
* a ticket (a client clicks and gets a bare 409), or a button hidden while the
|
|
73
|
+
* room is open. So both sides pin the value, and moving it means editing two
|
|
74
|
+
* specs that name each other.
|
|
75
|
+
*/
|
|
76
|
+
it("mirrors the server: 15 minutes before, 120 minutes after", () => {
|
|
77
|
+
expect(BOOKING_CALL_OPENS_MINUTES_BEFORE).toBe(15);
|
|
78
|
+
expect(BOOKING_CALL_CLOSES_MINUTES_AFTER).toBe(120);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("bookingCallWindow", () => {
|
|
83
|
+
it("opens exactly the stated number of minutes before the session", () => {
|
|
84
|
+
const subject = booking();
|
|
85
|
+
const opensAt = new Date(new Date(subject.sessionStartTime).getTime() - BOOKING_CALL_OPENS_MINUTES_BEFORE * MINUTE);
|
|
86
|
+
|
|
87
|
+
expect(bookingCallWindow(subject).opensAt.toISOString()).toBe(opensAt.toISOString());
|
|
88
|
+
// One millisecond either side of the boundary, so an off-by-one in the
|
|
89
|
+
// comparison cannot pass: a client refreshing at the advertised minute must
|
|
90
|
+
// get in, and one refreshing a moment earlier must not.
|
|
91
|
+
expect(bookingCallWindow(subject, new Date(opensAt.getTime() - 1)).isOpen).toBe(false);
|
|
92
|
+
expect(bookingCallWindow(subject, opensAt).isOpen).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("stays open after the hour ends, then shuts", () => {
|
|
96
|
+
const subject = booking();
|
|
97
|
+
const closesAt = new Date(new Date(subject.sessionEndTime).getTime() + BOOKING_CALL_CLOSES_MINUTES_AFTER * MINUTE);
|
|
98
|
+
|
|
99
|
+
expect(bookingCallWindow(subject).closesAt.toISOString()).toBe(closesAt.toISOString());
|
|
100
|
+
// A session that runs over must not be cut off, which is what the window
|
|
101
|
+
// after the end is for.
|
|
102
|
+
expect(bookingCallWindow(subject, at("2026-08-20T11:30:00.000Z")).isOpen).toBe(true);
|
|
103
|
+
expect(bookingCallWindow(subject, closesAt).isOpen).toBe(true);
|
|
104
|
+
expect(bookingCallWindow(subject, new Date(closesAt.getTime() + 1)).isOpen).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("is not a video call when the location is anything else", () => {
|
|
108
|
+
const midSession = at("2026-08-20T10:30:00.000Z");
|
|
109
|
+
|
|
110
|
+
for (const location of [{ type: "online" }, { type: "in_person" }, null, undefined]) {
|
|
111
|
+
const window = bookingCallWindow(booking({ location }), midSession);
|
|
112
|
+
expect(window.isVideo).toBe(false);
|
|
113
|
+
// Never open, however good the timing is: there is no room to enter.
|
|
114
|
+
expect(window.isOpen).toBe(false);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("is not a video call when the booking is not confirmed", () => {
|
|
119
|
+
const midSession = at("2026-08-20T10:30:00.000Z");
|
|
120
|
+
// A cancelled session has no room. The server withholds `location`
|
|
121
|
+
// entirely for one, so this is belt and braces against a cached row.
|
|
122
|
+
const window = bookingCallWindow(booking({ status: "canceled" }), midSession);
|
|
123
|
+
expect(window.isVideo).toBe(false);
|
|
124
|
+
expect(window.isOpen).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("holds for a session whose window straddles a day boundary", () => {
|
|
128
|
+
const subject = booking({
|
|
129
|
+
sessionStartTime: "2026-08-20T23:50:00.000Z",
|
|
130
|
+
sessionEndTime: "2026-08-21T00:50:00.000Z",
|
|
131
|
+
});
|
|
132
|
+
expect(bookingCallWindow(subject, at("2026-08-20T23:40:00.000Z")).isOpen).toBe(true);
|
|
133
|
+
expect(bookingCallWindow(subject, at("2026-08-20T23:34:00.000Z")).isOpen).toBe(false);
|
|
134
|
+
expect(bookingCallWindow(subject, at("2026-08-21T02:49:00.000Z")).isOpen).toBe(true);
|
|
135
|
+
expect(bookingCallWindow(subject, at("2026-08-21T02:51:00.000Z")).isOpen).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("useBookingSessionCredentials", () => {
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
post.mockReset();
|
|
142
|
+
post.mockResolvedValue({
|
|
143
|
+
data: {
|
|
144
|
+
mediaUrl: "wss://media.test",
|
|
145
|
+
token: "tok",
|
|
146
|
+
expiresAt: "2026-08-20T10:10:00.000Z",
|
|
147
|
+
identity: "guest-acc-1",
|
|
148
|
+
roomId: "booking-booking-1",
|
|
149
|
+
grants: { canPublish: true, canSubscribe: true, canPublishData: true },
|
|
150
|
+
role: "guest",
|
|
151
|
+
roomOpensAt: "2026-08-20T09:45:00.000Z",
|
|
152
|
+
roomClosesAt: "2026-08-20T13:00:00.000Z",
|
|
153
|
+
},
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("posts an EMPTY body to this booking's credential endpoint", async () => {
|
|
158
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
159
|
+
|
|
160
|
+
const credentials = await getCredentials();
|
|
161
|
+
|
|
162
|
+
expect(post).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(post).toHaveBeenCalledWith("/public/coaching/bookings/booking-1/session/join", {});
|
|
164
|
+
// The endpoint's schema is `.strict()` and declares no fields at all, so a
|
|
165
|
+
// `profileId` added here in a moment of helpfulness would 400 every call.
|
|
166
|
+
// Asserting the exact second argument is what catches that.
|
|
167
|
+
expect(credentials.roomId).toBe("booking-booking-1");
|
|
168
|
+
expect(credentials.role).toBe("guest");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* The server's refusal must reach the screen as the server's SENTENCE. Left
|
|
173
|
+
* as an axios error, the SDK read it as a dropped socket and the screen said
|
|
174
|
+
* "Connection lost" about a call the person was never in.
|
|
175
|
+
*/
|
|
176
|
+
it("turns the server's refusal into a MediaError carrying its message", async () => {
|
|
177
|
+
post.mockRejectedValueOnce({
|
|
178
|
+
response: { status: 409, data: { message: "The room opens 15 minutes before the session." } },
|
|
179
|
+
});
|
|
180
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
181
|
+
|
|
182
|
+
const error: unknown = await getCredentials().then(
|
|
183
|
+
() => undefined,
|
|
184
|
+
(e: unknown) => e,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
expect(error).toBeInstanceOf(MediaError);
|
|
188
|
+
expect((error as MediaError).message).toBe("The room opens 15 minutes before the session.");
|
|
189
|
+
expect((error as MediaError).code).toBe("bad_request");
|
|
190
|
+
expect((error as MediaError).retryable).toBe(false);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("maps a 401 and a 404 to the SDK's own codes, and a 5xx to a retryable internal", async () => {
|
|
194
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
195
|
+
const refusal = async (status: number) => {
|
|
196
|
+
post.mockRejectedValueOnce({ response: { status, data: { message: `status ${status}` } } });
|
|
197
|
+
return getCredentials().then(
|
|
198
|
+
() => undefined,
|
|
199
|
+
(e: unknown) => e as MediaError,
|
|
200
|
+
);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
expect((await refusal(401))?.code).toBe("unauthorized");
|
|
204
|
+
expect((await refusal(404))?.code).toBe("no_such_room");
|
|
205
|
+
const internal = await refusal(503);
|
|
206
|
+
expect(internal?.code).toBe("internal");
|
|
207
|
+
expect(internal?.retryable).toBe(true);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("leaves a network failure alone, because that one really is a connection problem", async () => {
|
|
211
|
+
const network = new Error("Network Error");
|
|
212
|
+
post.mockRejectedValueOnce(network);
|
|
213
|
+
const getCredentials = renderHook(() => useBookingSessionCredentials("booking-1")).result.current;
|
|
214
|
+
|
|
215
|
+
await expect(getCredentials()).rejects.toBe(network);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("keeps ONE callback identity per booking", () => {
|
|
219
|
+
const { result, rerender } = renderHook(() => useBookingSessionCredentials("booking-1"));
|
|
220
|
+
const first = result.current;
|
|
221
|
+
rerender();
|
|
222
|
+
// `<MediaRoomProvider>` holds its options in a ref because an inline arrow
|
|
223
|
+
// would be a new function every render; a callback that changed identity on
|
|
224
|
+
// every parent render would defeat that and reconnect a live call.
|
|
225
|
+
expect(result.current).toBe(first);
|
|
226
|
+
});
|
|
227
|
+
});
|