@tribe-nest/forge 3.59.0 → 3.61.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 (266) hide show
  1. package/package.json +2 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CartContext.tsx +34 -1
  4. package/src/contexts/CommunityContext.tsx +192 -0
  5. package/src/contexts/PublicAuthContext.tsx +15 -3
  6. package/src/data/queries/_tests/metaSignals.spec.tsx +130 -0
  7. package/src/data/queries/useAccount.ts +33 -2
  8. package/src/data/queries/useAnalytics.ts +6 -6
  9. package/src/data/queries/useCallChat.ts +2 -2
  10. package/src/data/queries/useChat.ts +52 -40
  11. package/src/data/queries/useCheckouts.ts +2 -1
  12. package/src/data/queries/useCoachingAvailability.ts +10 -1
  13. package/src/data/queries/useCommunities.ts +345 -0
  14. package/src/data/queries/useCommunity.ts +450 -102
  15. package/src/data/queries/useCommunityAgent.ts +247 -0
  16. package/src/data/queries/useCommunityCalendar.ts +150 -0
  17. package/src/data/queries/useCommunityClassroom.ts +75 -0
  18. package/src/data/queries/useCommunityMembers.ts +108 -0
  19. package/src/data/queries/useCommunityModeration.ts +327 -0
  20. package/src/data/queries/useCommunityWiki.ts +137 -0
  21. package/src/data/queries/useCourses.ts +2 -0
  22. package/src/data/queries/useEmailSignIn.ts +79 -0
  23. package/src/data/queries/useEvents.ts +6 -1
  24. package/src/data/queries/useForms.ts +5 -1
  25. package/src/data/queries/useLeadMagnet.ts +6 -2
  26. package/src/data/queries/useMembership.ts +32 -7
  27. package/src/data/queries/useMembershipCalls.ts +116 -7
  28. package/src/data/queries/useMyBookings.ts +24 -1
  29. package/src/data/queries/useMyTickets.ts +24 -1
  30. package/src/data/queries/useNotifications.ts +6 -2
  31. package/src/data/queries/useOrders.ts +2 -1
  32. package/src/data/queries/usePWA.ts +111 -35
  33. package/src/data/queries/usePostCollections.ts +28 -16
  34. package/src/data/queries/usePosts.ts +16 -14
  35. package/src/data/queries/useSiteApp.ts +130 -0
  36. package/src/data/queries/useSubscriptions.ts +28 -9
  37. package/src/i18n/de.json +891 -2
  38. package/src/i18n/en.json +891 -2
  39. package/src/index.ts +36 -7
  40. package/src/runtime/pages.spec.ts +36 -0
  41. package/src/runtime/pages.ts +52 -27
  42. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  43. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  44. package/src/types/models.ts +11 -0
  45. package/src/ui/analytics/ForgeAnalytics.tsx +64 -34
  46. package/src/ui/analytics/PageMetaPixel.tsx +23 -8
  47. package/src/ui/analytics/_tests/ForgeAnalytics.meta.spec.tsx +129 -0
  48. package/src/ui/analytics/_tests/metaCommerceEvents.spec.tsx +159 -0
  49. package/src/ui/analytics/useMetaEvents.ts +78 -0
  50. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  51. package/src/ui/format/membershipGate.ts +19 -15
  52. package/src/ui/headless/_tests/communityAgent.spec.tsx +519 -0
  53. package/src/ui/headless/_tests/communityCalendar.spec.tsx +195 -0
  54. package/src/ui/headless/_tests/communityClassroom.spec.tsx +120 -0
  55. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  56. package/src/ui/headless/_tests/communityHarness.tsx +212 -0
  57. package/src/ui/headless/_tests/communityMembers.spec.tsx +316 -0
  58. package/src/ui/headless/_tests/communityPoll.spec.tsx +389 -0
  59. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  60. package/src/ui/headless/_tests/memberHomeBar.spec.ts +84 -0
  61. package/src/ui/headless/_tests/siteAppBar.spec.ts +83 -0
  62. package/src/ui/headless/_tests/siteAppHarness.ts +19 -0
  63. package/src/ui/headless/_tests/siteAppHome.spec.ts +226 -0
  64. package/src/ui/headless/_tests/siteAppPrompts.spec.ts +185 -0
  65. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  66. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +596 -0
  67. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +179 -0
  68. package/src/ui/headless/_tests/useCommunityModeration.spec.tsx +420 -0
  69. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +295 -0
  70. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  71. package/src/ui/headless/_tests/useCommunityWiki.spec.tsx +224 -0
  72. package/src/ui/headless/_tests/useSiteApp.spec.tsx +52 -0
  73. package/src/ui/headless/_tests/wikiMediaImage.spec.tsx +132 -0
  74. package/src/ui/headless/auth/useEmailCodeSignIn.ts +186 -0
  75. package/src/ui/headless/auth/useSignupForm.ts +30 -3
  76. package/src/ui/headless/chat/useChatAttachmentUpload.ts +9 -6
  77. package/src/ui/headless/chat/useChatRoom.ts +35 -1
  78. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  79. package/src/ui/headless/communities/index.ts +96 -0
  80. package/src/ui/headless/communities/paths.ts +36 -0
  81. package/src/ui/headless/communities/useCommunity.ts +140 -0
  82. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  83. package/src/ui/headless/communities/useCommunityCalendar.ts +156 -0
  84. package/src/ui/headless/communities/useCommunityClassroom.ts +84 -0
  85. package/src/ui/headless/communities/useCommunityHome.ts +91 -0
  86. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  87. package/src/ui/headless/communities/useCommunityJoin.ts +494 -0
  88. package/src/ui/headless/communities/useCommunityMembership.ts +182 -0
  89. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  90. package/src/ui/headless/communities/useCommunityWiki.ts +266 -0
  91. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  92. package/src/ui/headless/community/communityPoll.ts +297 -0
  93. package/src/ui/headless/community/countries.ts +123 -0
  94. package/src/ui/headless/community/index.ts +81 -1
  95. package/src/ui/headless/community/linkPreview.ts +71 -0
  96. package/src/ui/headless/community/richLexical.ts +27 -2
  97. package/src/ui/headless/community/useCommunityAgent.ts +285 -0
  98. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  99. package/src/ui/headless/community/useCommunityMembers.ts +175 -0
  100. package/src/ui/headless/community/useCommunityModeration.ts +300 -0
  101. package/src/ui/headless/community/useCommunitySpaceFeed.ts +22 -4
  102. package/src/ui/headless/community/useCommunityThread.ts +16 -3
  103. package/src/ui/headless/community/useMentionComposer.ts +17 -9
  104. package/src/ui/headless/community/useRichMentionComposer.ts +21 -5
  105. package/src/ui/headless/downloads/index.ts +8 -0
  106. package/src/ui/headless/downloads/useDownloadPage.ts +134 -0
  107. package/src/ui/headless/index.ts +21 -4
  108. package/src/ui/headless/memberHome/index.ts +11 -1
  109. package/src/ui/headless/memberHome/memberHomeBar.ts +91 -0
  110. package/src/ui/headless/memberHome/useMemberHomeNav.ts +84 -44
  111. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  112. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  113. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  114. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  115. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  116. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  117. package/src/ui/headless/portal/index.ts +57 -0
  118. package/src/ui/headless/portal/usePortal.ts +274 -0
  119. package/src/ui/headless/portal/usePortalFiles.ts +68 -0
  120. package/src/ui/headless/portal/usePortalMessages.ts +189 -0
  121. package/src/ui/headless/portal/usePortalPreview.ts +142 -0
  122. package/src/ui/headless/posts/upsell.ts +54 -8
  123. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  124. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  125. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  126. package/src/ui/headless/posts/usePostItem.ts +6 -5
  127. package/src/ui/headless/siteApp/appBar.ts +102 -0
  128. package/src/ui/headless/siteApp/appHome.ts +344 -0
  129. package/src/ui/headless/siteApp/appPrompts.ts +229 -0
  130. package/src/ui/headless/siteApp/index.ts +58 -0
  131. package/src/ui/headless/siteApp/useAppHome.ts +210 -0
  132. package/src/ui/headless/siteApp/useAppInstallRequest.ts +16 -0
  133. package/src/ui/headless/work/index.ts +2 -0
  134. package/src/ui/headless/work/useWorkPortal.ts +37 -26
  135. package/src/ui/index.ts +28 -12
  136. package/src/ui/styled/AccountDashboard.tsx +754 -602
  137. package/src/ui/styled/AiAgentWidget.tsx +13 -1
  138. package/src/ui/styled/ChatRoom.tsx +126 -16
  139. package/src/ui/styled/Checkout.tsx +8 -0
  140. package/src/ui/styled/CheckoutConfirmation.tsx +8 -0
  141. package/src/ui/styled/CoachingConfirmation.tsx +8 -0
  142. package/src/ui/styled/CoachingDetail.tsx +2 -0
  143. package/src/ui/styled/CourseConfirmation.tsx +5 -0
  144. package/src/ui/styled/CourseDetail.tsx +2 -0
  145. package/src/ui/styled/DownloadPage.tsx +263 -0
  146. package/src/ui/styled/EventConfirmation.tsx +10 -0
  147. package/src/ui/styled/EventDetail.tsx +10 -0
  148. package/src/ui/styled/FilmLibrary.tsx +22 -9
  149. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  150. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  151. package/src/ui/styled/MembershipTiers.tsx +49 -11
  152. package/src/ui/styled/MusicLinkPage.tsx +5 -5
  153. package/src/ui/styled/Paywall.tsx +1 -1
  154. package/src/ui/styled/ProductDetail.tsx +12 -0
  155. package/src/ui/styled/PwaRegistration.tsx +5 -1
  156. package/src/ui/styled/SignupForm.tsx +18 -3
  157. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +6 -6
  158. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  159. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  160. package/src/ui/styled/_tests/AccountNotifications.spec.tsx +135 -0
  161. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  162. package/src/ui/styled/_tests/DownloadPage.spec.tsx +68 -0
  163. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  164. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  165. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  166. package/src/ui/styled/app/AppBar.tsx +298 -0
  167. package/src/ui/styled/app/AppHome.tsx +1139 -0
  168. package/src/ui/styled/app/AppHomeMark.tsx +66 -0
  169. package/src/ui/styled/app/AppPrompts.tsx +464 -0
  170. package/src/ui/styled/app/_tests/AppBar.spec.tsx +96 -0
  171. package/src/ui/styled/app/_tests/AppHome.spec.tsx +209 -0
  172. package/src/ui/styled/app/_tests/AppPrompts.spec.tsx +232 -0
  173. package/src/ui/styled/app/index.ts +7 -0
  174. package/src/ui/styled/chat/ChatThreadPanel.tsx +29 -4
  175. package/src/ui/styled/chat/MessageBubble.tsx +43 -6
  176. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  177. package/src/ui/styled/communities/CommunityAbout.tsx +145 -0
  178. package/src/ui/styled/communities/CommunityCalendar.tsx +484 -0
  179. package/src/ui/styled/communities/CommunityClassroom.tsx +302 -0
  180. package/src/ui/styled/communities/CommunityHome.tsx +298 -0
  181. package/src/ui/styled/communities/CommunityJoinSheet.tsx +544 -0
  182. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +249 -0
  183. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  184. package/src/ui/styled/communities/CommunityPublicWiki.tsx +75 -0
  185. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  186. package/src/ui/styled/communities/CommunityWiki.tsx +289 -0
  187. package/src/ui/styled/communities/MyMembershipsList.tsx +115 -0
  188. package/src/ui/styled/communities/_tests/CommunityAppHome.spec.tsx +92 -0
  189. package/src/ui/styled/communities/_tests/CommunityClassroomCalendar.spec.tsx +453 -0
  190. package/src/ui/styled/communities/_tests/CommunityMobile.spec.tsx +399 -0
  191. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  192. package/src/ui/styled/communities/_tests/CommunityWiki.spec.tsx +223 -0
  193. package/src/ui/styled/communities/index.ts +19 -0
  194. package/src/ui/styled/communities/shared.tsx +177 -0
  195. package/src/ui/styled/community/CommunityAgent.tsx +286 -0
  196. package/src/ui/styled/community/CommunityComposer.tsx +266 -15
  197. package/src/ui/styled/community/CommunityFeed.tsx +50 -1
  198. package/src/ui/styled/community/CommunityLinkPreview.tsx +159 -0
  199. package/src/ui/styled/community/CommunityMemberGlobe.tsx +171 -0
  200. package/src/ui/styled/community/CommunityMemberProfile.tsx +137 -4
  201. package/src/ui/styled/community/CommunityMembers.tsx +387 -0
  202. package/src/ui/styled/community/CommunityModeration.tsx +587 -0
  203. package/src/ui/styled/community/CommunityModerationState.tsx +127 -0
  204. package/src/ui/styled/community/CommunityModeratorMenu.tsx +317 -0
  205. package/src/ui/styled/community/CommunityNotifications.tsx +52 -6
  206. package/src/ui/styled/community/CommunityPollCard.tsx +250 -0
  207. package/src/ui/styled/community/CommunityPostCard.tsx +110 -34
  208. package/src/ui/styled/community/CommunityPostDetail.tsx +157 -71
  209. package/src/ui/styled/community/CommunityRichText.tsx +177 -32
  210. package/src/ui/styled/community/CommunitySpaces.tsx +49 -14
  211. package/src/ui/styled/community/_tests/CommunityAgent.spec.tsx +571 -0
  212. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  213. package/src/ui/styled/community/_tests/CommunityLinkPreview.spec.tsx +167 -0
  214. package/src/ui/styled/community/_tests/CommunityMembers.spec.tsx +137 -0
  215. package/src/ui/styled/community/_tests/CommunityModeration.spec.tsx +291 -0
  216. package/src/ui/styled/community/_tests/CommunityPoll.spec.tsx +386 -0
  217. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  218. package/src/ui/styled/community/index.ts +37 -2
  219. package/src/ui/styled/forge-utilities.css +253 -0
  220. package/src/ui/styled/members/CallRecordingActions.tsx +182 -0
  221. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  222. package/src/ui/styled/members/MemberCallsCalendar.tsx +41 -54
  223. package/src/ui/styled/members/MemberHomeLayout.tsx +327 -29
  224. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  225. package/src/ui/styled/members/MemberPostsFeed.tsx +3 -2
  226. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  227. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  228. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  229. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  230. package/src/ui/styled/members/index.ts +8 -0
  231. package/src/ui/styled/portal/PortalAccountSection.tsx +61 -0
  232. package/src/ui/styled/portal/PortalActivity.tsx +167 -0
  233. package/src/ui/styled/portal/PortalBilling.tsx +197 -0
  234. package/src/ui/styled/portal/PortalFiles.tsx +279 -0
  235. package/src/ui/styled/portal/PortalHome.tsx +401 -0
  236. package/src/ui/styled/portal/PortalMessages.tsx +300 -0
  237. package/src/ui/styled/portal/PortalPreviewBanner.tsx +75 -0
  238. package/src/ui/styled/portal/PortalProjects.tsx +407 -0
  239. package/src/ui/styled/portal/PortalShell.tsx +397 -0
  240. package/src/ui/styled/portal/_tests/PortalAppHome.spec.tsx +90 -0
  241. package/src/ui/styled/portal/_tests/PortalFiles.spec.tsx +159 -0
  242. package/src/ui/styled/portal/_tests/PortalHome.spec.tsx +148 -0
  243. package/src/ui/styled/portal/_tests/PortalMessages.spec.tsx +168 -0
  244. package/src/ui/styled/portal/_tests/PortalPreviewBanner.spec.tsx +132 -0
  245. package/src/ui/styled/portal/_tests/portalNav.spec.ts +123 -0
  246. package/src/ui/styled/portal/index.ts +13 -0
  247. package/src/ui/styled/portal/portalNav.ts +260 -0
  248. package/src/ui/styled/portal/shared.tsx +206 -0
  249. package/src/ui/styled/work/WorkInviteAccept.tsx +228 -168
  250. package/src/ui/styled/work/WorkPortalProjects.tsx +68 -120
  251. package/src/ui/styled/work/WorkProjectDocuments.tsx +99 -128
  252. package/src/ui/styled/work/WorkProjectInvoices.tsx +91 -86
  253. package/src/ui/styled/work/WorkProjectReport.tsx +11 -17
  254. package/src/ui/styled/work/WorkReportBody.tsx +223 -248
  255. package/src/ui/styled/work/WorkTaskAttachments.tsx +23 -29
  256. package/src/ui/styled/work/WorkTaskComments.tsx +75 -102
  257. package/src/ui/styled/work/WorkTaskDetail.tsx +48 -59
  258. package/src/ui/styled/work/WorkTokenReport.tsx +8 -16
  259. package/src/ui/styled/work/index.ts +1 -1
  260. package/src/utils/_tests/metaEvents.spec.ts +264 -0
  261. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  262. package/src/utils/landing.ts +19 -6
  263. package/src/utils/metaEvents.ts +302 -0
  264. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  265. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  266. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -0,0 +1,195 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { act, renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ calendarMonthWindow,
6
+ calendarMonthStart,
7
+ callJoinable,
8
+ groupCalendarItems,
9
+ nextRsvp,
10
+ rsvpErrorCode,
11
+ useCommunityCalendar,
12
+ } from "../communities/useCommunityCalendar";
13
+ import type { CommunityCalendarCall, CommunityCalendarEvent } from "../../../data/queries/useCommunityCalendar";
14
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper, type Route } from "./communityHarness";
15
+
16
+ /**
17
+ * The community calendar as the site reads it: one month per request, grouped
18
+ * by the viewer's own days with what is still ahead on top, and going or not
19
+ * going on a call (what is SENT, and that the new counts land in the cache).
20
+ */
21
+
22
+ afterEach(restoreAdapter);
23
+
24
+ /** Local wall-clock times, so the day grouping holds in any test time zone. */
25
+ const at = (day: number, hour: number) => new Date(2026, 8, day, hour, 0, 0).toISOString();
26
+ const NOW = new Date(2026, 8, 15, 12, 0, 0);
27
+
28
+ const call = (overrides: Partial<CommunityCalendarCall> = {}): CommunityCalendarCall => ({
29
+ kind: "call",
30
+ id: "11111111-aaaa-4aaa-8aaa-111111111111",
31
+ title: "Weekly Q&A",
32
+ description: null,
33
+ startsAt: at(16, 18),
34
+ endsAt: at(16, 19),
35
+ timezone: "Europe/Berlin",
36
+ participation: "open",
37
+ role: "attendee",
38
+ audiences: [],
39
+ opensAt: at(16, 17),
40
+ closesAt: at(16, 19),
41
+ isOpen: false,
42
+ callPublicId: "pub-1",
43
+ rsvp: { going: 2, notGoing: 1, mine: null },
44
+ icsUrl: "https://api.test/calendar/calls/c1.ics",
45
+ recording: null,
46
+ ...overrides,
47
+ });
48
+
49
+ const event = (overrides: Partial<CommunityCalendarEvent> = {}): CommunityCalendarEvent => ({
50
+ kind: "event",
51
+ id: "event-1",
52
+ title: "Album launch",
53
+ description: null,
54
+ slug: "album-launch",
55
+ startsAt: at(16, 20),
56
+ endsAt: null,
57
+ timezone: "Europe/Berlin",
58
+ type: "physical",
59
+ address: { name: "The Club", city: "Berlin" },
60
+ cancelled: false,
61
+ icsUrl: "https://api.test/calendar/events/e1.ics",
62
+ ...overrides,
63
+ });
64
+
65
+ const put =
66
+ (path: string, answer: (req: { body: Record<string, unknown> | null }) => unknown): Route =>
67
+ (req) =>
68
+ req.method === "put" && req.url === path ? answer(req) : undefined;
69
+
70
+ describe("calendar helpers", () => {
71
+ it("reads one month from its first local midnight to the next", () => {
72
+ const { from, to } = calendarMonthWindow(calendarMonthStart(NOW));
73
+ expect(from).toEqual(new Date(2026, 8, 1));
74
+ expect(to).toEqual(new Date(2026, 9, 1));
75
+ expect(calendarMonthStart(NOW, -1)).toEqual(new Date(2026, 7, 1));
76
+ expect(calendarMonthStart(NOW, 4)).toEqual(new Date(2027, 0, 1));
77
+ });
78
+
79
+ it("groups by day, upcoming soonest first, then what already happened, latest first", () => {
80
+ const items = [
81
+ event({ id: "e-late", startsAt: at(16, 20) }),
82
+ call({ id: "c-early", startsAt: at(16, 9), endsAt: at(16, 10) }),
83
+ call({ id: "c-next-week", startsAt: at(22, 18), endsAt: at(22, 19) }),
84
+ call({ id: "c-past", startsAt: at(3, 18), endsAt: at(3, 19) }),
85
+ event({ id: "e-past", startsAt: at(10, 20), endsAt: at(10, 23) }),
86
+ ];
87
+ const { upcoming, past } = groupCalendarItems(items, NOW);
88
+ expect(upcoming.map((day) => [day.key, day.items.map((item) => item.id)])).toEqual([
89
+ ["2026-09-16", ["c-early", "e-late"]],
90
+ ["2026-09-22", ["c-next-week"]],
91
+ ]);
92
+ expect(past.map((day) => [day.key, day.items.map((item) => item.id)])).toEqual([
93
+ ["2026-09-10", ["e-past"]],
94
+ ["2026-09-03", ["c-past"]],
95
+ ]);
96
+ });
97
+
98
+ it("keeps a call that is on right now with what is ahead", () => {
99
+ const live = call({ id: "live", startsAt: at(15, 11), endsAt: at(15, 13), isOpen: true });
100
+ expect(groupCalendarItems([live], NOW).upcoming[0]!.items[0]!.id).toBe("live");
101
+ });
102
+
103
+ it("clears an answer when the same one is pressed again", () => {
104
+ expect(nextRsvp(null, "going")).toBe("going");
105
+ expect(nextRsvp("going", "going")).toBeNull();
106
+ expect(nextRsvp("going", "not_going")).toBe("not_going");
107
+ });
108
+
109
+ it("offers Join only while the room is open and there is one", () => {
110
+ expect(callJoinable(call({ isOpen: true }))).toBe(true);
111
+ expect(callJoinable(call({ isOpen: false }))).toBe(false);
112
+ expect(callJoinable(call({ isOpen: true, callPublicId: null }))).toBe(false);
113
+ });
114
+
115
+ it("reads a refused answer", () => {
116
+ expect(rsvpErrorCode({ response: { status: 409 } })).toBe("closed");
117
+ expect(rsvpErrorCode({ response: { status: 404 } })).toBe("not_found");
118
+ expect(rsvpErrorCode(new Error("network"))).toBe("failed");
119
+ });
120
+ });
121
+
122
+ describe("useCommunityCalendar", () => {
123
+ const PATH = "/public/communities/fan-club/calendar";
124
+ const RSVP = `/public/communities/fan-club/calendar/calls/${call().id}/rsvp`;
125
+
126
+ it("asks for the month shown, and the next one on the arrow", async () => {
127
+ installAdapter([get(PATH, () => [call(), event()])]);
128
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
129
+ await waitFor(() => expect(result.current.items).toHaveLength(2));
130
+ expect(calls("get", PATH)[0]!.params).toEqual({
131
+ profileId: PROFILE_ID,
132
+ from: new Date(2026, 8, 1).toISOString(),
133
+ to: new Date(2026, 9, 1).toISOString(),
134
+ });
135
+ act(() => result.current.goToNextMonth());
136
+ await waitFor(() => expect(calls("get", PATH)).toHaveLength(2));
137
+ expect(calls("get", PATH)[1]!.params).toMatchObject({
138
+ from: new Date(2026, 9, 1).toISOString(),
139
+ to: new Date(2026, 10, 1).toISOString(),
140
+ });
141
+ expect(result.current.isCurrentMonth).toBe(false);
142
+ });
143
+
144
+ it("sends going, and puts the new counts in the cache", async () => {
145
+ installAdapter([
146
+ get(PATH, () => [call()]),
147
+ put(RSVP, (req) => ({ going: 3, notGoing: 1, mine: req.body?.response ?? null })),
148
+ ]);
149
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
150
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
151
+ act(() => result.current.answer(result.current.items[0] as CommunityCalendarCall, "going"));
152
+ await waitFor(() => expect((result.current.items[0] as CommunityCalendarCall).rsvp).toEqual({ going: 3, notGoing: 1, mine: "going" }));
153
+ expect(calls("put", RSVP)[0]!.body).toEqual({ profileId: PROFILE_ID, response: "going" });
154
+ // Only the one GET: the answer's own reply is what updates the list.
155
+ expect(calls("get", PATH)).toHaveLength(1);
156
+ });
157
+
158
+ it("clears the answer when the chosen one is pressed again", async () => {
159
+ installAdapter([
160
+ get(PATH, () => [call({ rsvp: { going: 3, notGoing: 1, mine: "going" } })]),
161
+ put(RSVP, () => ({ going: 2, notGoing: 1, mine: null })),
162
+ ]);
163
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
164
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
165
+ act(() => result.current.answer(result.current.items[0] as CommunityCalendarCall, "going"));
166
+ await waitFor(() => expect((result.current.items[0] as CommunityCalendarCall).rsvp.mine).toBeNull());
167
+ expect(calls("put", RSVP)[0]!.body).toEqual({ profileId: PROFILE_ID, response: null });
168
+ });
169
+
170
+ it("keeps a 409 against the call it was for", async () => {
171
+ installAdapter([
172
+ get(PATH, () => [call()]),
173
+ put(RSVP, () => {
174
+ throw { status: 409, message: "Closed" };
175
+ }),
176
+ ]);
177
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
178
+ await waitFor(() => expect(result.current.items).toHaveLength(1));
179
+ act(() => result.current.answer(result.current.items[0] as CommunityCalendarCall, "not_going"));
180
+ await waitFor(() => expect(result.current.rsvpErrorFor(call().id)).toBe("closed"));
181
+ expect(result.current.rsvpErrorFor("another-call")).toBeNull();
182
+ expect((result.current.items[0] as CommunityCalendarCall).rsvp).toEqual(call().rsvp);
183
+ });
184
+
185
+ it("is forbidden for a non-member", async () => {
186
+ installAdapter([
187
+ get(PATH, () => {
188
+ throw { status: 403, message: "Forbidden" };
189
+ }),
190
+ ]);
191
+ const { result } = renderHook(() => useCommunityCalendar({ now: NOW }), { wrapper: wrapper({ user: account() }) });
192
+ await waitFor(() => expect(result.current.forbidden).toBe(true));
193
+ expect(result.current.isError).toBe(false);
194
+ });
195
+ });
@@ -0,0 +1,120 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import {
5
+ classroomCourseHref,
6
+ classroomCourseState,
7
+ classroomProgressPercent,
8
+ classroomUnlockParts,
9
+ useCommunityClassroom,
10
+ } from "../communities/useCommunityClassroom";
11
+ import type { CommunityClassroomCourse } from "../../../data/queries/useCommunityClassroom";
12
+ import { PROFILE_ID, account, calls, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
13
+
14
+ /**
15
+ * The classroom tab's reading of the server: what state a card is in, how far
16
+ * through it the member is, what opens a locked one, where an open one leads,
17
+ * and what the hook SENDS (the community by slug and the profile, nothing more).
18
+ */
19
+
20
+ afterEach(restoreAdapter);
21
+
22
+ const classroomCourse =(overrides: Partial<CommunityClassroomCourse> = {}): CommunityClassroomCourse => ({
23
+ courseId: "course-1",
24
+ title: "Songwriting 101",
25
+ description: "From idea to demo.",
26
+ slug: "songwriting-101",
27
+ coverUrl: null,
28
+ open: true,
29
+ progress: { completed: 3, total: 10, completedAt: null, currentLessonId: "lesson-4" },
30
+ unlockLevel: null,
31
+ unlockTiers: [],
32
+ accessId: "acc-1",
33
+ ...overrides,
34
+ });
35
+
36
+ describe("classroom helpers", () => {
37
+ it("reads progress as a percentage, full once completed", () => {
38
+ expect(classroomProgressPercent({ completed: 3, total: 10, completedAt: null, currentLessonId: null })).toBe(30);
39
+ expect(classroomProgressPercent({ completed: 0, total: 0, completedAt: null, currentLessonId: null })).toBe(0);
40
+ expect(
41
+ classroomProgressPercent({ completed: 2, total: 10, completedAt: "2026-09-01T00:00:00.000Z", currentLessonId: null }),
42
+ ).toBe(100);
43
+ expect(classroomProgressPercent(null)).toBe(0);
44
+ });
45
+
46
+ it("names the card's state", () => {
47
+ expect(classroomCourseState(classroomCourse())).toBe("in_progress");
48
+ expect(classroomCourseState(classroomCourse({ progress: { completed: 0, total: 5, completedAt: null, currentLessonId: null } }))).toBe(
49
+ "not_started",
50
+ );
51
+ expect(classroomCourseState(classroomCourse({ progress: { completed: 5, total: 5, completedAt: null, currentLessonId: null } }))).toBe(
52
+ "completed",
53
+ );
54
+ expect(classroomCourseState(classroomCourse({ open: false, progress: null }))).toBe("locked");
55
+ });
56
+
57
+ it("says what opens a locked course", () => {
58
+ expect(classroomUnlockParts(classroomCourse({ open: false, unlockLevel: 4 }))).toEqual({ level: 4, tiers: "" });
59
+ expect(
60
+ classroomUnlockParts(
61
+ classroomCourse({
62
+ open: false,
63
+ unlockTiers: [
64
+ { id: "t-gold", name: "Gold" },
65
+ { id: "t-vip", name: "VIP" },
66
+ ],
67
+ }),
68
+ ),
69
+ ).toEqual({ level: null, tiers: "Gold, VIP" });
70
+ });
71
+
72
+ it("opens the course player, and never falls back to the sales page", () => {
73
+ expect(classroomCourseHref({ open: true, accessId: "acc-9" })).toBe("/i/course-access/acc-9");
74
+ expect(classroomCourseHref({ open: true, accessId: null })).toBeNull();
75
+ expect(classroomCourseHref({ open: false, accessId: null })).toBeNull();
76
+ });
77
+ });
78
+
79
+ describe("useCommunityClassroom", () => {
80
+ it("reads the classroom by slug with the profile", async () => {
81
+ installAdapter([get("/public/communities/fan-club/classroom", () => [classroomCourse()])]);
82
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper({ user: account() }) });
83
+ await waitFor(() => expect(result.current.courses).toHaveLength(1));
84
+ const sent = calls("get", "/public/communities/fan-club/classroom");
85
+ expect(sent).toHaveLength(1);
86
+ expect(sent[0]!.params).toEqual({ profileId: PROFILE_ID });
87
+ expect(result.current.forbidden).toBe(false);
88
+ });
89
+
90
+ it("is forbidden, not broken, for a non-member", async () => {
91
+ installAdapter([
92
+ get("/public/communities/fan-club/classroom", () => {
93
+ throw { status: 403, message: "Forbidden" };
94
+ }),
95
+ ]);
96
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper({ user: account() }) });
97
+ await waitFor(() => expect(result.current.forbidden).toBe(true));
98
+ expect(result.current.isError).toBe(false);
99
+ // A 403 is an answer: asking again changes nothing.
100
+ expect(calls("get", "/public/communities/fan-club/classroom")).toHaveLength(1);
101
+ });
102
+
103
+ it("asks nothing while signed out", async () => {
104
+ installAdapter([get("/public/communities/fan-club/classroom", () => [classroomCourse()])]);
105
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper() });
106
+ await new Promise((resolve) => setTimeout(resolve, 20));
107
+ expect(result.current.courses).toEqual([]);
108
+ expect(calls("get", "/public/communities/fan-club/classroom")).toHaveLength(0);
109
+ });
110
+
111
+ it("offers an upgrade only when a tier includes the course", async () => {
112
+ installAdapter([get("/public/communities/fan-club/classroom", () => [])]);
113
+ const { result } = renderHook(() => useCommunityClassroom(), { wrapper: wrapper({ user: account() }) });
114
+ const tierLocked = classroomCourse({ open: false, progress: null, unlockTiers: [{ id: "t-gold", name: "Gold" }] });
115
+ const levelLocked = classroomCourse({ open: false, progress: null, unlockLevel: 3 });
116
+ expect(result.current.canUpgrade(tierLocked)).toBe(true);
117
+ expect(result.current.canUpgrade(levelLocked)).toBe(false);
118
+ expect(result.current.unlockHref(tierLocked)).toBe("/i/communities/fan-club?tier=t-gold");
119
+ });
120
+ });
@@ -0,0 +1,80 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { render, screen, waitFor } from "@testing-library/react";
4
+ import { CommunityGate, communityGateAllows } from "../communities/CommunityGate";
5
+ import { account, detailRoute, installAdapter, meRoute, restoreAdapter, viewer, wrapper } from "./communityHarness";
6
+
7
+ /**
8
+ * `CommunityGate` replaces `MembershipGate`. With no tiers it means "an active
9
+ * member of THIS community": a membership in another community of the same
10
+ * creator unlocks nothing here.
11
+ */
12
+
13
+ afterEach(restoreAdapter);
14
+
15
+ const memberOn = (tierId: string, state: "member" | "past_due" | "cancelled" = "member") =>
16
+ viewer({
17
+ state,
18
+ membership: { id: "m-1", tierId, tierName: tierId, status: "active", cancelAtPeriodEnd: false, currentPeriodEnd: null, pendingDowngradeTierId: null },
19
+ });
20
+
21
+ describe("communityGateAllows", () => {
22
+ it("any active member of this community, when no tiers are named", () => {
23
+ expect(communityGateAllows(memberOn("tier-a"))).toBe(true);
24
+ expect(communityGateAllows(memberOn("tier-a", "past_due"))).toBe(true);
25
+ });
26
+ it("nobody else: none, pending, cancelled, anonymous, or no viewer at all", () => {
27
+ for (const state of ["none", "pending", "cancelled", "anonymous"] as const) {
28
+ expect(communityGateAllows(viewer({ state }))).toBe(false);
29
+ }
30
+ expect(communityGateAllows(undefined)).toBe(false);
31
+ });
32
+ it("only the named tiers when tiers are named", () => {
33
+ expect(communityGateAllows(memberOn("tier-a"), ["tier-a", "tier-b"])).toBe(true);
34
+ expect(communityGateAllows(memberOn("tier-c"), ["tier-a", "tier-b"])).toBe(false);
35
+ });
36
+ });
37
+
38
+ function renderGate(me: () => ReturnType<typeof viewer>, tiers?: string[]) {
39
+ installAdapter([meRoute(me), detailRoute(() => ({ viewer: me() }))]);
40
+ const Wrapper = wrapper({ user: account() });
41
+ render(
42
+ <Wrapper>
43
+ <CommunityGate tiers={tiers} fallback={(state) => <p data-testid="locked">{state.viewerState}</p>}>
44
+ <p data-testid="unlocked">secret</p>
45
+ </CommunityGate>
46
+ </Wrapper>,
47
+ );
48
+ }
49
+
50
+ describe("<CommunityGate>", () => {
51
+ it("shows the content to a member of this community", async () => {
52
+ renderGate(() => memberOn("tier-a"));
53
+ expect(await screen.findByTestId("unlocked")).toBeTruthy();
54
+ });
55
+
56
+ it("locks out a fan whose membership is in ANOTHER community (the server answers none here)", async () => {
57
+ renderGate(() => viewer({ state: "none" }));
58
+ expect((await screen.findByTestId("locked")).textContent).toBe("none");
59
+ expect(screen.queryByTestId("unlocked")).toBeNull();
60
+ });
61
+
62
+ it("locks out a member on a tier the gate does not name", async () => {
63
+ renderGate(() => memberOn("tier-c"), ["tier-a"]);
64
+ await waitFor(() => expect(screen.getByTestId("locked").textContent).toBe("member"));
65
+ });
66
+
67
+ it("refuses to render outside a provider", () => {
68
+ installAdapter([]);
69
+ const Wrapper = wrapper({ community: null });
70
+ expect(() =>
71
+ render(
72
+ <Wrapper>
73
+ <CommunityGate>
74
+ <p>secret</p>
75
+ </CommunityGate>
76
+ </Wrapper>,
77
+ ),
78
+ ).toThrow(/CommunityProvider/);
79
+ });
80
+ });
@@ -0,0 +1,212 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import axios, { type AxiosAdapter, type InternalAxiosRequestConfig } from "axios";
3
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
4
+ import { ForgeClientProvider } from "../../../provider/ForgeProvider";
5
+ import { PublicAuthContext } from "../../../contexts/PublicAuthContext";
6
+ import { CommunityProvider } from "../../../contexts/CommunityContext";
7
+ import type {
8
+ CommunityDetail,
9
+ CommunityViewerState,
10
+ PublicCommunity,
11
+ PublicTier,
12
+ } from "../../../data/queries/useCommunities";
13
+
14
+ /**
15
+ * A real provider tree for the community hook specs: the real data hooks over
16
+ * a stub axios adapter, so a spec asserts what is SENT, and an auth context
17
+ * whose sign-up, code and sign-in steps really sign the fan in (the join sheet
18
+ * reacts to that exactly as it does on a site).
19
+ */
20
+
21
+ export const PROFILE_ID = "profile-1";
22
+
23
+ export const COMMUNITY: PublicCommunity = {
24
+ id: "11111111-1111-4111-8111-111111111111",
25
+ name: "Fan Club",
26
+ slug: "fan-club",
27
+ description: "Where the fans are.",
28
+ coverUrl: null,
29
+ logoUrl: null,
30
+ category: "Music",
31
+ tags: [],
32
+ memberCount: 42,
33
+ };
34
+
35
+ export const OTHER_COMMUNITY: PublicCommunity = {
36
+ ...COMMUNITY,
37
+ id: "22222222-2222-4222-8222-222222222222",
38
+ name: "Producers Circle",
39
+ slug: "producers",
40
+ };
41
+
42
+ export const tier = (overrides: Partial<PublicTier> = {}): PublicTier => ({
43
+ id: "tier-free",
44
+ name: "Free",
45
+ description: "",
46
+ payWhatYouWant: false,
47
+ benefits: [],
48
+ communityId: COMMUNITY.id,
49
+ ...overrides,
50
+ });
51
+
52
+ export const viewer = (overrides: Partial<CommunityViewerState> = {}): CommunityViewerState => ({
53
+ state: "none",
54
+ membership: null,
55
+ application: null,
56
+ upgradeTiers: [],
57
+ canInteract: false,
58
+ ...overrides,
59
+ });
60
+
61
+ export const account = (email = "fan@example.com") => ({
62
+ id: "account-1",
63
+ email,
64
+ firstName: "Fan",
65
+ lastName: "Person",
66
+ kind: "fan",
67
+ status: "active",
68
+ createdAt: "2026-01-01T00:00:00.000Z",
69
+ updatedAt: "2026-01-01T00:00:00.000Z",
70
+ });
71
+
72
+ export interface Sent {
73
+ method: string;
74
+ url: string;
75
+ params: Record<string, unknown> | undefined;
76
+ body: Record<string, unknown> | null;
77
+ }
78
+
79
+ export type Route = (req: Sent) => unknown | undefined;
80
+
81
+ let realAdapter: AxiosAdapter | undefined;
82
+ export let sent: Sent[] = [];
83
+
84
+ /**
85
+ * Answer requests from `routes` in order; the first that returns something
86
+ * other than `undefined` wins. A thrown `{ status }` becomes that HTTP error.
87
+ */
88
+ export function installAdapter(routes: Route[]) {
89
+ sent = [];
90
+ realAdapter ??= axios.defaults.adapter as AxiosAdapter | undefined;
91
+ axios.defaults.adapter = (async (config: InternalAxiosRequestConfig) => {
92
+ const req: Sent = {
93
+ method: (config.method ?? "get").toLowerCase(),
94
+ url: config.url ?? "",
95
+ params: config.params as Record<string, unknown> | undefined,
96
+ body: config.data ? (JSON.parse(config.data as string) as Record<string, unknown>) : null,
97
+ };
98
+ sent.push(req);
99
+ for (const route of routes) {
100
+ let data: unknown;
101
+ try {
102
+ data = route(req);
103
+ } catch (e) {
104
+ const status = (e as { status?: number }).status ?? 500;
105
+ const error = Object.assign(new Error(`HTTP ${status}`), {
106
+ response: { status, data: { message: (e as { message?: string }).message } },
107
+ config,
108
+ isAxiosError: true,
109
+ });
110
+ throw error;
111
+ }
112
+ if (data !== undefined) return { data, status: 200, statusText: "OK", headers: {}, config } as never;
113
+ }
114
+ return { data: {}, status: 200, statusText: "OK", headers: {}, config } as never;
115
+ }) as AxiosAdapter;
116
+ }
117
+
118
+ export function restoreAdapter() {
119
+ if (realAdapter) axios.defaults.adapter = realAdapter;
120
+ }
121
+
122
+ /** The community read, answered for `/public/communities/:slug` (with or without a token). */
123
+ export const detailRoute =
124
+ (detail: (req: Sent) => Partial<CommunityDetail>): Route =>
125
+ (req) => {
126
+ if (req.method !== "get") return undefined;
127
+ const match = /^\/public\/communities\/([^/]+)$/.exec(req.url);
128
+ if (!match) return undefined;
129
+ return { community: COMMUNITY, tiers: [], viewer: viewer(), invite: null, ...detail(req) };
130
+ };
131
+
132
+ export const meRoute =
133
+ (state: () => CommunityViewerState): Route =>
134
+ (req) =>
135
+ req.method === "get" && /^\/public\/communities\/[^/]+\/me$/.test(req.url) ? state() : undefined;
136
+
137
+ export const post = (path: string, answer: (req: Sent) => unknown): Route => (req) =>
138
+ req.method === "post" && req.url === path ? answer(req) : undefined;
139
+
140
+ export const get = (path: string | RegExp, answer: (req: Sent) => unknown): Route => (req) =>
141
+ req.method === "get" && (typeof path === "string" ? req.url === path : path.test(req.url)) ? answer(req) : undefined;
142
+
143
+ export interface AuthControls {
144
+ logout?: () => void;
145
+ /** Sees every session the email-first flow hands to `adoptSession`. */
146
+ adopted?: (session: unknown) => void;
147
+ }
148
+
149
+ /**
150
+ * A stateful auth context: `register` succeeds and asks for a code,
151
+ * `verifyRegistration` signs the fan in, `adoptSession` (the email-first flow)
152
+ * signs in whoever the session names, `validateLoginCredentials` +
153
+ * `verifyLoginCode` do the same for sign-in, `logout` signs out.
154
+ */
155
+ function TestAuth({ initialUser, children, controls }: { initialUser: unknown; children: ReactNode; controls?: AuthControls }) {
156
+ const [user, setUser] = useState<ReturnType<typeof account> | null>(initialUser as ReturnType<typeof account> | null);
157
+ const value = {
158
+ user,
159
+ isAuthenticated: !!user,
160
+ isInitialized: true,
161
+ currencies: null,
162
+ userSelectedCurrency: "USD",
163
+ register: async () => ({}),
164
+ verifyRegistration: async (data: { email: string }) => {
165
+ setUser(account(data.email));
166
+ return {};
167
+ },
168
+ validateLoginCredentials: async () => ({}),
169
+ verifyLoginCode: async (email: string) => {
170
+ setUser(account(email));
171
+ return {};
172
+ },
173
+ resendLoginCode: async () => {},
174
+ adoptSession: (session: { account: ReturnType<typeof account> }) => {
175
+ controls?.adopted?.(session);
176
+ setUser(session.account);
177
+ },
178
+ logout: async () => {
179
+ controls?.logout?.();
180
+ setUser(null);
181
+ },
182
+ refetchUser: async () => {},
183
+ };
184
+ return <PublicAuthContext.Provider value={value as never}>{children}</PublicAuthContext.Provider>;
185
+ }
186
+
187
+ /** A `renderHook` wrapper: client, auth, and (unless `community: null`) a community provider. */
188
+ export function wrapper(opts: { user?: unknown; community?: PublicCommunity | null; controls?: AuthControls } = {}) {
189
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
190
+ const community = opts.community === undefined ? COMMUNITY : opts.community;
191
+ return function Wrapper({ children }: { children: ReactNode }) {
192
+ return (
193
+ <QueryClientProvider client={client}>
194
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
195
+ <TestAuth initialUser={opts.user ?? null} controls={opts.controls}>
196
+ {community ? (
197
+ <CommunityProvider community={community.slug} initialData={community}>
198
+ {children}
199
+ </CommunityProvider>
200
+ ) : (
201
+ children
202
+ )}
203
+ </TestAuth>
204
+ </ForgeClientProvider>
205
+ </QueryClientProvider>
206
+ );
207
+ };
208
+ }
209
+
210
+ /** Requests to `path` (exact) with `method`. */
211
+ export const calls = (method: string, path: string | RegExp) =>
212
+ sent.filter((req) => req.method === method && (typeof path === "string" ? req.url === path : path.test(req.url)));