@tribe-nest/forge 3.59.0 → 3.60.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 (94) hide show
  1. package/package.json +1 -1
  2. package/src/_tests/testCommunity.tsx +28 -0
  3. package/src/contexts/CommunityContext.tsx +192 -0
  4. package/src/data/queries/useChat.ts +42 -39
  5. package/src/data/queries/useCommunities.ts +343 -0
  6. package/src/data/queries/useCommunity.ts +93 -89
  7. package/src/data/queries/useMembership.ts +32 -7
  8. package/src/data/queries/useMembershipCalls.ts +116 -7
  9. package/src/data/queries/usePostCollections.ts +28 -16
  10. package/src/data/queries/usePosts.ts +16 -14
  11. package/src/data/queries/useSubscriptions.ts +22 -8
  12. package/src/i18n/de.json +145 -1
  13. package/src/i18n/en.json +145 -1
  14. package/src/index.ts +19 -7
  15. package/src/runtime/pages.spec.ts +31 -0
  16. package/src/runtime/pages.ts +21 -22
  17. package/src/server/_tests/buildWebManifest.spec.ts +1 -1
  18. package/src/server/_tests/forgeServiceWorkerScript.spec.ts +4 -4
  19. package/src/ui/format/_tests/membershipGate.spec.ts +6 -6
  20. package/src/ui/format/membershipGate.ts +19 -15
  21. package/src/ui/headless/_tests/communityGate.spec.tsx +80 -0
  22. package/src/ui/headless/_tests/communityHarness.tsx +205 -0
  23. package/src/ui/headless/_tests/communityProvider.spec.tsx +151 -0
  24. package/src/ui/headless/_tests/useCommunityInvite.spec.tsx +104 -0
  25. package/src/ui/headless/_tests/useCommunityJoin.spec.tsx +382 -0
  26. package/src/ui/headless/_tests/useCommunityMembership.spec.tsx +151 -0
  27. package/src/ui/headless/_tests/useCommunityReads.spec.tsx +286 -0
  28. package/src/ui/headless/_tests/useCommunityUpgrade.spec.tsx +157 -0
  29. package/src/ui/headless/auth/useSignupForm.ts +10 -2
  30. package/src/ui/headless/communities/CommunityGate.tsx +67 -0
  31. package/src/ui/headless/communities/index.ts +55 -0
  32. package/src/ui/headless/communities/paths.ts +36 -0
  33. package/src/ui/headless/communities/useCommunity.ts +140 -0
  34. package/src/ui/headless/communities/useCommunityAbout.ts +71 -0
  35. package/src/ui/headless/communities/useCommunityHome.ts +88 -0
  36. package/src/ui/headless/communities/useCommunityInvite.ts +116 -0
  37. package/src/ui/headless/communities/useCommunityJoin.ts +444 -0
  38. package/src/ui/headless/communities/useCommunityMembership.ts +136 -0
  39. package/src/ui/headless/communities/useCommunityUpgrade.ts +213 -0
  40. package/src/ui/headless/communities/useMyMemberships.ts +59 -0
  41. package/src/ui/headless/community/useCommunityAttachmentUpload.ts +7 -3
  42. package/src/ui/headless/index.ts +8 -4
  43. package/src/ui/headless/memberHome/index.ts +1 -1
  44. package/src/ui/headless/memberHome/useMemberHomeNav.ts +44 -42
  45. package/src/ui/headless/membership/_tests/membershipCheckoutRefetch.spec.tsx +8 -0
  46. package/src/ui/headless/membership/_tests/useMembershipCheckoutApplication.spec.tsx +5 -4
  47. package/src/ui/headless/membership/_tests/useMembershipCheckoutQuestionnaire.spec.tsx +1 -1
  48. package/src/ui/headless/membership/_tests/useMembershipCheckoutTrial.spec.tsx +1 -1
  49. package/src/ui/headless/membership/useMembershipCheckout.ts +89 -22
  50. package/src/ui/headless/membership/useMembershipGateNotice.ts +25 -7
  51. package/src/ui/headless/posts/upsell.ts +54 -8
  52. package/src/ui/headless/posts/useMemberPostsFeed.ts +6 -5
  53. package/src/ui/headless/posts/usePostCollectionDetail.ts +7 -4
  54. package/src/ui/headless/posts/usePostCollectionsList.ts +6 -5
  55. package/src/ui/headless/posts/usePostItem.ts +6 -5
  56. package/src/ui/index.ts +17 -12
  57. package/src/ui/styled/AccountDashboard.tsx +48 -256
  58. package/src/ui/styled/MembershipCheckout.tsx +37 -17
  59. package/src/ui/styled/MembershipTierCallout.tsx +20 -1
  60. package/src/ui/styled/MembershipTiers.tsx +49 -11
  61. package/src/ui/styled/Paywall.tsx +1 -1
  62. package/src/ui/styled/_tests/AccountDashboardBookingCall.spec.tsx +1 -1
  63. package/src/ui/styled/_tests/AccountDashboardMemberships.spec.tsx +151 -0
  64. package/src/ui/styled/_tests/AccountDashboardRentals.spec.tsx +1 -1
  65. package/src/ui/styled/_tests/CourseCallsSection.spec.tsx +7 -1
  66. package/src/ui/styled/_tests/MembershipCheckout.spec.tsx +54 -11
  67. package/src/ui/styled/_tests/membershipTierCallout.spec.tsx +1 -1
  68. package/src/ui/styled/_tests/membershipTiersCuratedAccess.spec.tsx +1 -1
  69. package/src/ui/styled/communities/CommunitiesList.tsx +88 -0
  70. package/src/ui/styled/communities/CommunityAbout.tsx +140 -0
  71. package/src/ui/styled/communities/CommunityHome.tsx +175 -0
  72. package/src/ui/styled/communities/CommunityJoinSheet.tsx +444 -0
  73. package/src/ui/styled/communities/CommunityMembershipMenu.tsx +133 -0
  74. package/src/ui/styled/communities/CommunityPending.tsx +49 -0
  75. package/src/ui/styled/communities/CommunityUpgradeSheet.tsx +137 -0
  76. package/src/ui/styled/communities/MyMembershipsList.tsx +89 -0
  77. package/src/ui/styled/communities/_tests/CommunityPages.spec.tsx +102 -0
  78. package/src/ui/styled/communities/index.ts +10 -0
  79. package/src/ui/styled/communities/shared.tsx +149 -0
  80. package/src/ui/styled/community/_tests/CommunityComposer.spec.tsx +4 -1
  81. package/src/ui/styled/community/_tests/CommunityPostDetail.spec.tsx +4 -1
  82. package/src/ui/styled/forge-utilities.css +19 -0
  83. package/src/ui/styled/members/CallTranscriptViewer.tsx +424 -0
  84. package/src/ui/styled/members/MemberCallsCalendar.tsx +142 -10
  85. package/src/ui/styled/members/MemberHomeLayout.tsx +35 -17
  86. package/src/ui/styled/members/MemberPostCard.tsx +9 -5
  87. package/src/ui/styled/members/PostCollectionDetail.tsx +18 -10
  88. package/src/ui/styled/members/PostCollectionsGrid.tsx +24 -10
  89. package/src/ui/styled/members/_tests/MemberCallsCalendar.spec.tsx +167 -8
  90. package/src/ui/styled/members/callTranscriptText.ts +67 -0
  91. package/src/utils/_tests/safeRedirect.spec.ts +2 -2
  92. package/src/ui/headless/membership/MembershipGate.tsx +0 -44
  93. package/src/ui/styled/_tests/AccountDashboardCommunity.spec.tsx +0 -149
  94. package/src/ui/styled/_tests/AccountDashboardTrial.spec.tsx +0 -105
