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
197 changes: 183 additions & 14 deletions packages/web/app/api/v1/vton/posts/__tests__/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

const createSupabaseServerClientMock = vi.fn();

vi.mock("@/lib/server-env", () => ({
API_BASE_URL: "http://api.test",
}));

vi.mock("@/lib/supabase/server", () => ({
createSupabaseServerClient: createSupabaseServerClientMock,
}));
Expand All @@ -11,11 +15,16 @@ type QueryResponse = {
error: { message: string } | null;
};

function makeRequest(url: string) {
return new Request(url) as unknown as import("next/server").NextRequest;
}

function createQueryMock(response: QueryResponse) {
const query = {
select: vi.fn(() => query),
in: vi.fn(() => query),
eq: vi.fn(() => query),
or: vi.fn(() => query),
order: vi.fn(() => query),
limit: vi.fn(() => query),
not: vi.fn(() => query),
Expand All @@ -41,9 +50,66 @@ function setupSupabase(
return { fromMock, subcategoryQuery, postsQuery };
}

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

const blackpinkPost = {
id: "post-bp",
title: "Stage Look",
context: "BLACKPINK World Tour",
artist_name: "Jennie",
group_name: "BLACKPINK",
image_url: "https://cdn.example.com/bp.jpg",
spots: [
{
subcategory_id: "sc-tops",
solutions: [
{
id: "sol-1",
title: "Black Cropped Jacket",
thumbnail_url: "https://cdn.example.com/jacket.jpg",
description: "stage jacket",
keywords: ["jacket", "stage"],
status: "active",
accurate_count: 5,
},
],
},
],
};

const newjeansPost = {
id: "post-nj",
title: "Music Video",
context: "Bubble Gum MV",
artist_name: "Hanni",
group_name: "NewJeans",
image_url: "https://cdn.example.com/nj.jpg",
spots: [
{
subcategory_id: "sc-bottoms",
solutions: [
{
id: "sol-2",
title: "White Pleated Skirt",
thumbnail_url: "https://cdn.example.com/skirt.jpg",
description: null,
keywords: null,
status: "active",
accurate_count: 3,
},
],
},
],
};

beforeEach(() => {
vi.resetModules();
createSupabaseServerClientMock.mockReset();
vi.unstubAllGlobals();
});

describe("GET /api/v1/vton/posts", () => {
Expand All @@ -54,10 +120,9 @@ describe("GET /api/v1/vton/posts", () => {
);

const { GET } = await import("../route");
const req = new Request(
"http://localhost/api/v1/vton/posts?limit=10"
) as unknown as import("next/server").NextRequest;
const res = await GET(req);
const res = await GET(
makeRequest("http://localhost/api/v1/vton/posts?limit=10")
);

expect(res.status).toBe(200);
expect(subcategoryQuery.in).toHaveBeenCalledWith("code", [
Expand All @@ -68,12 +133,6 @@ describe("GET /api/v1/vton/posts", () => {
});

it("includes posts whose spots are in any of tops/bottoms/shoes subcategories", async () => {
const subcategoryRows = [
{ id: "sc-tops" },
{ id: "sc-bottoms" },
{ id: "sc-shoes" },
];

const makeSolution = (id: string, title: string) => ({
id,
title,
Expand Down Expand Up @@ -130,15 +189,13 @@ describe("GET /api/v1/vton/posts", () => {
];

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

const { GET } = await import("../route");
const res = await GET(
new Request(
"http://localhost/api/v1/vton/posts?limit=10"
) as unknown as import("next/server").NextRequest
makeRequest("http://localhost/api/v1/vton/posts?limit=10")
);
const json = await res.json();

Expand All @@ -151,4 +208,116 @@ describe("GET /api/v1/vton/posts", () => {
)
).toBe(true);
});

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 { GET } = await import("../route");
const res = await GET(makeRequest("http://localhost/api/v1/vton/posts"));
const json = await res.json();

expect(res.status).toBe(200);
expect(postsQuery.or).not.toHaveBeenCalled();
// No explicit `id` filter was applied (post_id branch is `eq`, search branch is `in`).
expect(postsQuery.in).not.toHaveBeenCalled();
expect(json.posts).toHaveLength(2);
});

it("uses Meilisearch post results to narrow VTON posts when q is provided", async () => {
const fetchMock = vi.fn(async () =>
Response.json({
data: [
{ id: "post-bp", type: "post" },
{ id: "person-1", type: "person" },
],
})
);
vi.stubGlobal("fetch", fetchMock);
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [blackpinkPost], error: null }
);

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

expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledWith(
"http://api.test/api/v1/search?q=blackpink&page=1&limit=60",
{ headers: { Accept: "application/json" } }
);
expect(postsQuery.in).toHaveBeenCalledWith("id", ["post-bp"]);
expect(postsQuery.or).not.toHaveBeenCalled();
expect(json.posts).toHaveLength(1);
expect(json.posts[0].id).toBe("post-bp");
});

it("falls back to DB ilike across title/artist_name/group_name/context when Meilisearch is unavailable", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(null, { status: 502 }))
);
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [blackpinkPost], error: null }
);

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

expect(res.status).toBe(200);
expect(postsQuery.or).toHaveBeenCalledWith(
"title.ilike.%jennie%,artist_name.ilike.%jennie%,group_name.ilike.%jennie%,context.ilike.%jennie%"
);
expect(json.posts).toHaveLength(1);
});

it("returns empty when Meilisearch responds successfully with zero post-type hits (no DB fallback)", async () => {
const fetchMock = vi.fn(async () =>
Response.json({ data: [{ id: "person-only", type: "person" }] })
);
vi.stubGlobal("fetch", fetchMock);
const { postsQuery } = setupSupabase(
{ data: defaultSubcategoryRows, error: null },
{ data: [], error: null }
);

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

expect(res.status).toBe(200);
// Meilisearch returned successfully with zero post hits → trust it,
// don't degrade into a broad DB ilike scan.
expect(postsQuery.or).not.toHaveBeenCalled();
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 }
);

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

expect(res.status).toBe(200);
expect(postsQuery.eq).toHaveBeenCalledWith("id", "post-bp");
expect(json.posts).toHaveLength(1);
});
});
77 changes: 74 additions & 3 deletions packages/web/app/api/v1/vton/posts/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { API_BASE_URL } from "@/lib/server-env";
import { createSupabaseServerClient } from "@/lib/supabase/server";
import type { Json } from "@/lib/supabase/types";

