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
174 changes: 118 additions & 56 deletions packages/web/app/api/v1/vton/posts/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ function createQueryMock(response: QueryResponse) {
in: vi.fn(() => query),
eq: vi.fn(() => query),
or: vi.fn(() => query),
lt: vi.fn(() => query),
order: vi.fn(() => query),
limit: vi.fn(() => query),
not: vi.fn(() => query),
Expand All @@ -34,28 +35,15 @@ function createQueryMock(response: QueryResponse) {
return query;
}

function setupSupabase(
subcategoryResponse: QueryResponse,
postsResponse: QueryResponse
) {
const subcategoryQuery = createQueryMock(subcategoryResponse);
function setupSupabase(postsResponse: QueryResponse) {
const postsQuery = createQueryMock(postsResponse);
const fromMock = vi.fn((table: string) => {
if (table === "subcategories") return subcategoryQuery;
return postsQuery;
});
const fromMock = vi.fn(() => postsQuery);

createSupabaseServerClientMock.mockResolvedValue({ from: fromMock });

return { fromMock, subcategoryQuery, postsQuery };
return { fromMock, postsQuery };
}

const defaultSubcategoryRows = [
{ id: "sc-tops" },
{ id: "sc-bottoms" },
{ id: "sc-shoes" },
];

const blackpinkPost = {
id: "post-bp",
title: "Stage Look",
Expand Down Expand Up @@ -113,26 +101,19 @@ beforeEach(() => {
});

describe("GET /api/v1/vton/posts", () => {
it("queries subcategories with tops/bottoms/shoes whitelist", async () => {
const { subcategoryQuery } = setupSupabase(
{ data: [], error: null },
{ data: [], error: null }
);
it("does not query subcategories — no whitelist filter", async () => {
const { fromMock } = setupSupabase({ data: [], error: null });

const { GET } = await import("../route");
const res = await GET(
makeRequest("http://localhost/api/v1/vton/posts?limit=10")
);

expect(res.status).toBe(200);
expect(subcategoryQuery.in).toHaveBeenCalledWith("code", [
"tops",
"bottoms",
"shoes",
]);
expect(fromMock).not.toHaveBeenCalledWith("subcategories");
});

it("includes posts whose spots are in any of tops/bottoms/shoes subcategories", async () => {
it("includes posts regardless of spots subcategory (no whitelist)", async () => {
const makeSolution = (id: string, title: string) => ({
id,
title,
Expand Down Expand Up @@ -160,38 +141,37 @@ describe("GET /api/v1/vton/posts", () => {
},
{
id: "post-2",
title: "Shoes post",
title: "Accessories post",
context: null,
artist_name: null,
group_name: null,
image_url: "https://cdn.example.com/post2.jpg",
spots: [
{
subcategory_id: "sc-shoes",
solutions: [makeSolution("sol-2", "Sneakers")],
// Previously filtered out by the tops/bottoms/shoes whitelist —
// now that the whitelist is gone, this post should pass through.
subcategory_id: "sc-accessories",
solutions: [makeSolution("sol-2", "Sunglasses")],
},
],
},
{
id: "post-3",
title: "Bottoms post",
title: "Untagged post",
context: null,
artist_name: null,
group_name: null,
image_url: "https://cdn.example.com/post3.jpg",
spots: [
{
subcategory_id: "sc-bottoms",
subcategory_id: null,
solutions: [makeSolution("sol-3", "Jeans")],
},
],
},
];

setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: postsRows, error: null }
);
setupSupabase({ data: postsRows, error: null });

const { GET } = await import("../route");
const res = await GET(
Expand All @@ -210,10 +190,10 @@ describe("GET /api/v1/vton/posts", () => {
});

it("does not apply any text filter when q is omitted", async () => {
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [blackpinkPost, newjeansPost], error: null }
);
const { postsQuery } = setupSupabase({
data: [blackpinkPost, newjeansPost],
error: null,
});

const { GET } = await import("../route");
const res = await GET(makeRequest("http://localhost/api/v1/vton/posts"));
Expand All @@ -236,10 +216,10 @@ describe("GET /api/v1/vton/posts", () => {
})
);
vi.stubGlobal("fetch", fetchMock);
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [blackpinkPost], error: null }
);
const { postsQuery } = setupSupabase({
data: [blackpinkPost],
error: null,
});

const { GET } = await import("../route");
const res = await GET(
Expand All @@ -263,10 +243,10 @@ describe("GET /api/v1/vton/posts", () => {
"fetch",
vi.fn(async () => new Response(null, { status: 502 }))
);
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [blackpinkPost], error: null }
);
const { postsQuery } = setupSupabase({
data: [blackpinkPost],
error: null,
});

const { GET } = await import("../route");
const res = await GET(
Expand All @@ -286,10 +266,7 @@ describe("GET /api/v1/vton/posts", () => {
Response.json({ data: [{ id: "person-only", type: "person" }] })
);
vi.stubGlobal("fetch", fetchMock);
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [], error: null }
);
const { postsQuery } = setupSupabase({ data: [], error: null });

const { GET } = await import("../route");
const res = await GET(
Expand All @@ -304,11 +281,96 @@ describe("GET /api/v1/vton/posts", () => {
expect(json.posts).toEqual([]);
});

it("still supports post_id lookups regardless of q presence", async () => {
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [blackpinkPost], error: null }
it("returns nextCursor when the default listing fills the page", async () => {
const makeSolution = (id: string) => ({
id,
title: `Item ${id}`,
thumbnail_url: `https://cdn.example.com/${id}.jpg`,
description: null,
keywords: null,
status: "active",
accurate_count: 1,
});
const postsRows = Array.from({ length: 2 }, (_, i) => ({
id: `post-${i}`,
title: `Post ${i}`,
context: null,
artist_name: null,
group_name: null,
image_url: `https://cdn.example.com/p${i}.jpg`,
created_at: `2026-05-${10 + i}T00:00:00Z`,
spots: [{ subcategory_id: "sc", solutions: [makeSolution(`sol-${i}`)] }],
})).reverse(); // newest first

setupSupabase({ data: postsRows, error: null });

const { GET } = await import("../route");
const res = await GET(
makeRequest("http://localhost/api/v1/vton/posts?limit=2")
);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.posts).toHaveLength(2);
expect(json.nextCursor).toBe("2026-05-10T00:00:00Z");
});

it("returns nextCursor=null when fewer posts than the page limit pass the filter", async () => {
setupSupabase({ data: [blackpinkPost], error: null });

const { GET } = await import("../route");
const res = await GET(
makeRequest("http://localhost/api/v1/vton/posts?limit=24")
);
const json = await res.json();

expect(res.status).toBe(200);
expect(json.posts).toHaveLength(1);
expect(json.nextCursor).toBeNull();
});

it("passes cursor as a `created_at < cursor` predicate on the default listing", async () => {
const { postsQuery } = setupSupabase({ data: [], error: null });
const cursor = "2026-04-01T00:00:00Z";

const { GET } = await import("../route");
await GET(
makeRequest(
`http://localhost/api/v1/vton/posts?cursor=${encodeURIComponent(cursor)}`
)
);

expect(postsQuery.lt).toHaveBeenCalledWith("created_at", cursor);
});

it("ignores cursor for search and post_id branches (nextCursor always null)", async () => {
const fetchMock = vi.fn(async () =>
Response.json({ data: [{ id: "post-bp", type: "post" }] })
);
vi.stubGlobal("fetch", fetchMock);
const { postsQuery } = setupSupabase({
data: [blackpinkPost],
error: null,
});

const { GET } = await import("../route");
const res = await GET(
makeRequest(
"http://localhost/api/v1/vton/posts?q=blackpink&cursor=2026-04-01T00:00:00Z"
)
);
const json = await res.json();

expect(res.status).toBe(200);
expect(postsQuery.lt).not.toHaveBeenCalled();
expect(json.nextCursor).toBeNull();
});

it("still supports post_id lookups regardless of q presence", async () => {
const { postsQuery } = setupSupabase({
data: [blackpinkPost],
error: null,
});

const { GET } = await import("../route");
const res = await GET(
Expand Down
Loading
Loading