@@ -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,205 @@
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
+ }
146
+
147
+ /**
148
+ * A stateful auth context: `register` succeeds and asks for a code,
149
+ * `verifyRegistration` signs the fan in, `validateLoginCredentials` +
150
+ * `verifyLoginCode` do the same for sign-in, `logout` signs out.
151
+ */
152
+ function TestAuth({ initialUser, children, controls }: { initialUser: unknown; children: ReactNode; controls?: AuthControls }) {
153
+ const [user, setUser] = useState<ReturnType<typeof account> | null>(initialUser as ReturnType<typeof account> | null);
154
+ const value = {
155
+ user,
156
+ isAuthenticated: !!user,
157
+ isInitialized: true,
158
+ currencies: null,
159
+ userSelectedCurrency: "USD",
160
+ register: async () => ({}),
161
+ verifyRegistration: async (data: { email: string }) => {
162
+ setUser(account(data.email));
163
+ return {};
164
+ },
165
+ validateLoginCredentials: async () => ({}),
166
+ verifyLoginCode: async (email: string) => {
167
+ setUser(account(email));
168
+ return {};
169
+ },
170
+ resendLoginCode: async () => {},
171
+ logout: async () => {
172
+ controls?.logout();
173
+ setUser(null);
174
+ },
175
+ refetchUser: async () => {},
176
+ };
177
+ return <PublicAuthContext.Provider value={value as never}>{children}</PublicAuthContext.Provider>;
178
+ }
179
+
180
+ /** A `renderHook` wrapper: client, auth, and (unless `community: null`) a community provider. */
181
+ export function wrapper(opts: { user?: unknown; community?: PublicCommunity | null; controls?: AuthControls } = {}) {
182
+ const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
183
+ const community = opts.community === undefined ? COMMUNITY : opts.community;
184
+ return function Wrapper({ children }: { children: ReactNode }) {
185
+ return (
186
+ <QueryClientProvider client={client}>
187
+ <ForgeClientProvider apiUrl="http://api.test" profileId={PROFILE_ID}>
188
+ <TestAuth initialUser={opts.user ?? null} controls={opts.controls}>
189
+ {community ? (
190
+ <CommunityProvider community={community.slug} initialData={community}>
191
+ {children}
192
+ </CommunityProvider>
193
+ ) : (
194
+ children
195
+ )}
196
+ </TestAuth>
197
+ </ForgeClientProvider>
198
+ </QueryClientProvider>
199
+ );
200
+ };
201
+ }
202
+
203
+ /** Requests to `path` (exact) with `method`. */
204
+ export const calls = (method: string, path: string | RegExp) =>
205
+ sent.filter((req) => req.method === method && (typeof path === "string" ? req.url === path : path.test(req.url)));
@@ -0,0 +1,151 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { render, renderHook, waitFor } from "@testing-library/react";
4
+ import { useCommunitySpaces, useCommunityLeaderboard } from "../../../data/queries/useCommunity";
5
+ import { useChatChannels } from "../../../data/queries/useChat";
6
+ import { useGetPosts, useGetSavedPosts } from "../../../data/queries/usePosts";
7
+ import { useGetPostCollection, useGetPostCollections } from "../../../data/queries/usePostCollections";
8
+ import { useMyMembershipCalls } from "../../../data/queries/useMembershipCalls";
9
+ import type { ReactNode } from "react";
10
+ import { CommunityProvider, useCommunityContext } from "../../../contexts/CommunityContext";
11
+ import { useMemberHomeNav } from "../memberHome/useMemberHomeNav";
12
+ import { useCommunityJoin } from "../communities/useCommunityJoin";
13
+ import { COMMUNITY, OTHER_COMMUNITY, calls, detailRoute, get, installAdapter, restoreAdapter, wrapper } from "./communityHarness";
14
+
15
+ /**
16
+ * `<CommunityProvider>` is how every member-content hook learns WHICH community
17
+ * it is reading. A profile runs several, each fully isolated, so a hook outside
18
+ * a provider must refuse (a developer error naming the provider) rather than
19
+ * fetch "the profile's" content, and inside one it must send the community and
20
+ * key its cache by it.
21
+ */
22
+
23
+ afterEach(restoreAdapter);
24
+
25
+ const PROVIDER_ERROR = /must be used inside <CommunityProvider/;
26
+
27
+ describe("outside a provider, every member-content hook refuses", () => {
28
+ const cases: Array<[string, () => unknown]> = [
29
+ ["useCommunitySpaces", () => useCommunitySpaces()],
30
+ ["useCommunityLeaderboard", () => useCommunityLeaderboard()],
31
+ ["useChatChannels", () => useChatChannels()],
32
+ ["useGetPosts", () => useGetPosts()],
33
+ ["useGetSavedPosts", () => useGetSavedPosts()],
34
+ ["useGetPostCollections", () => useGetPostCollections()],
35
+ ["useMyMembershipCalls", () => useMyMembershipCalls()],
36
+ ["useMemberHomeNav", () => useMemberHomeNav()],
37
+ ["useCommunityJoin", () => useCommunityJoin()],
38
+ ["useCommunityContext", () => useCommunityContext()],
39
+ ];
40
+ for (const [name, hook] of cases) {
41
+ it(`${name} throws a developer error that names the provider and the hook`, () => {
42
+ installAdapter([]);
43
+ expect(() => renderHook(hook, { wrapper: wrapper({ community: null }) })).toThrow(PROVIDER_ERROR);
44
+ expect(() => renderHook(hook, { wrapper: wrapper({ community: null }) })).toThrow(name);
45
+ // Nothing was fetched on the way to the error.
46
+ expect(calls("get", /\/public\/(community|chat|posts|post-collections|scheduled-calls)/)).toHaveLength(0);
47
+ });
48
+ }
49
+
50
+ it("a course-scoped call read needs no provider: a course belongs to no community", async () => {
51
+ installAdapter([get("/public/scheduled-calls/mine", () => [])]);
52
+ const { result } = renderHook(() => useMyMembershipCalls({ courseId: "course-1" }), { wrapper: wrapper({ community: null }) });
53
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
54
+ const [req] = calls("get", "/public/scheduled-calls/mine");
55
+ expect(req.params).toMatchObject({ courseId: "course-1" });
56
+ expect(req.params?.communityId).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe("inside a provider, the community is sent and keys the cache", () => {
61
+ it("sends communityId with every member-content read", async () => {
62
+ installAdapter([
63
+ get("/public/community/spaces", () => []),
64
+ get("/public/chat/channels", () => []),
65
+ get("/public/posts", () => ({ data: [], total: 0, page: 1, pageSize: 10, hasNextPage: false })),
66
+ get("/public/post-collections", () => ({ data: [], total: 0, page: 1, pageSize: 10 })),
67
+ get("/public/scheduled-calls/mine", () => []),
68
+ ]);
69
+ const { result } = renderHook(
70
+ () => ({
71
+ spaces: useCommunitySpaces(),
72
+ channels: useChatChannels(),
73
+ posts: useGetPosts(),
74
+ collections: useGetPostCollections(),
75
+ calls: useMyMembershipCalls(),
76
+ }),
77
+ { wrapper: wrapper() },
78
+ );
79
+ await waitFor(() => expect(Object.values(result.current).every((q) => q.isSuccess)).toBe(true));
80
+ for (const path of ["/public/community/spaces", "/public/chat/channels", "/public/posts", "/public/post-collections", "/public/scheduled-calls/mine"]) {
81
+ const [req] = calls("get", path);
82
+ expect(req?.params, path).toMatchObject({ profileId: "profile-1", communityId: COMMUNITY.id });
83
+ }
84
+ });
85
+
86
+ it("two communities on one page (one query cache) never share a cache entry", async () => {
87
+ installAdapter([
88
+ get("/public/community/spaces", (req) => [
89
+ { id: `space-of-${String(req.params?.communityId)}`, name: "Space", tiers: [] },
90
+ ]),
91
+ ]);
92
+ const seen: Record<string, string | undefined> = {};
93
+ function Spaces({ label }: { label: string }) {
94
+ seen[label] = useCommunitySpaces().data?.[0]?.id;
95
+ return null;
96
+ }
97
+ const Wrapper = wrapper({ community: null });
98
+ render(
99
+ <Wrapper>
100
+ <CommunityProvider community={COMMUNITY.slug} initialData={COMMUNITY}>
101
+ <Spaces label="a" />
102
+ </CommunityProvider>
103
+ <CommunityProvider community={OTHER_COMMUNITY.slug} initialData={OTHER_COMMUNITY}>
104
+ <Spaces label="b" />
105
+ </CommunityProvider>
106
+ </Wrapper>,
107
+ );
108
+ await waitFor(() => expect(seen.a && seen.b).toBeTruthy());
109
+ expect(seen.a).toBe(`space-of-${COMMUNITY.id}`);
110
+ expect(seen.b).toBe(`space-of-${OTHER_COMMUNITY.id}`);
111
+ expect(calls("get", "/public/community/spaces")).toHaveLength(2);
112
+ });
113
+
114
+ it("resolves a slug to the community's id before any member read goes out", async () => {
115
+ installAdapter([detailRoute(() => ({})), get("/public/community/spaces", () => [])]);
116
+ const Wrapper = wrapper({ community: null });
117
+ const { result } = renderHook(
118
+ () => {
119
+ const context = useCommunityContext();
120
+ const spaces = useCommunitySpaces();
121
+ return { context, spaces };
122
+ },
123
+ {
124
+ wrapper: ({ children }) => (
125
+ <Wrapper>
126
+ <ProviderBySlug>{children}</ProviderBySlug>
127
+ </Wrapper>
128
+ ),
129
+ },
130
+ );
131
+ expect(result.current.context.slug).toBe("fan-club");
132
+ await waitFor(() => expect(result.current.context.communityId).toBe(COMMUNITY.id));
133
+ await waitFor(() => expect(result.current.spaces.isSuccess).toBe(true));
134
+ expect(calls("get", "/public/community/spaces")[0].params).toMatchObject({ communityId: COMMUNITY.id });
135
+ });
136
+
137
+ it("a collection that is not in this community reads as not found, not as a crash", async () => {
138
+ installAdapter([
139
+ get(/^\/public\/post-collections\//, () => {
140
+ throw { status: 404 };
141
+ }),
142
+ ]);
143
+ const { result } = renderHook(() => useGetPostCollection("col-elsewhere"), { wrapper: wrapper() });
144
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
145
+ expect(result.current.data).toBeNull();
146
+ });
147
+ });
148
+
149
+ function ProviderBySlug({ children }: { children: ReactNode }) {
150
+ return <CommunityProvider community="fan-club">{children}</CommunityProvider>;
151
+ }
@@ -0,0 +1,104 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+ import { renderHook, waitFor } from "@testing-library/react";
4
+ import { describeCommunityInvite, useCommunityInvite } from "../communities/useCommunityInvite";
5
+ import type { CommunityInviteState } from "../../../data/queries/useCommunities";
6
+ import { account, calls, get, installAdapter, restoreAdapter, tier, wrapper } from "./communityHarness";
7
+
8
+ /**
9
+ * What an invite is worth to THIS viewer: every state the server can answer,
10
+ * the mismatch only the client can see (signed in on this page as someone
11
+ * else), and which token is forwarded (only a usable one).
12
+ */
13
+
14
+ afterEach(restoreAdapter);
15
+
16
+ const vip = tier({ id: "tier-vip", joinMode: "invite" });
17
+ const answer = (patch: Partial<CommunityInviteState>): CommunityInviteState => ({
18
+ kind: "email",
19
+ state: "valid",
20
+ invitedEmail: "invited@example.com",
21
+ tier: vip,
22
+ ...patch,
23
+ });
24
+
25
+ describe("describeCommunityInvite", () => {
26
+ it("no token: nothing to say and nothing to forward", () => {
27
+ expect(describeCommunityInvite({ tokens: {}, answer: undefined })).toMatchObject({
28
+ kind: null,
29
+ state: null,
30
+ isValid: false,
31
+ problem: null,
32
+ forward: {},
33
+ });
34
+ });
35
+
36
+ it("a valid per-email invite forwards the invite token and names its tier and email", () => {
37
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({}), signedInEmail: null })).toMatchObject({
38
+ kind: "email",
39
+ state: "valid",
40
+ isValid: true,
41
+ emailMismatch: false,
42
+ problem: null,
43
+ invitedEmail: "invited@example.com",
44
+ tier: vip,
45
+ forward: { inviteToken: "tok" },
46
+ });
47
+ });
48
+
49
+ it("signed in as another address (case-insensitively) is a mismatch, still forwarded so signing in as the invitee works", () => {
50
+ const summary = describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({}), signedInEmail: "Other@Example.com" });
51
+ expect(summary).toMatchObject({ state: "email_mismatch", isValid: false, emailMismatch: true, forward: { inviteToken: "tok" } });
52
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({}), signedInEmail: "INVITED@example.com" }).isValid).toBe(true);
53
+ });
54
+
55
+ it("the server's own email_mismatch is kept", () => {
56
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({ state: "email_mismatch" }) }).emailMismatch).toBe(true);
57
+ });
58
+
59
+ it.each(["expired", "revoked", "used", "used_up", "not_found"] as const)("%s is a problem, with no tier and no token forwarded", (state) => {
60
+ expect(describeCommunityInvite({ tokens: { invite: "tok" }, answer: answer({ state, tier: vip }) })).toMatchObject({
61
+ state,
62
+ isValid: false,
63
+ problem: state,
64
+ tier: null,
65
+ forward: {},
66
+ });
67
+ });
68
+
69
+ it("a shareable link forwards the join token and never mismatches on email", () => {
70
+ const summary = describeCommunityInvite({
71
+ tokens: { join: "link" },
72
+ answer: answer({ kind: "link", invitedEmail: null }),
73
+ signedInEmail: "anyone@example.com",
74
+ });
75
+ expect(summary).toMatchObject({ kind: "link", isValid: true, emailMismatch: false, invitedEmail: null, forward: { joinToken: "link" } });
76
+ });
77
+ });
78
+
79
+ describe("useCommunityInvite", () => {
80
+ it("reads the invite state for the provider's community with the token", async () => {
81
+ installAdapter([get("/public/communities/fan-club/invite", () => answer({}))]);
82
+ const { result } = renderHook(() => useCommunityInvite({ invite: "tok" }), { wrapper: wrapper({ user: account("invited@example.com") }) });
83
+ await waitFor(() => expect(result.current.isValid).toBe(true));
84
+ expect(calls("get", "/public/communities/fan-club/invite")[0].params).toMatchObject({ profileId: "profile-1", invite: "tok" });
85
+ });
86
+
87
+ it("a token the server cannot resolve at all (404) reads as not found, never as valid", async () => {
88
+ installAdapter([
89
+ get("/public/communities/fan-club/invite", () => {
90
+ throw { status: 404 };
91
+ }),
92
+ ]);
93
+ const { result } = renderHook(() => useCommunityInvite({ join: "gone" }), { wrapper: wrapper() });
94
+ await waitFor(() => expect(result.current.problem).toBe("not_found"));
95
+ expect(result.current.forward).toEqual({});
96
+ });
97
+
98
+ it("sends nothing without a token", async () => {
99
+ installAdapter([]);
100
+ const { result } = renderHook(() => useCommunityInvite({}), { wrapper: wrapper() });
101
+ expect(result.current.kind).toBeNull();
102
+ expect(calls("get", /\/invite$/)).toHaveLength(0);
103
+ });
104
+ });