Expand Down Expand Up @@ -30,6 +31,13 @@ type PostRow = {
spots?: SpotRow[] | null;
};

type SearchResponse = {
data?: Array<{
id?: string;
type?: string;
}>;
};

function toKeywords(value: Json | null): string[] | null {
if (!Array.isArray(value)) return null;
const keywords = value.filter(
Expand Down Expand Up @@ -57,10 +65,51 @@ async function getVtonSubcategoryIds(supabase: SupabaseClient) {
return new Set((data ?? []).map((row) => row.id));
}

async function fetchSearchPostIds(
query: string,
limit: number
): Promise<string[] | null> {
if (!API_BASE_URL || !query) return null;

const params = new URLSearchParams({
q: query,
page: "1",
limit: String(Math.max(limit, 60)),
});

try {
const response = await fetch(`${API_BASE_URL}/api/v1/search?${params}`, {
headers: { Accept: "application/json" },
});

if (!response.ok) return null;

const data = (await response.json()) as SearchResponse;
const postIds = (data.data ?? [])
.filter((item) => !item.type || item.type === "post")
.map((item) => item.id)
.filter((id): id is string => typeof id === "string" && id.length > 0);

return Array.from(new Set(postIds));
} catch (error) {
if (process.env.NODE_ENV === "development") {
console.error("VTON post search error:", error);
}
return null;
}
}

// PostgREST or() filters are comma-separated; strip commas/parens from user
// input so a malicious q can't inject extra filter clauses.
function sanitizeIlikeValue(value: string): string {
return value.replace(/[,()]/g, " ");
}

export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const limit = Math.min(Number(searchParams.get("limit")) || 24, 50);
const postId = searchParams.get("post_id")?.trim() || "";
const query = searchParams.get("q")?.trim() || "";

try {
const supabase = await createSupabaseServerClient();
Expand All @@ -73,9 +122,31 @@ export async function GET(request: NextRequest) {
.eq("status", "active")
.not("image_url", "is", null);

postsQuery = postId
? postsQuery.eq("id", postId).limit(1)
: postsQuery.order("created_at", { ascending: false }).limit(limit * 3);
if (postId) {
postsQuery = postsQuery.eq("id", postId).limit(1);
} else if (query) {
const searchPostIds = await fetchSearchPostIds(query, limit);

if (searchPostIds && searchPostIds.length > 0) {
postsQuery = postsQuery.in("id", searchPostIds).limit(limit * 3);
} else if (searchPostIds && searchPostIds.length === 0) {
// Meilisearch responded successfully with zero post hits — trust it
// and skip a broad DB ilike fanout.
return NextResponse.json({ posts: [] });
} else {
const pattern = `%${sanitizeIlikeValue(query)}%`;
postsQuery = postsQuery
.or(
`title.ilike.${pattern},artist_name.ilike.${pattern},group_name.ilike.${pattern},context.ilike.${pattern}`
)
.order("created_at", { ascending: false })
.limit(limit * 3);
}
} else {
postsQuery = postsQuery
.order("created_at", { ascending: false })
.limit(limit * 3);
}

const { data, error } = await postsQuery;

Expand Down
14 changes: 10 additions & 4 deletions packages/web/lib/components/vton/VtonItemPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,8 +181,8 @@ export function VtonItemPanel({
</div>
)}

{/* Search — hidden in post mode */}
{sourceMode === "items" && !isPostMode && (
{/* Search — hidden when a specific post is preloaded */}
{!isPostMode && (
<div className="border-b border-white/10 px-4 py-2">
<div className="relative">
<Search
Expand All @@ -193,7 +193,11 @@ export function VtonItemPanel({
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder={`Search ${CATEGORIES.find((c) => c.key === activeCategory)?.label_en.toLowerCase()}...`}
placeholder={
sourceMode === "posts"
? "Search posts by artist or scene..."
: `Search ${CATEGORIES.find((c) => c.key === activeCategory)?.label_en.toLowerCase()}...`
}
className="w-full rounded-lg bg-white/5 py-2 pr-3 pl-8 text-sm text-white placeholder-white/30 outline-none ring-1 ring-white/10 transition-all focus:ring-[#eafd67]/50"
/>
</div>
Expand Down Expand Up @@ -254,7 +258,9 @@ export function VtonItemPanel({
</div>
{!isLoadingPosts && posts.length === 0 && (
<p className="mt-8 text-center text-xs text-white/35">
No try-on ready posts yet.
{searchQuery.trim()
? "No posts match your search."
: "No try-on ready posts yet."}
</p>
)}
</>
Expand Down
Loading
Loading