@tribe-nest/forge 3.34.0 → 3.35.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 (33) hide show
  1. package/package.json +3 -2
  2. package/src/client/_tests/tenantHeaders.spec.ts +77 -0
  3. package/src/client/createForgeClient.ts +37 -0
  4. package/src/data/queries/useFilmPlaybackSession.ts +224 -0
  5. package/src/data/queries/useFilms.ts +561 -0
  6. package/src/i18n/de.json +128 -28
  7. package/src/i18n/en.json +128 -28
  8. package/src/index.ts +5 -0
  9. package/src/provider/ForgeAppProvider.tsx +10 -0
  10. package/src/provider/ForgeProvider.tsx +18 -3
  11. package/src/server/index.ts +76 -17
  12. package/src/ui/headless/film/FilmWatermark.tsx +180 -0
  13. package/src/ui/headless/film/_tests/filmRules.spec.ts +531 -0
  14. package/src/ui/headless/film/_tests/useStageFullscreen.spec.ts +167 -0
  15. package/src/ui/headless/film/index.ts +36 -0
  16. package/src/ui/headless/film/useFilmCatalog.ts +66 -0
  17. package/src/ui/headless/film/useFilmPlayback.ts +497 -0
  18. package/src/ui/headless/film/useFilmRentalFlow.ts +277 -0
  19. package/src/ui/headless/film/useStageFullscreen.ts +156 -0
  20. package/src/ui/headless/index.ts +6 -9
  21. package/src/ui/index.ts +18 -16
  22. package/src/ui/media/CallStage.tsx +59 -1
  23. package/src/ui/media/CallWindowNotice.tsx +94 -0
  24. package/src/ui/media/_tests/CallWindowNotice.spec.tsx +83 -0
  25. package/src/ui/media/index.ts +9 -0
  26. package/src/ui/styled/AccountDashboard.tsx +28 -36
  27. package/src/ui/styled/FilmCatalog.tsx +278 -0
  28. package/src/ui/styled/FilmDetail.tsx +661 -0
  29. package/src/ui/styled/FilmLibrary.tsx +254 -0
  30. package/src/ui/styled/FilmWatch.tsx +701 -0
  31. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +28 -0
  32. package/src/ui/styled/forge-utilities.css +3 -0
  33. package/src/utils/structuredData.ts +88 -17
