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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion docs/agent/design-system-llm.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
title: Design System — LLM Reference
title: Design System — LLM Reference (v2.2.0)
owner: human
status: approved
updated: 2026-05-28
Expand Down
131 changes: 130 additions & 1 deletion packages/web/app/api/v1/vton/items/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ function createQueryMock(response: QueryResponse) {
order: vi.fn(() => query),
limit: vi.fn(() => query),
ilike: vi.fn(() => query),
filter: vi.fn(() => query),
then: (resolve: (value: QueryResponse) => unknown) =>
Promise.resolve(response).then(resolve),
};
Expand Down Expand Up @@ -121,19 +122,147 @@ describe("GET /api/v1/vton/items", () => {
const { solutionQueries } = setupSupabase([
{ data: [stageJacket], error: null },
{ data: [], error: null },
{ data: [], error: null },
]);

const { GET } = await import("../route");
const res = await GET(makeRequest());
const json = await res.json();

expect(res.status).toBe(200);
expect(solutionQueries).toHaveLength(2);
expect(solutionQueries).toHaveLength(3);
expect(solutionQueries[0].ilike).toHaveBeenCalledWith("title", "%stage%");
expect(solutionQueries[1].ilike).toHaveBeenCalledWith(
"description",
"%stage%"
);
expect(json.items).toHaveLength(1);
});

it("accepts 'shoes' as a valid VTON category code", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 502 }))
);
const { subcategoryQuery } = setupSupabase([
{ data: [], error: null },
]);

const { GET } = await import("../route");
const res = await GET(
new Request(
"http://localhost/api/v1/vton/items?category=shoes"
) as unknown as import("next/server").NextRequest
);

expect(res.status).toBe(200);
expect(subcategoryQuery.in).toHaveBeenCalledWith("code", ["shoes"]);
});

it("falls back to all 3 codes when category param is invalid", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 502 }))
);
const { subcategoryQuery } = setupSupabase([
{ data: [], error: null },
]);

const { GET } = await import("../route");
await GET(
new Request(
"http://localhost/api/v1/vton/items?category=invalid"
) as unknown as import("next/server").NextRequest
);

expect(subcategoryQuery.in).toHaveBeenCalledWith("code", [
"tops",
"bottoms",
"shoes",
]);
});

it("matches items whose keywords array contains the query", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 502 }))
);
const koreanShirt = {
id: "solution-keywords-only",
title: "Plain top",
thumbnail_url: "https://cdn.example.com/top.jpg",
description: "casual",
keywords: ["셔츠", "shirt"],
accurate_count: 3,
spots: {
post_id: "post-x",
subcategory_id: "subcategory-tops",
posts: { image_url: "https://cdn.example.com/p.jpg" },
},
};
const { solutionQueries } = setupSupabase([
{ data: [], error: null },
{ data: [], error: null },
{ data: [koreanShirt], error: null },
]);

const { GET } = await import("../route");
const res = await GET(
new Request(
"http://localhost/api/v1/vton/items?category=tops&q=%EC%85%94%EC%B8%A0"
) as unknown as import("next/server").NextRequest
);
const json = await res.json();

expect(res.status).toBe(200);
expect(solutionQueries).toHaveLength(3);
expect(solutionQueries[2].filter).toHaveBeenCalledWith(
"keywords::text",
"ilike",
"%셔츠%"
);
expect(json.items.some((i: { id: string }) => i.id === "solution-keywords-only")).toBe(true);
});

