Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions src/hooks/communities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,136 @@ describe("communities", () => {
expect(rendered.result.current.hourActiveUserCount).toBe(1);
});

test("useCommunityStats unwraps fetchCid content responses", async () => {
let fetchSpy: { mockRestore: () => void } | undefined;
try {
fetchSpy = vi.spyOn(PKC.prototype as any, "fetchCid").mockResolvedValue({
content: {
hourActiveUserCount: 3,
weekActiveUserCount: 33,
allPostCount: 333,
},
});
const rendered = renderHook<any, any>(() =>
useCommunityStats({ community: { name: "wrapped stats" } }),
);
const waitFor = testUtils.createWaitFor(rendered);
await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.hourActiveUserCount).toBe(3);
expect(rendered.result.current.weekActiveUserCount).toBe(33);
expect(rendered.result.current.allPostCount).toBe(333);
} finally {
fetchSpy?.mockRestore();
}
});

test("useCommunityStats parses string fetchCid content responses", async () => {
let fetchSpy: { mockRestore: () => void } | undefined;
try {
fetchSpy = vi.spyOn(PKC.prototype as any, "fetchCid").mockResolvedValue({
content: JSON.stringify({
hourActiveUserCount: 4,
weekActiveUserCount: 44,
allPostCount: 444,
}),
});
const rendered = renderHook<any, any>(() =>
useCommunityStats({ community: { name: "string wrapped stats" } }),
);
const waitFor = testUtils.createWaitFor(rendered);
await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.hourActiveUserCount).toBe(4);
expect(rendered.result.current.weekActiveUserCount).toBe(44);
expect(rendered.result.current.allPostCount).toBe(444);
} finally {
fetchSpy?.mockRestore();
}
});

test("useCommunityStats parses string fetchCid responses", async () => {
let fetchSpy: { mockRestore: () => void } | undefined;
try {
fetchSpy = vi.spyOn(PKC.prototype as any, "fetchCid").mockResolvedValue(
JSON.stringify({
hourActiveUserCount: 5,
weekActiveUserCount: 55,
allPostCount: 555,
}),
);
const rendered = renderHook<any, any>(() =>
useCommunityStats({ community: { name: "string stats" } }),
);
const waitFor = testUtils.createWaitFor(rendered);
await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.hourActiveUserCount).toBe(5);
expect(rendered.result.current.weekActiveUserCount).toBe(55);
expect(rendered.result.current.allPostCount).toBe(555);
} finally {
fetchSpy?.mockRestore();
}
});

test("useCommunityStats accepts object fetchCid responses", async () => {
let fetchSpy: { mockRestore: () => void } | undefined;
try {
fetchSpy = vi.spyOn(PKC.prototype as any, "fetchCid").mockResolvedValue({
hourActiveUserCount: 6,
weekActiveUserCount: 66,
allPostCount: 666,
});
const rendered = renderHook<any, any>(() =>
useCommunityStats({ community: { name: "object stats" } }),
);
const waitFor = testUtils.createWaitFor(rendered);
await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.hourActiveUserCount).toBe(6);
expect(rendered.result.current.weekActiveUserCount).toBe(66);
expect(rendered.result.current.allPostCount).toBe(666);
} finally {
fetchSpy?.mockRestore();
}
});

test("useCommunityStats keeps direct stats responses with content fields", async () => {
let fetchSpy: { mockRestore: () => void } | undefined;
try {
fetchSpy = vi.spyOn(PKC.prototype as any, "fetchCid").mockResolvedValue({
content: "not json stats content",
hourActiveUserCount: 5,
weekActiveUserCount: 55,
allPostCount: 555,
});
const rendered = renderHook<any, any>(() =>
useCommunityStats({ community: { name: "stats with content" } }),
);
const waitFor = testUtils.createWaitFor(rendered);
await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.hourActiveUserCount).toBe(5);
expect(rendered.result.current.weekActiveUserCount).toBe(55);
expect(rendered.result.current.allPostCount).toBe(555);
expect(rendered.result.current.content).toBe("not json stats content");
} finally {
fetchSpy?.mockRestore();
}
});