@@ -0,0 +1,531 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { buildFilmPlaybackGate, msUntil } from "../useFilmPlayback";
3
+ import {
4
+ absolutizeFilmUrl,
5
+ withFilmSessionToken,
6
+ filmErrorCode,
7
+ filmErrorStatus,
8
+ filmErrorMessage,
9
+ } from "../../../../data/queries/useFilmPlaybackSession";
10
+ import {
11
+ canWatchFilmEpisode,
12
+ cheapestFilmRentalOption,
13
+ filmFromPriceCents,
14
+ filmRentalCoversEpisode,
15
+ filmRentalOptions,
16
+ groupFilmEpisodesBySeason,
17
+ isFilmEpisodeReleased,
18
+ nextFilmEpisode,
19
+ splitDurationSec,
20
+ splitRemainingMs,
21
+ splitRentalHours,
22
+ type PublicFilmBase,
23
+ type PublicFilmEpisode,
24
+ type PublicFilmRental,
25
+ type PublicFilmRentalOption,
26
+ } from "../../../../data/queries/useFilms";
27
+ import { buildFilmSchema } from "../../../../utils/structuredData";
28
+
29
+ /**
30
+ * The pure half of film rentals, as both the styled blocks and any custom player
31
+ * read it.
32
+ *
33
+ * None of this is the entitlement - every playback endpoint re-derives coverage,
34
+ * liveness and the session holder from the database on every request, so being
35
+ * wrong here draws a button that 404s rather than granting anything. What being
36
+ * wrong here DOES cost is a person stranded on the wrong sentence: a gate that
37
+ * says "sign in" to somebody already signed in, or "cannot be played" to
38
+ * somebody who only needed to.
39
+ */
40
+
41
+ const episode = (over: Partial<PublicFilmEpisode> = {}): PublicFilmEpisode => ({
42
+ id: "ep-1",
43
+ filmId: "film-1",
44
+ seasonNumber: 1,
45
+ episodeNumber: 1,
46
+ title: null,
47
+ description: null,
48
+ durationSec: 2700,
49
+ availableAt: null,
50
+ isFreePreview: false,
51
+ ...over,
52
+ });
53
+
54
+ const option = (over: Partial<PublicFilmRentalOption> = {}): PublicFilmRentalOption => ({
55
+ id: "opt-48",
56
+ label: null,
57
+ priceCents: 200_00,
58
+ durationHours: 48,
59
+ position: 0,
60
+ ...over,
61
+ });
62
+
63
+ const film = (over: Partial<PublicFilmBase> = {}): PublicFilmBase => ({
64
+ id: "film-1",
65
+ kind: "series",
66
+ title: "The Four",
67
+ slug: "the-four",
68
+ description: null,
69
+ currency: "NGN",
70
+ rentalOptions: [option()],
71
+ fromPriceCents: 200_00,
72
+ publishedAt: "2026-01-01T00:00:00.000Z",
73
+ cover: null,
74
+ ...over,
75
+ });
76
+
77
+ const rejection = (status: number, body?: Record<string, unknown>) => ({ response: { status, data: body } });
78
+
79
+ describe("the playback gate", () => {
80
+ it("sends a signed-OUT 401 to sign in", () => {
81
+ const gate = buildFilmPlaybackGate({ error: rejection(401), isAuthenticated: false });
82
+ expect(gate?.kind).toBe("sign_in_required");
83
+ });
84
+
85
+ it("does NOT tell a signed-IN 401 to sign in", () => {
86
+ // Signing in is what they already did. The refusal is about the SESSION
87
+ // token, not about who they are, so pointing them at the login form is a
88
+ // loop with no exit.
89
+ const gate = buildFilmPlaybackGate({ error: rejection(401), isAuthenticated: true });
90
+ expect(gate?.kind).toBe("unavailable");
91
+ });
92
+
93
+ it("sends a 403 out of the session it is in, not back into one", () => {
94
+ const gate = buildFilmPlaybackGate({ error: rejection(403), isAuthenticated: true });
95
+ expect(gate?.kind).toBe("wrong_account");
96
+ });
97
+
98
+ it("reads a 404 as no rental when nothing is known about one", () => {
99
+ expect(buildFilmPlaybackGate({ error: rejection(404), isAuthenticated: true })?.kind).toBe("no_rental");
100
+ });
101
+
102
+ it("reads the SAME 404 as expired once a rental is known", () => {
103
+ // The status cannot tell these apart on purpose - a 403 for somebody else's
104
+ // rental would confirm the id exists. The caller's own knowledge is what
105
+ // turns "you never had this" into "yours ran out", and those two need
106
+ // different buttons.
107
+ const gate = buildFilmPlaybackGate({ error: rejection(404), isAuthenticated: true, hadRental: true });
108
+ expect(gate?.kind).toBe("expired");
109
+ });
110
+
111
+ it("keeps the API's own sentence when it sent one", () => {
112
+ const gate = buildFilmPlaybackGate({
113
+ error: rejection(404, { message: "That episode is not out yet." }),
114
+ isAuthenticated: true,
115
+ });
116
+ expect(gate?.message).toBe("That episode is not out yet.");
117
+ });
118
+
119
+ it("is null when nothing failed", () => {
120
+ expect(buildFilmPlaybackGate({ error: null, isAuthenticated: true })).toBeNull();
121
+ });
122
+
123
+ it("survives a rejection that is not axios-shaped", () => {
124
+ for (const thrown of [new Error("offline"), "nope", 42, {}, undefined]) {
125
+ const gate = buildFilmPlaybackGate({ error: thrown, isAuthenticated: true });
126
+ if (thrown === undefined) expect(gate).toBeNull();
127
+ else expect(gate?.kind).toBe("unavailable");
128
+ }
129
+ });
130
+ });
131
+
132
+ describe("reading a rejection", () => {
133
+ it("pulls status, code and message without importing axios", () => {
134
+ const error = rejection(409, { code: "FILM_SESSION_TAKEN_OVER", message: "Playing elsewhere." });
135
+ expect(filmErrorStatus(error)).toBe(409);
136
+ expect(filmErrorCode(error)).toBe("FILM_SESSION_TAKEN_OVER");
137
+ expect(filmErrorMessage(error)).toBe("Playing elsewhere.");
138
+ });
139
+
140
+ it("returns null rather than throwing on anything else", () => {
141
+ for (const thrown of [null, undefined, "boom", { response: {} }, { response: { status: "409" } }]) {
142
+ expect(filmErrorStatus(thrown)).toBeNull();
143
+ expect(filmErrorCode(thrown)).toBeNull();
144
+ expect(filmErrorMessage(thrown)).toBeNull();
145
+ }
146
+ });
147
+
148
+ it("treats a blank message as no message", () => {
149
+ expect(filmErrorMessage(rejection(404, { message: " " }))).toBeNull();
150
+ });
151
+ });
152
+
153
+ describe("the countdown", () => {
154
+ const now = new Date("2026-08-23T12:00:00.000Z");
155
+
156
+ it("is null with no window, which is what a free preview has", () => {
157
+ expect(msUntil(null, now)).toBeNull();
158
+ expect(msUntil(undefined, now)).toBeNull();
159
+ });
160
+
161
+ it("never runs backwards", () => {
162
+ // Zero IS the answer once it has run out. A negative number renders as a
163
+ // countdown counting up, which is how a fan learns not to trust the clock.
164
+ expect(msUntil("2026-08-23T11:00:00.000Z", now)).toBe(0);
165
+ });
166
+
167
+ it("is null for a timestamp it cannot read", () => {
168
+ expect(msUntil("not a date", now)).toBeNull();
169
+ });
170
+
171
+ it("counts the remaining milliseconds", () => {
172
+ expect(msUntil("2026-08-23T12:30:00.000Z", now)).toBe(30 * 60 * 1000);
173
+ });
174
+
175
+ it("rounds a remainder DOWN at every level", () => {
176
+ // 59 minutes that reads as "1 hour" is a promise the clock will not keep,
177
+ // and the fan finds out at the worst possible moment.
178
+ const split = splitRemainingMs(59 * 60_000 + 59_000);
179
+ expect(split).toEqual({ days: 0, hours: 0, minutes: 59, totalMinutes: 59 });
180
+ });
181
+
182
+ it("splits days, hours and minutes", () => {
183
+ const split = splitRemainingMs((26 * 60 + 5) * 60_000);
184
+ expect(split).toEqual({ days: 1, hours: 2, minutes: 5, totalMinutes: 1565 });
185
+ });
186
+
187
+ it("is null for nothing and for nonsense", () => {
188
+ expect(splitRemainingMs(null)).toBeNull();
189
+ expect(splitRemainingMs(-1)).toBeNull();
190
+ expect(splitRemainingMs(Number.NaN)).toBeNull();
191
+ });
192
+ });
193
+
194
+ describe("what a rental covers", () => {
195
+ it("grants EVERY episode of the title it was bought for", () => {
196
+ // The whole product decision in one assertion: a rental is for a title, so
197
+ // episode 9 of a ten-part series is covered by the same row as episode 1.
198
+ const rental = { filmId: "film-1" };
199
+ expect(filmRentalCoversEpisode(rental, episode({ id: "ep-1", episodeNumber: 1 }))).toBe(true);
200
+ expect(filmRentalCoversEpisode(rental, episode({ id: "ep-9", episodeNumber: 9 }))).toBe(true);
201
+ });
202
+
203
+ it("does NOT reach across titles", () => {
204
+ const rental = { filmId: "film-1" };
205
+ expect(filmRentalCoversEpisode(rental, episode({ id: "ep-9", filmId: "film-2" }))).toBe(false);
206
+ });
207
+
208
+ it("short-circuits a free preview, with or without a rental", () => {
209
+ const preview = episode({ id: "ep-1", isFreePreview: true });
210
+ expect(filmRentalCoversEpisode(null, preview)).toBe(true);
211
+ expect(filmRentalCoversEpisode(undefined, preview)).toBe(true);
212
+ });
213
+
214
+ it("covers nothing without a rental when the episode is not a preview", () => {
215
+ expect(filmRentalCoversEpisode(null, episode())).toBe(false);
216
+ });
217
+ });
218
+
219
+ /**
220
+ * The one question every episode row asks. One grain means one rental to ask it
221
+ * of, so there is no set to search and no helper that takes one.
222
+ */
223
+ describe("whether an episode can be played right now", () => {
224
+ const rental = (over: Partial<PublicFilmRental> = {}): PublicFilmRental => ({
225
+ id: "r-1",
226
+ filmId: "film-1",
227
+ status: "active",
228
+ amountCents: 200,
229
+ currency: "USD",
230
+ rentalDurationHours: 48,
231
+ rentalOptionId: "opt-48",
232
+ paidAt: "2026-08-20T00:00:00.000Z",
233
+ startBy: "2026-09-19T00:00:00.000Z",
234
+ firstPlayedAt: null,
235
+ expiresAt: null,
236
+ refundedAt: null,
237
+ revokedAt: null,
238
+ isLive: true,
239
+ msRemaining: null,
240
+ windowMs: 48 * 3600_000,
241
+ ...over,
242
+ });
243
+
244
+ const now = new Date("2026-08-23T12:00:00.000Z");
245
+
246
+ it("opens every episode of the rented title", () => {
247
+ const live = rental();
248
+ expect(canWatchFilmEpisode(live, episode({ id: "ep-1", episodeNumber: 1 }), now)).toBe(true);
249
+ expect(canWatchFilmEpisode(live, episode({ id: "ep-9", episodeNumber: 9 }), now)).toBe(true);
250
+ });
251
+
252
+ it("does not open another title's episode", () => {
253
+ expect(canWatchFilmEpisode(rental(), episode({ id: "ep-9", filmId: "film-2" }), now)).toBe(false);
254
+ });
255
+
256
+ it("refuses a covering rental whose window has run out", () => {
257
+ // Covered but expired is not a reason to draw Watch: the playback endpoints
258
+ // re-derive liveness and would 404.
259
+ expect(canWatchFilmEpisode(rental({ isLive: false }), episode(), now)).toBe(false);
260
+ });
261
+
262
+ it("plays a free preview with no rental at all", () => {
263
+ const preview = episode({ id: "ep-1", isFreePreview: true });
264
+ expect(canWatchFilmEpisode(null, preview, now)).toBe(true);
265
+ expect(canWatchFilmEpisode(undefined, preview, now)).toBe(true);
266
+ });
267
+
268
+ it("refuses everything else with no rental", () => {
269
+ expect(canWatchFilmEpisode(null, episode(), now)).toBe(false);
270
+ });
271
+
272
+ it("holds back an unreleased episode, free preview or not", () => {
273
+ // Drip release is not entitlement, so it is checked separately and it wins:
274
+ // a free preview that is not out yet still has nothing to play.
275
+ const unreleased = { availableAt: "2026-08-24T00:00:00.000Z" };
276
+ expect(canWatchFilmEpisode(rental(), episode(unreleased), now)).toBe(false);
277
+ expect(canWatchFilmEpisode(null, episode({ ...unreleased, isFreePreview: true }), now)).toBe(false);
278
+ });
279
+ });
280
+
281
+ describe("whether an episode has been released", () => {
282
+ const now = new Date("2026-08-23T12:00:00.000Z");
283
+
284
+ it("treats a null release date as out", () => {
285
+ expect(isFilmEpisodeReleased(episode({ availableAt: null }), now)).toBe(true);
286
+ });
287
+
288
+ it("is inclusive at the boundary, matching the server", () => {
289
+ expect(isFilmEpisodeReleased(episode({ availableAt: "2026-08-23T12:00:00.000Z" }), now)).toBe(true);
290
+ });
291
+
292
+ it("holds back a future release", () => {
293
+ expect(isFilmEpisodeReleased(episode({ availableAt: "2026-08-23T12:00:00.001Z" }), now)).toBe(false);
294
+ });
295
+
296
+ it("treats an unreadable timestamp as released, matching the NULL arm", () => {
297
+ expect(isFilmEpisodeReleased(episode({ availableAt: "garbage" }), now)).toBe(true);
298
+ });
299
+ });
300
+
301
+ describe("what is on sale", () => {
302
+ const cheap = option({ id: "opt-8", priceCents: 100_00, durationHours: 8, position: 3 });
303
+ const dear = option({ id: "opt-48", priceCents: 200_00, durationHours: 48, position: 1 });
304
+
305
+ it("keeps the creator's own order, whatever order the payload arrived in", () => {
306
+ // `dear` sits at position 1 because the creator put it there with the up
307
+ // arrow, and the admin screen told them "Fans see these in this order, top
308
+ // first." Re-sorting by price here made that sentence false and made every
309
+ // reorder invisible, since two windows on one title rarely cost the same.
310
+ expect(filmRentalOptions(film({ rentalOptions: [cheap, dear] })).map((o) => o.id)).toEqual(["opt-48", "opt-8"]);
311
+ });
312
+
313
+ it("breaks a position tie on price, then on id", () => {
314
+ const a = option({ id: "b", priceCents: 200_00, durationHours: 8, position: 1 });
315
+ const b = option({ id: "a", priceCents: 100_00, durationHours: 12, position: 1 });
316
+ const c = option({ id: "a2", priceCents: 100_00, durationHours: 24, position: 1 });
317
+ expect(filmRentalOptions(film({ rentalOptions: [a, b, c] })).map((o) => o.id)).toEqual(["a", "a2", "b"]);
318
+ });
319
+
320
+ it("defaults to the CHEAPEST window even when the creator shows a dearer one first", () => {
321
+ // Pre-selecting the dearer window would make the default choice the one that
322
+ // costs more, which is a decision the fan did not make. `dear` is first in
323
+ // the list above, so this can never be "take index 0".
324
+ expect(cheapestFilmRentalOption(film({ rentalOptions: [cheap, dear] }))?.id).toBe("opt-8");
325
+ expect(filmFromPriceCents(film({ rentalOptions: [cheap, dear], fromPriceCents: null }))).toBe(100_00);
326
+ });
327
+
328
+ it("has nothing on sale when there are no live options", () => {
329
+ // Every window archived looks exactly like never having priced the title,
330
+ // and both mean the Rent button stays off the page.
331
+ expect(filmRentalOptions(film({ rentalOptions: [] }))).toEqual([]);
332
+ expect(cheapestFilmRentalOption(film({ rentalOptions: [] }))).toBeNull();
333
+ expect(filmFromPriceCents(film({ rentalOptions: [], fromPriceCents: null }))).toBeNull();
334
+ });
335
+
336
+ it("prints the server's `from` price, falling back to the cheapest option", () => {
337
+ expect(filmFromPriceCents(film({ rentalOptions: [dear, cheap], fromPriceCents: 100_00 }))).toBe(10000);
338
+ // A hand-built payload (a custom surface, a fixture) has no `fromPriceCents`
339
+ // and must still answer the same number rather than "not on sale".
340
+ expect(filmFromPriceCents({ rentalOptions: [dear, cheap] })).toBe(10000);
341
+ });
342
+
343
+ it("treats a zero price as a real (free) window, not as an absence", () => {
344
+ // An explicit 0 is a free rental somebody chose to publish, and hiding it
345
+ // would delete a feature. Every check is `!= null`, never truthiness.
346
+ const free = film({ rentalOptions: [option({ priceCents: 0 })], fromPriceCents: 0 });
347
+ expect(cheapestFilmRentalOption(free)?.priceCents).toBe(0);
348
+ expect(filmFromPriceCents(free)).toBe(0);
349
+ });
350
+
351
+ it("is empty or null for no film at all", () => {
352
+ expect(filmRentalOptions(undefined)).toEqual([]);
353
+ expect(filmRentalOptions(null)).toEqual([]);
354
+ expect(cheapestFilmRentalOption(undefined)).toBeNull();
355
+ expect(filmFromPriceCents(null)).toBeNull();
356
+ });
357
+
358
+ it("does not mutate the payload it was handed", () => {
359
+ // The array belongs to a react-query cache entry. Sorting it in place would
360
+ // reorder the very object the next render reads, which is the kind of bug
361
+ // that only shows up as a list that shuffles.
362
+ // Handed in the order the sort will CHANGE, or an in-place sort would go
363
+ // unnoticed here.
364
+ const options = [cheap, dear];
365
+ expect(filmRentalOptions({ rentalOptions: options }).map((o) => o.id)).toEqual(["opt-48", "opt-8"]);
366
+ expect(options.map((o) => o.id)).toEqual(["opt-8", "opt-48"]);
367
+ });
368
+ });
369
+
370
+ describe("ordering episodes", () => {
371
+ const list = [
372
+ episode({ id: "s2e1", seasonNumber: 2, episodeNumber: 1 }),
373
+ episode({ id: "s1e2", seasonNumber: 1, episodeNumber: 2 }),
374
+ episode({ id: "s1e1", seasonNumber: 1, episodeNumber: 1 }),
375
+ ];
376
+
377
+ it("groups by season, ascending, with episodes in order", () => {
378
+ expect(groupFilmEpisodesBySeason(list).map((s) => [s.seasonNumber, s.episodes.map((e) => e.id)])).toEqual([
379
+ [1, ["s1e1", "s1e2"]],
380
+ [2, ["s2e1"]],
381
+ ]);
382
+ });
383
+
384
+ it("crosses a season boundary to find the next episode", () => {
385
+ expect(nextFilmEpisode(list, "s1e2")?.id).toBe("s2e1");
386
+ });
387
+
388
+ it("ends at the last episode rather than wrapping", () => {
389
+ // A series still releasing weekly must end at the last one OUT, or the
390
+ // prompt offers a link to an episode nobody can play.
391
+ expect(nextFilmEpisode(list, "s2e1")).toBeNull();
392
+ });
393
+
394
+ it("is null for an episode that is not in the list", () => {
395
+ expect(nextFilmEpisode(list, "not-here")).toBeNull();
396
+ });
397
+ });
398
+
399
+ describe("formatting durations", () => {
400
+ it("splits a runtime into hours and whole minutes", () => {
401
+ expect(splitDurationSec(6120)).toEqual({ hours: 1, minutes: 42 });
402
+ expect(splitDurationSec(2700)).toEqual({ hours: 0, minutes: 45 });
403
+ });
404
+
405
+ it("is null for nothing and for nonsense", () => {
406
+ expect(splitDurationSec(null)).toBeNull();
407
+ expect(splitDurationSec(0)).toBeNull();
408
+ expect(splitDurationSec(Number.NaN)).toBeNull();
409
+ });
410
+
411
+ it("splits a rental window into days and hours", () => {
412
+ expect(splitRentalHours(48)).toEqual({ days: 2, hours: 0 });
413
+ expect(splitRentalHours(30)).toEqual({ days: 1, hours: 6 });
414
+ expect(splitRentalHours(0)).toEqual({ days: 0, hours: 0 });
415
+ });
416
+ });
417
+
418
+ describe("playback URLs", () => {
419
+ it("resolves a root-relative path against the API, not the site", () => {
420
+ // A root-relative path on a creator site resolves against the CREATOR's
421
+ // origin, where it hits the site's own router and 404s. That reads as "the
422
+ // player is broken".
423
+ expect(absolutizeFilmUrl("/public/films/a/episodes/b/manifest/master", "https://api.tribenest.co")).toBe(
424
+ "https://api.tribenest.co/public/films/a/episodes/b/manifest/master",
425
+ );
426
+ });
427
+
428
+ it("leaves an absolute URL alone", () => {
429
+ expect(absolutizeFilmUrl("https://api.example.com/x", "https://api.tribenest.co")).toBe(
430
+ "https://api.example.com/x",
431
+ );
432
+ });
433
+
434
+ it("does not double the slash when the API URL has a trailing one", () => {
435
+ expect(absolutizeFilmUrl("/x", "https://api.tribenest.co/")).toBe("https://api.tribenest.co/x");
436
+ });
437
+
438
+ it("attaches the token as `st`, respecting an existing query string", () => {
439
+ expect(withFilmSessionToken("https://a/b", "tok")).toBe("https://a/b?st=tok");
440
+ expect(withFilmSessionToken("https://a/b?x=1", "tok")).toBe("https://a/b?x=1&st=tok");
441
+ });
442
+
443
+ it("encodes a token that would otherwise break the query string", () => {
444
+ expect(withFilmSessionToken("https://a/b", "a+b/c=")).toBe("https://a/b?st=a%2Bb%2Fc%3D");
445
+ });
446
+
447
+ it("builds a subtitle src the same way it builds the manifest src", () => {
448
+ // A `<track>` is fetched by the BROWSER, not by the engine, so it cannot be
449
+ // handed a header either: the session token has to be in the URL, exactly as
450
+ // it is for the manifest and the key. The API deliberately sends the track
451
+ // URL token-free, because the heartbeat rotates the token and a baked-in one
452
+ // would be stale inside ten minutes.
453
+ const track = { url: "/public/films/a/episodes/b/subtitles/c" };
454
+ expect(withFilmSessionToken(absolutizeFilmUrl(track.url, "https://api.tribenest.co"), "tok")).toBe(
455
+ "https://api.tribenest.co/public/films/a/episodes/b/subtitles/c?st=tok",
456
+ );
457
+ });
458
+
459
+ it("does not append a SECOND token to a url that already carries one", () => {
460
+ // `?st=a&st=b` is parsed by Express as an array, and the schema rejects an
461
+ // array, so a double-appended token is a 400 on every subtitle fetch rather
462
+ // than a token that merely loses. Appending exactly once is the caller's
463
+ // job, and this pins what happens when they get it wrong.
464
+ const once = withFilmSessionToken("https://api/x", "a");
465
+ expect(withFilmSessionToken(once, "b")).toBe("https://api/x?st=a&st=b");
466
+ });
467
+ });
468
+
469
+ /**
470
+ * `buildFilmSchema` takes ONE price, because a schema.org `Offer` quotes one.
471
+ * The starter route feeds it `fromPriceCents` - the cheapest window - which is
472
+ * what the catalogue card says and what a search result should say. Quoting the
473
+ * dearest window would advertise a price nobody has to pay.
474
+ */
475
+ const schemaSource = (over: Partial<PublicFilmBase> = {}, episodes: { durationSec?: number | null }[] = []) => {
476
+ const source = film(over);
477
+ return { ...source, rentalPriceCents: source.fromPriceCents, episodes };
478
+ };
479
+
480
+ describe("the JSON-LD a crawler reads", () => {
481
+ it("is a Movie for a film and a TVSeries for a series", () => {
482
+ expect(buildFilmSchema(schemaSource({ kind: "film" }))?.["@type"]).toBe("Movie");
483
+ expect(buildFilmSchema(schemaSource())?.["@type"]).toBe("TVSeries");
484
+ });
485
+
486
+ it("quotes the CHEAPEST window's price, in major units, as a rental", () => {
487
+ const schema = buildFilmSchema(
488
+ schemaSource({
489
+ rentalOptions: [option({ id: "opt-8", priceCents: 100_00, durationHours: 8 }), option()],
490
+ fromPriceCents: 100_00,
491
+ }),
492
+ { currency: "NGN" },
493
+ );
494
+ expect(schema?.offers).toEqual({
495
+ "@type": "Offer",
496
+ priceCurrency: "NGN",
497
+ price: 100,
498
+ availability: "https://schema.org/InStock",
499
+ category: "Rental",
500
+ });
501
+ });
502
+
503
+ it("emits no offer when nothing is on sale", () => {
504
+ const schema = buildFilmSchema(schemaSource({ rentalOptions: [], fromPriceCents: null }), { currency: "NGN" });
505
+ expect(schema).not.toHaveProperty("offers");
506
+ });
507
+
508
+ it("gives a film its runtime and a series none", () => {
509
+ const asFilm = buildFilmSchema(schemaSource({ kind: "film" }, [{ durationSec: 6120 }]));
510
+ expect(asFilm?.duration).toBe("PT1H42M");
511
+ // A series has no ONE runtime, and an invented average is worse than silence.
512
+ const asSeries = buildFilmSchema(schemaSource({}, [{ durationSec: 2700 }, { durationSec: 2800 }]));
513
+ expect(asSeries).not.toHaveProperty("duration");
514
+ expect(asSeries?.numberOfEpisodes).toBe(2);
515
+ });
516
+
517
+ it("is null with nothing truthful to say", () => {
518
+ expect(buildFilmSchema(null)).toBeNull();
519
+ expect(buildFilmSchema(schemaSource({ title: " " }))).toBeNull();
520
+ });
521
+
522
+ it("carries no rental, session or storage field at any depth", () => {
523
+ // The paywall assertion is on the BYTES, not on the shape: a leak the UI
524
+ // happens not to render is still a leak, and this node is served to an
525
+ // anonymous SSR document.
526
+ const serialized = JSON.stringify(buildFilmSchema(schemaSource({}, [{ durationSec: 2700 }])));
527
+ for (const forbidden of ["hlsPrefix", "hls_prefix", "manifest", "sessionToken", "keyUrl", "rental_id"]) {
528
+ expect(serialized).not.toContain(forbidden);
529
+ }
530
+ });
531
+ });