Skip to content

Commit 8eca6cb

Browse files
committed
fix: favorites page, add tests
1 parent 9643f0a commit 8eca6cb

7 files changed

Lines changed: 542 additions & 1 deletion

File tree

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Tests for favorites server actions
5+
*
6+
* Regression test: Ensures getConvexClerkToken is called OUTSIDE unstable_cache
7+
* to avoid "headers() inside cache scope" runtime error.
8+
* See: https://nextjs.org/docs/app/api-reference/functions/unstable_cache
9+
*/
10+
import { beforeEach, describe, expect, it, vi } from "vitest"
11+
12+
// Mock the cache function
13+
const mockGetFavoritesPageCached = vi.fn()
14+
15+
vi.mock("@/app/_server/cache/favorites", () => ({
16+
getFavoritesPageCached: (...args: unknown[]) => mockGetFavoritesPageCached(...args),
17+
}))
18+
19+
// Mock the auth helper
20+
const mockRequireUserId = vi.fn()
21+
22+
vi.mock("../convex/client", () => ({
23+
requireUserId: () => mockRequireUserId(),
24+
}))
25+
26+
// Import after mocks
27+
import { loadFavoritesPage } from "./favorites"
28+
29+
describe("favorites server actions", () => {
30+
const mockPaginatedResult = {
31+
page: [
32+
{ _id: "img1", url: "https://example.com/1.jpg" },
33+
{ _id: "img2", url: "https://example.com/2.jpg" },
34+
],
35+
continueCursor: "cursor123",
36+
isDone: false,
37+
}
38+
39+
beforeEach(() => {
40+
vi.clearAllMocks()
41+
mockRequireUserId.mockResolvedValue("user_123")
42+
mockGetFavoritesPageCached.mockResolvedValue(mockPaginatedResult)
43+
})
44+
45+
describe("loadFavoritesPage", () => {
46+
it("calls getFavoritesPageCached with userId and cursor", async () => {
47+
const result = await loadFavoritesPage({ cursor: "abc123" })
48+
49+
expect(mockRequireUserId).toHaveBeenCalled()
50+
expect(mockGetFavoritesPageCached).toHaveBeenCalledWith("user_123", "abc123", undefined)
51+
expect(result).toEqual(mockPaginatedResult)
52+
})
53+
54+
it("calls getFavoritesPageCached with null cursor for first page", async () => {
55+
await loadFavoritesPage({ cursor: null })
56+
57+
expect(mockGetFavoritesPageCached).toHaveBeenCalledWith("user_123", null, undefined)
58+
})
59+
60+
it("passes numItems when provided", async () => {
61+
await loadFavoritesPage({ cursor: null, numItems: 50 })
62+
63+
expect(mockGetFavoritesPageCached).toHaveBeenCalledWith("user_123", null, 50)
64+
})
65+
66+
it("throws error when user is not authenticated", async () => {
67+
mockRequireUserId.mockRejectedValue(new Error("Authentication required"))
68+
69+
await expect(loadFavoritesPage({ cursor: null })).rejects.toThrow("Authentication required")
70+
expect(mockGetFavoritesPageCached).not.toHaveBeenCalled()
71+
})
72+
})
73+
})

app/_server/actions/feed.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,24 @@ describe("feed server actions", () => {
5959

6060
expect(mockGetPublicFeedPageCached).toHaveBeenCalledWith(null, 50, "blur")
6161
})
62+
63+
it("passes filterPreference 'block' when provided", async () => {
64+
await loadPublicFeedPage({ cursor: null, filterPreference: "block" })
65+
66+
expect(mockGetPublicFeedPageCached).toHaveBeenCalledWith(null, undefined, "block")
67+
})
68+
69+
it("passes filterPreference 'allow' when provided", async () => {
70+
await loadPublicFeedPage({ cursor: null, filterPreference: "allow" })
71+
72+
expect(mockGetPublicFeedPageCached).toHaveBeenCalledWith(null, undefined, "allow")
73+
})
74+
75+
it("passes all parameters together correctly", async () => {
76+
await loadPublicFeedPage({ cursor: "cursor_xyz", numItems: 25, filterPreference: "block" })
77+
78+
expect(mockGetPublicFeedPageCached).toHaveBeenCalledWith("cursor_xyz", 25, "block")
79+
})
6280
})
6381