test("useCommunityStats keeps unrecognized content responses", async () => {
let fetchSpy: { mockRestore: () => void } | undefined;
try {
fetchSpy = vi.spyOn(PKC.prototype as any, "fetchCid").mockResolvedValue({
content: "not json stats content",
});
const rendered = renderHook<any, any>(() =>
useCommunityStats({ community: { name: "unknown content stats" } }),
);
const waitFor = testUtils.createWaitFor(rendered);
await waitFor(() => rendered.result.current.state === "succeeded");
expect(rendered.result.current.content).toBe("not json stats content");
} finally {
fetchSpy?.mockRestore();
}
});

test("useCommunityStats fetchCid error logs (stmt 110)", async () => {
const origFetch = PKC.prototype.fetchCid;
(PKC.prototype as any).fetchCid = () => Promise.reject(new Error("fetchCid failed"));
Expand Down
33 changes: 32 additions & 1 deletion src/hooks/communities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,37 @@ import shallow from "zustand/shallow";
import { getChainProviders, getPkcCommunityAddresses } from "../lib/pkc-compat";
import { getCommunityRefKey, getUniqueSortedCommunityRefs } from "../lib/community-ref";

const parseMaybeJson = (value: unknown) => (typeof value === "string" ? JSON.parse(value) : value);

const tryParseMaybeJson = (value: unknown) => {
try {
return parseMaybeJson(value);
} catch {
return value;
}
};

const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object";

const isCommunityStatsPayload = (value: unknown): value is CommunityStats =>
isRecord(value) &&
("hourActiveUserCount" in value || "weekActiveUserCount" in value || "allPostCount" in value);

const parseFetchedCommunityStats = (fetchedCid: unknown): CommunityStats => {
const parsedCid = parseMaybeJson(fetchedCid);
if (isCommunityStatsPayload(parsedCid)) {
return parsedCid;
}
if (isRecord(parsedCid) && "content" in parsedCid) {
const parsedContent = tryParseMaybeJson(parsedCid.content);
if (isCommunityStatsPayload(parsedContent)) {
return parsedContent;
}
}
return parsedCid as CommunityStats;
};
Comment on lines +46 to +58
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fallback silently stores non-stats payloads as CommunityStats.

When neither the top-level payload nor parsedCid.content matches isCommunityStatsPayload, line 57 returns parsedCid as CommunityStats and the hook transitions to state === "succeeded" with a value that doesn't actually have any of the expected stat fields. Consumers reading hourActiveUserCount/weekActiveUserCount/allPostCount will silently see undefined instead of surfacing a fetch error or staying in a fetching state.

Consider either throwing (so the outer catch routes to setFetchError / state: "failed") or at least logging a warning when the shape is unrecognized:

Proposed change
-  return parsedCid as CommunityStats;
+  log.error?.("parseFetchedCommunityStats: unrecognized payload shape", { fetchedCid });
+  throw new Error("useCommunityStats received unrecognized stats payload shape");
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/communities.ts` around lines 46 - 58, The current
parseFetchedCommunityStats function can return a value that doesn't match
CommunityStats, causing silent undefined fields; update
parseFetchedCommunityStats (and its uses of parseMaybeJson, tryParseMaybeJson,
isCommunityStatsPayload) to detect the unrecognized shape and instead surface an
error: throw a descriptive Error (including the raw parsed payload/context) so
the outer fetch catch sets state to "failed" and triggers setFetchError;
alternatively, if you prefer not to throw, emit a process/local logger.warn with
the payload and return a safe default, but the preferred fix is to throw to
avoid transitioning to "succeeded" with invalid data.


/**
* @param community - The community identifier, e.g. {name: 'memes.eth'} or {publicKey: '12D3KooW...'}
* @param acountName - The nickname of the account, e.g. 'Account 1'. If no accountName is provided, use
Expand Down Expand Up @@ -183,7 +214,7 @@ export function useCommunityStats(options?: UseCommunityStatsOptions): UseCommun
let fetchedCid;
try {
fetchedCid = await account.pkc.fetchCid({ cid: communityStatsCid });
fetchedCid = JSON.parse(fetchedCid);
fetchedCid = parseFetchedCommunityStats(fetchedCid);
if (cancelled) {
return;
}
Expand Down
Loading