it("dedupes items by solution.id when same solution appears in multiple spots", async () => {
const fetchMock = vi.fn(async () =>
Response.json({
data: [{ id: "post-1", type: "post" }],
})
);
vi.stubGlobal("fetch", fetchMock);

const dupRow = (id: string, spotId: string) => ({
id,
title: `Item ${id}`,
thumbnail_url: `https://cdn.example.com/${id}.jpg`,
description: null,
keywords: null,
accurate_count: 5,
spots: {
post_id: "post-1",
subcategory_id: `subcategory-${spotId}`,
posts: { image_url: "https://cdn.example.com/p.jpg" },
},
});

setupSupabase([
{
// 같은 solution.id="dup"이 여러 spot에서 join돼 중복 row로 반환
data: [dupRow("dup", "a"), dupRow("dup", "b"), dupRow("other", "a")],
error: null,
},
]);

const { GET } = await import("../route");
const res = await GET(makeRequest());
const json = await res.json();

expect(res.status).toBe(200);
const dupItems = json.items.filter(
(i: { id: string }) => i.id === "dup"
);
expect(dupItems).toHaveLength(1);
expect(json.items).toHaveLength(2);
});
});
43 changes: 28 additions & 15 deletions packages/web/app/api/v1/vton/items/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { API_BASE_URL } from "@/lib/server-env";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import type { Database, Json } from "@/lib/supabase/types";

const VTON_CATEGORY_CODES = ["tops", "bottoms"] as const;
const VTON_CATEGORY_CODES = ["tops", "bottoms", "shoes"] as const;
type VtonCategoryCode = (typeof VTON_CATEGORY_CODES)[number];

type SupabaseClient = Awaited<ReturnType<typeof createSupabaseServerClient>>;
Expand Down Expand Up @@ -62,6 +62,7 @@ async function getVtonSubcategoryIds(
}

function mapItems(data: SolutionRow[] | null) {
const seen = new Set<string>();
return (data || [])
.map((item) => ({
id: item.id,
Expand All @@ -70,16 +71,20 @@ function mapItems(data: SolutionRow[] | null) {
description: item.description,
keywords: toKeywords(item.keywords),
}))
.filter((item) => item.thumbnail_url);
.filter((item) => {
if (!item.thumbnail_url) return false;
if (seen.has(item.id)) return false;
seen.add(item.id);
return true;
});
}

function mergeSolutionRows(
titleRows: SolutionRow[] | null,
descriptionRows: SolutionRow[] | null
) {
function mergeSolutionRows(resultSets: Array<SolutionRow[] | null>) {
const rowsById = new Map<string, SolutionRow>();
for (const row of [...(titleRows ?? []), ...(descriptionRows ?? [])]) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
for (const set of resultSets) {
for (const row of set ?? []) {
if (!rowsById.has(row.id)) rowsById.set(row.id, row);
}
}
return Array.from(rowsById.values()).sort(
(a, b) => (b.accurate_count ?? 0) - (a.accurate_count ?? 0)
Expand All @@ -94,7 +99,7 @@ async function fetchVtonItems(
limit: number,
postIds?: string[]
) {
const buildQuery = (field?: "title" | "description") => {
const buildQuery = (field?: "title" | "description" | "keywords") => {
let dbQuery = supabase
.from("solutions")
.select(selectColumns)
Expand All @@ -110,7 +115,12 @@ async function fetchVtonItems(
}

if (field && query) {
dbQuery = dbQuery.ilike(field, `%${query}%`);
if (field === "keywords") {
// jsonb → text cast 후 ilike: '["셔츠","shirt"]' 안의 substring 매칭
dbQuery = dbQuery.filter("keywords::text", "ilike", `%${query}%`);
} else {
dbQuery = dbQuery.ilike(field, `%${query}%`);
}
}

return dbQuery;
Expand All @@ -121,19 +131,22 @@ async function fetchVtonItems(
return { data: data as SolutionRow[] | null, error };
}

const [titleResult, descriptionResult] = await Promise.all([
const [titleResult, descriptionResult, keywordsResult] = await Promise.all([
buildQuery("title"),
buildQuery("description"),
buildQuery("keywords"),
]);

const error = titleResult.error ?? descriptionResult.error;
const error =
titleResult.error ?? descriptionResult.error ?? keywordsResult.error;
if (error) return { data: null, error };

return {
data: mergeSolutionRows(
data: mergeSolutionRows([
titleResult.data as SolutionRow[] | null,
descriptionResult.data as SolutionRow[] | null
).slice(0, limit),
descriptionResult.data as SolutionRow[] | null,
keywordsResult.data as SolutionRow[] | null,
]).slice(0, limit),
error: null,
};
}
Expand Down
Loading
Loading