6482
describe("loadFollowingFeedPage", () => {

app/_server/cache/config.test.ts

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
2+
import { describe, expect, it, vi } from "vitest"
3+
4+
// Mock server-only
5+
vi.mock("server-only", () => ({}))
6+
7+
import { CACHE_TAGS, CACHE_TTL, PAGE_SIZES } from "./config"
8+
9+
describe("cache config", () => {
10+
describe("CACHE_TTL", () => {
11+
it("defines TTLs for feed pages", () => {
12+
expect(CACHE_TTL.FEED_PUBLIC_FIRST_PAGE).toBe(60)
13+
expect(CACHE_TTL.FEED_PUBLIC_LATER_PAGES).toBe(21600) // 6 hours
14+
})
15+
16+
it("defines TTLs for history pages", () => {
17+
expect(CACHE_TTL.HISTORY_FIRST_PAGE).toBe(30)
18+
expect(CACHE_TTL.HISTORY_LATER_PAGES).toBe(43200) // 12 hours
19+
})
20+
})
21+
22+
describe("CACHE_TAGS", () => {
23+
it("defines static tags", () => {
24+
expect(CACHE_TAGS.FEED_PUBLIC).toBe("feed:public")
25+
})
26+
27+
it("generates correct user tags", () => {
28+
expect(CACHE_TAGS.HISTORY_USER("123")).toBe("history:user:123")
29+
expect(CACHE_TAGS.FAVORITES_USER("123")).toBe("favorites:user:123")
30+
expect(CACHE_TAGS.FEED_FOLLOWING_USER("123")).toBe("feed:following:123")
31+
})
32+
})
33+
34+
describe("PAGE_SIZES", () => {
35+
it("defines consistent page sizes", () => {
36+
expect(PAGE_SIZES.DEFAULT).toBe(20)
37+
expect(PAGE_SIZES.STUDIO_GALLERY).toBe(20)
38+
expect(PAGE_SIZES.HISTORY).toBe(20)
39+
expect(PAGE_SIZES.FAVORITES).toBe(20)
40+
expect(PAGE_SIZES.FEED).toBe(20)
41+
})
42+
})
43+
})
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* @vitest-environment node
3+
*
4+
* Tests for favorites cache functions
5+
*
6+
* REGRESSION TEST: This test suite specifically validates that getConvexClerkToken
7+
* is called OUTSIDE the unstable_cache callback to prevent the runtime error:
8+
* "Route used headers() inside a function cached with unstable_cache()"
9+
*
10+
* Root cause: Clerk's auth() uses headers() which is dynamic data.
11+
* Dynamic data cannot be accessed inside unstable_cache scope.
12+
*
13+
* Fix: Call getConvexClerkToken() BEFORE unstable_cache() and capture token in closure.
14+
*/
15+
import { beforeEach, describe, expect, it, vi } from "vitest"
16+
17+
// Mock server-only to allow testing
18+
vi.mock("server-only", () => ({}))
19+
20+
// Track when getConvexClerkToken and unstable_cache are called
21+
const callOrder: string[] = []
22+
23+
// Mock getConvexClerkToken to track call order
24+
const mockGetConvexClerkToken = vi.fn().mockImplementation(async () => {
25+
callOrder.push("getConvexClerkToken")
26+
return "mock-token"
27+
})
28+
29+
vi.mock("../convex/client", () => ({
30+
getConvexClerkToken: () => mockGetConvexClerkToken(),
31+
}))
32+
33+
// Mock fetchQuery
34+
const mockFetchQuery = vi.fn().mockResolvedValue({
35+
page: [],
36+
continueCursor: null,
37+
isDone: true,
38+
})
39+
40+
vi.mock("convex/nextjs", () => ({
41+
fetchQuery: (...args: unknown[]) => mockFetchQuery(...args),
42+
}))
43+
44+
// Mock unstable_cache to track when its callback is executed
45+
vi.mock("next/cache", () => ({
46+
unstable_cache: (fn: () => Promise<unknown>, _keys: string[], _opts: unknown) => {
47+
// Return a function that, when called, executes the cached function
48+
return async () => {
49+
callOrder.push("unstable_cache_callback_start")
50+
const result = await fn()
51+
callOrder.push("unstable_cache_callback_end")
52+
return result
53+
}
54+
},
55+
}))
56+
57+
// Mock API
58+
vi.mock("@/convex/_generated/api", () => ({
59+
api: {
60+
favorites: {
61+
list: "favorites:list",
62+
},
63+
},
64+
}))
65+
66+
// Mock config
67+
vi.mock("./config", () => ({
68+
CACHE_TTL: {
69+
FAVORITES_FIRST_PAGE: 30,
70+
FAVORITES_LATER_PAGES: 120,
71+
},
72+
CACHE_TAGS: {
73+
FAVORITES_USER: (userId: string) => `favorites:${userId}`,
74+
},
75+
PAGE_SIZES: {
76+
FAVORITES: 20,
77+
},
78+
}))
79+
80+
// Import after mocks
81+
import { getFavoritesPageCached } from "./favorites"
82+
83+
describe("favorites cache", () => {
84+
beforeEach(() => {
85+
vi.clearAllMocks()
86+
callOrder.length = 0 // Clear array
87+
})
88+
89+
describe("getFavoritesPageCached", () => {
90+
it("calls getConvexClerkToken BEFORE unstable_cache callback", async () => {
91+
await getFavoritesPageCached("user_123", null, 20)
92+
93+
// This is the critical assertion for the regression fix.
94+
// getConvexClerkToken MUST be called BEFORE the unstable_cache callback starts.
95+
// Otherwise, we get: "Route used headers() inside unstable_cache()"
96+
const tokenCallIndex = callOrder.indexOf("getConvexClerkToken")
97+
const cacheCallbackIndex = callOrder.indexOf("unstable_cache_callback_start")
98+
99+
expect(tokenCallIndex).toBeGreaterThan(-1)
100+
expect(cacheCallbackIndex).toBeGreaterThan(-1)
101+
expect(tokenCallIndex).toBeLessThan(cacheCallbackIndex)
102+
})
103+
104+
it("passes token to fetchQuery inside cache callback", async () => {
105+
await getFavoritesPageCached("user_123", null, 20)
106+
107+
expect(mockFetchQuery).toHaveBeenCalledWith(
108+
"favorites:list",
109+
{ paginationOpts: { numItems: 20, cursor: null } },
110+
{ token: "mock-token" }
111+
)
112+
})
113+
114+
it("uses correct cache configuration for first page", async () => {
115+
await getFavoritesPageCached("user_123", null, 20)
116+
117+
expect(mockFetchQuery).toHaveBeenCalled()
118+
})
119+
120+
it("uses correct cache configuration for later pages", async () => {
121+
await getFavoritesPageCached("user_123", "cursor_abc", 20)
122+
123+
expect(mockFetchQuery).toHaveBeenCalledWith(
124+
"favorites:list",
125+
{ paginationOpts: { numItems: 20, cursor: "cursor_abc" } },
126+
{ token: "mock-token" }
127+
)
128+
})
129+
})
130+
})

app/_server/cache/favorites.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,20 @@ type Cursor = string | null
1010
/**
1111
* Cached user favorites page query.
1212
* Cache is per-user, keyed by userId.
13+
*
14+
* Note: Token is captured in closure but NOT part of cache key.
15+
* userId provides stable cache key isolation.
1316
*/
1417
export async function getFavoritesPageCached(
1518
userId: string,
1619
cursor: Cursor,
1720
numItems: number = PAGE_SIZES.FAVORITES
1821
) {
22+
const token = await getConvexClerkToken()
1923
const isFirstPage = cursor === null
2024

2125
return unstable_cache(
2226
async () => {
23-
const token = await getConvexClerkToken()
2427
return fetchQuery(
2528
api.favorites.list,
2629
{ paginationOpts: { numItems, cursor } },

0 commit comments

Comments
 (0)