useCompleteOnboarding.spec.js
3 weeks ago
useDateRangePicker.spec.js
1 month ago
useEmail.spec.js
1 month ago
useEngagementChartData.spec.js
1 month ago
useLicenseSettings.spec.js
1 month ago
useLink.spec.js
1 month ago
useMediaDetail.spec.js
1 month ago
useMediaLibrary.spec.js
1 month ago
useMediaList.spec.js
1 month ago
usePerformanceSettings.spec.js
1 month ago
useRegisterActivePage.spec.js
1 month ago
useSettingOption.spec.js
1 month ago
useSimpleSettingsPage.spec.js
1 month ago
useTopPerforming.spec.js
1 month ago
useTopVideosPaginated.spec.js
1 month ago
useUpgradeCTA.spec.js
1 month ago
useUserDetail.spec.js
1 month ago
useUserDetail.spec.js
281 lines
| 1 | import { renderHook, act } from "@testing-library/react-hooks"; |
| 2 | import apiFetch from "@wordpress/api-fetch"; |
| 3 | import useTopVideosPaginated from "../useTopVideosPaginated"; |
| 4 | import useUserDetail from "../useUserDetail"; |
| 5 | |
| 6 | jest.mock("@wordpress/api-fetch"); |
| 7 | jest.mock("../useTopVideosPaginated", () => ({ |
| 8 | __esModule: true, |
| 9 | default: jest.fn(), |
| 10 | })); |
| 11 | |
| 12 | const mediaStub = (overrides = {}) => ({ |
| 13 | topMedia: [], |
| 14 | page: 1, |
| 15 | setPage: jest.fn(), |
| 16 | pagination: { totalItems: 0, totalPages: 0 }, |
| 17 | perPage: 10, |
| 18 | isLoading: false, |
| 19 | error: null, |
| 20 | ...overrides, |
| 21 | }); |
| 22 | |
| 23 | // Drains pending React state updates so trailing setStats/setIsLoading |
| 24 | // don't fire after the test completes (which would trip @wordpress/jest- |
| 25 | // console's "console.error not expected" guard via React's act warning). |
| 26 | const settle = () => act(async () => {}); |
| 27 | |
| 28 | // Freeze "now" so the default-range math is deterministic — and in |
| 29 | // particular doesn't flake when the suite happens to straddle local |
| 30 | // midnight while the hook and the test each call `new Date()`. |
| 31 | beforeAll(() => { |
| 32 | jest.useFakeTimers().setSystemTime(new Date("2026-05-10T16:33:22")); |
| 33 | }); |
| 34 | |
| 35 | afterAll(() => { |
| 36 | jest.useRealTimers(); |
| 37 | }); |
| 38 | |
| 39 | beforeEach(() => { |
| 40 | apiFetch.mockReset(); |
| 41 | useTopVideosPaginated.mockReset(); |
| 42 | useTopVideosPaginated.mockReturnValue(mediaStub()); |
| 43 | }); |
| 44 | |
| 45 | describe("useUserDetail", () => { |
| 46 | it("does not fetch when userId is falsy", async () => { |
| 47 | const { result } = renderHook(() => useUserDetail(null)); |
| 48 | await settle(); |
| 49 | expect(apiFetch).not.toHaveBeenCalled(); |
| 50 | expect(result.current.user).toBeNull(); |
| 51 | }); |
| 52 | |
| 53 | it("populates user metadata from /wp/v2/users + parallel analytics", async () => { |
| 54 | apiFetch |
| 55 | .mockResolvedValueOnce({ |
| 56 | id: 7, |
| 57 | name: "Ada Lovelace", |
| 58 | email: "ada@example.com", |
| 59 | avatar_urls: { 48: "small.png", 96: "big.png" }, |
| 60 | }) |
| 61 | .mockResolvedValueOnce({ view: "12" }) |
| 62 | .mockResolvedValueOnce({ view: "30" }) |
| 63 | .mockResolvedValueOnce({ view: "180" }); |
| 64 | |
| 65 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(7)); |
| 66 | await waitForNextUpdate(); |
| 67 | await settle(); |
| 68 | |
| 69 | expect(result.current.user).toMatchObject({ |
| 70 | id: 7, |
| 71 | name: "Ada Lovelace", |
| 72 | email: "ada@example.com", |
| 73 | avatarUrl: "big.png", |
| 74 | }); |
| 75 | expect(result.current.user.profileUrl).toContain("user_id=7"); |
| 76 | |
| 77 | expect(result.current.stats).toEqual({ |
| 78 | totalViews: 12, |
| 79 | avgWatchTime: "30s", |
| 80 | totalWatchTime: "3m", |
| 81 | }); |
| 82 | }); |
| 83 | |
| 84 | it("falls back to a generated name when the user has none", async () => { |
| 85 | apiFetch |
| 86 | .mockResolvedValueOnce({ id: 99, name: "" }) |
| 87 | .mockResolvedValueOnce({ view: "0" }) |
| 88 | .mockResolvedValueOnce({ view: "0" }) |
| 89 | .mockResolvedValueOnce({ view: "0" }); |
| 90 | |
| 91 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(99)); |
| 92 | await waitForNextUpdate(); |
| 93 | await settle(); |
| 94 | |
| 95 | expect(result.current.user.name).toBe("User #99"); |
| 96 | }); |
| 97 | |
| 98 | it("uses the smaller avatar size when 96 isn't available", async () => { |
| 99 | apiFetch |
| 100 | .mockResolvedValueOnce({ |
| 101 | id: 1, |
| 102 | name: "x", |
| 103 | avatar_urls: { 48: "small.png" }, |
| 104 | }) |
| 105 | .mockResolvedValueOnce({ view: "0" }) |
| 106 | .mockResolvedValueOnce({ view: "0" }) |
| 107 | .mockResolvedValueOnce({ view: "0" }); |
| 108 | |
| 109 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(1)); |
| 110 | await waitForNextUpdate(); |
| 111 | await settle(); |
| 112 | expect(result.current.user.avatarUrl).toBe("small.png"); |
| 113 | }); |
| 114 | |
| 115 | it("forces avg/total watch time to '0s' when totalViews is 0", async () => { |
| 116 | apiFetch |
| 117 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 118 | .mockResolvedValueOnce({ view: "0" }) |
| 119 | .mockResolvedValueOnce({ view: "100" }) |
| 120 | .mockResolvedValueOnce({ view: "200" }); |
| 121 | |
| 122 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(1)); |
| 123 | await waitForNextUpdate(); |
| 124 | await settle(); |
| 125 | |
| 126 | expect(result.current.stats).toEqual({ |
| 127 | totalViews: 0, |
| 128 | avgWatchTime: "0s", |
| 129 | totalWatchTime: "0s", |
| 130 | }); |
| 131 | }); |
| 132 | |
| 133 | it("falls back to zero stats with a warn when analytics endpoints fail", async () => { |
| 134 | const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {}); |
| 135 | |
| 136 | apiFetch |
| 137 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 138 | .mockRejectedValueOnce(new Error("403")); |
| 139 | |
| 140 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(1)); |
| 141 | await waitForNextUpdate(); |
| 142 | await settle(); |
| 143 | |
| 144 | expect(result.current.stats).toEqual({ |
| 145 | totalViews: 0, |
| 146 | avgWatchTime: "0s", |
| 147 | totalWatchTime: "0s", |
| 148 | }); |
| 149 | expect(warnSpy).toHaveBeenCalled(); |
| 150 | warnSpy.mockRestore(); |
| 151 | }); |
| 152 | |
| 153 | // Both rest_user_invalid_id (non-existent user) and rest_no_route (REST |
| 154 | // route disabled) collapse to the same user-facing error string. |
| 155 | it.each([["rest_user_invalid_id"], ["rest_no_route"]])( |
| 156 | "maps %s to a 'User not found' error", |
| 157 | async (code) => { |
| 158 | const errorSpy = jest |
| 159 | .spyOn(console, "error") |
| 160 | .mockImplementation(() => {}); |
| 161 | |
| 162 | const err = new Error("nope"); |
| 163 | err.code = code; |
| 164 | apiFetch.mockRejectedValueOnce(err); |
| 165 | |
| 166 | const { result, waitForNextUpdate } = renderHook(() => |
| 167 | useUserDetail(1) |
| 168 | ); |
| 169 | await waitForNextUpdate(); |
| 170 | await settle(); |
| 171 | |
| 172 | expect(result.current.error).toBe("User not found"); |
| 173 | errorSpy.mockRestore(); |
| 174 | } |
| 175 | ); |
| 176 | |
| 177 | it("surfaces other errors verbatim", async () => { |
| 178 | const errorSpy = jest |
| 179 | .spyOn(console, "error") |
| 180 | .mockImplementation(() => {}); |
| 181 | |
| 182 | apiFetch.mockRejectedValueOnce(new Error("server down")); |
| 183 | |
| 184 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(1)); |
| 185 | await waitForNextUpdate(); |
| 186 | await settle(); |
| 187 | |
| 188 | expect(result.current.error).toBe("server down"); |
| 189 | errorSpy.mockRestore(); |
| 190 | }); |
| 191 | |
| 192 | it("refetch(from, to) re-runs analytics with the new date range", async () => { |
| 193 | apiFetch |
| 194 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 195 | .mockResolvedValueOnce({ view: "0" }) |
| 196 | .mockResolvedValueOnce({ view: "0" }) |
| 197 | .mockResolvedValueOnce({ view: "0" }); |
| 198 | |
| 199 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(1)); |
| 200 | await waitForNextUpdate(); |
| 201 | await settle(); |
| 202 | |
| 203 | apiFetch.mockReset(); |
| 204 | apiFetch |
| 205 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 206 | .mockResolvedValueOnce({ view: "0" }) |
| 207 | .mockResolvedValueOnce({ view: "0" }) |
| 208 | .mockResolvedValueOnce({ view: "0" }); |
| 209 | |
| 210 | await act(async () => { |
| 211 | result.current.refetch( |
| 212 | new Date(Date.UTC(2026, 0, 5)), |
| 213 | new Date(Date.UTC(2026, 0, 10)) |
| 214 | ); |
| 215 | }); |
| 216 | await settle(); |
| 217 | |
| 218 | const analyticsPaths = apiFetch.mock.calls |
| 219 | .slice(1) |
| 220 | .map((c) => c[0].path); |
| 221 | expect(analyticsPaths.length).toBeGreaterThan(0); |
| 222 | expect(analyticsPaths.every((p) => p.includes("start=2026-01-05"))).toBe( |
| 223 | true |
| 224 | ); |
| 225 | expect(analyticsPaths.every((p) => p.includes("end=2026-01-10"))).toBe( |
| 226 | true |
| 227 | ); |
| 228 | }); |
| 229 | |
| 230 | it("refetch() with no args restores the default analytics range", async () => { |
| 231 | // First mount with the default analytics range. |
| 232 | apiFetch |
| 233 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 234 | .mockResolvedValueOnce({ view: "0" }) |
| 235 | .mockResolvedValueOnce({ view: "0" }) |
| 236 | .mockResolvedValueOnce({ view: "0" }); |
| 237 | |
| 238 | const { result, waitForNextUpdate } = renderHook(() => useUserDetail(1)); |
| 239 | await waitForNextUpdate(); |
| 240 | await settle(); |
| 241 | |
| 242 | // Switch to a custom range so a follow-up refetch() actually changes |
| 243 | // dateRange (going from null → null wouldn't trigger a re-fetch). |
| 244 | apiFetch.mockReset(); |
| 245 | apiFetch |
| 246 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 247 | .mockResolvedValueOnce({ view: "0" }) |
| 248 | .mockResolvedValueOnce({ view: "0" }) |
| 249 | .mockResolvedValueOnce({ view: "0" }); |
| 250 | |
| 251 | await act(async () => { |
| 252 | result.current.refetch( |
| 253 | new Date(Date.UTC(2026, 0, 5)), |
| 254 | new Date(Date.UTC(2026, 0, 10)) |
| 255 | ); |
| 256 | }); |
| 257 | await settle(); |
| 258 | |
| 259 | apiFetch.mockReset(); |
| 260 | apiFetch |
| 261 | .mockResolvedValueOnce({ id: 1, name: "x" }) |
| 262 | .mockResolvedValueOnce({ view: "0" }) |
| 263 | .mockResolvedValueOnce({ view: "0" }) |
| 264 | .mockResolvedValueOnce({ view: "0" }); |
| 265 | |
| 266 | await act(async () => { |
| 267 | result.current.refetch(); |
| 268 | }); |
| 269 | await settle(); |
| 270 | |
| 271 | // After clearing back to the default (DEFAULT_ANALYTICS_DAYS = 90), |
| 272 | // the start param should be the window's lower bound — frozen "today" |
| 273 | // 2026-05-10 minus 89 days = 2026-02-10 — not the all-time 2020-01-01 |
| 274 | // sentinel. If DEFAULT_ANALYTICS_DAYS changes, recompute this date. |
| 275 | expect(apiFetch.mock.calls.length).toBeGreaterThanOrEqual(2); |
| 276 | const analyticsPath = apiFetch.mock.calls[1][0].path; |
| 277 | expect(analyticsPath).not.toContain("start=2020-01-01"); |
| 278 | expect(analyticsPath).toContain("start=2026-02-10"); |
| 279 | }); |
| 280 | }); |
| 281 |