Skip to content
216 changes: 216 additions & 0 deletions apps/web/e2e/specs/wish/folderDetail.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { ENDPOINTS } from '@/consts/api';

import { expect, test } from '@e2e/fixtures/mockApiFixture';
import { MOCK_FOLDER, MOCK_FOLDERS } from '@e2e/mocks/folder';
import { MOCK_MEMBER_ME } from '@e2e/mocks/me';
import { MOCK_WISHLIST_ENTRIES, MOCK_WISH_ADDED_BY_LINK } from '@e2e/mocks/wish';

const [FIRST_ENTRY] = MOCK_WISHLIST_ENTRIES;
const FIRST_WISH_NAME = FIRST_ENTRY?.item.name ?? '';
const FIRST_WISH_ID = FIRST_ENTRY?.wish.id ?? 0;
const OTHER_FOLDER = MOCK_FOLDERS.find(({ id }) => id !== MOCK_FOLDER.id);
const FOLDER_PATH = `/wish/folder/${MOCK_FOLDER.id}`;

test('폴더 상세에 폴더 이름 · 상품 개수 · 위시가 보인다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);

await page.goto(FOLDER_PATH);

await expect(page.getByRole('heading', { name: MOCK_FOLDER.name })).toBeVisible();
await expect(page.getByText(`상품 ${MOCK_FOLDER.wishCount}`)).toBeVisible();
await expect(page.getByText(FIRST_WISH_NAME, { exact: true })).toBeVisible();
});

test('없는 폴더로 들어오면 안내 후 폴더 탭으로 이동한다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto('/wish/folder/999');

await expect(page).toHaveURL(/\/wish\?tab=folder$/);
await expect(page.getByText('폴더를 찾을 수 없어요.')).toBeVisible();
});

test('편집 모드에서 고른 위시를 폴더에서 뺀다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.delete(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 6 });
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(FOLDER_PATH);
await page.getByRole('button', { name: '편집' }).click();
await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click();
await page.getByRole('button', { name: '삭제', exact: true }).click();

const removeRequest = page.waitForRequest(
request =>
request.method() === 'DELETE' &&
new URL(request.url()).pathname === ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id)
);
await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click();

expect(new URL((await removeRequest).url()).searchParams.getAll('wishIds')).toEqual([
String(FIRST_WISH_ID),
]);
await expect(page.getByText('폴더에서 상품 1개를 삭제했어요.')).toBeVisible();
});

test('편집 모드에서 다른 폴더를 고르면 한 번의 요청으로 이동한다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.post(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id), {
folderId: MOCK_FOLDER.id,
wishCount: 6,
targetFolderId: OTHER_FOLDER?.id,
targetWishCount: 1,
});
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(FOLDER_PATH);
await page.getByRole('button', { name: '편집' }).click();
await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click();
await page.getByRole('button', { name: '폴더 이동' }).click();

const moveRequest = page.waitForRequest(
request =>
request.method() === 'POST' &&
request.url().endsWith(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id))
);
await page.getByRole('button', { name: new RegExp(OTHER_FOLDER?.name ?? '') }).click();

expect((await moveRequest).postDataJSON()).toEqual({
wishIds: [FIRST_WISH_ID],
targetFolderId: OTHER_FOLDER?.id,
});
await expect(page.getByText(`상품을 '${OTHER_FOLDER?.name}' 폴더로 이동했어요.`)).toBeVisible();
});

test('내 위시에서 골라 폴더에 추가하면 폴더 상세로 돌아간다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.post(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 8 });
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(`${FOLDER_PATH}/add`);
await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click();

const addRequest = page.waitForRequest(
request =>
request.method() === 'POST' && request.url().endsWith(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id))
);
await page.getByRole('button', { name: '선택한 1개의 상품 추가' }).click();

expect((await addRequest).postDataJSON()).toEqual({ wishIds: [FIRST_WISH_ID] });
await expect(page).toHaveURL(new RegExp(`${FOLDER_PATH}$`));
});

test('폴더 상세에서 링크로 담으면 폴더를 지정해 등록하고 화면에 머문다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.post(ENDPOINTS.WISHLISTS, MOCK_WISH_ADDED_BY_LINK);
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(FOLDER_PATH);
await page.getByRole('button', { name: '추가', exact: true }).click();
await page.getByRole('button', { name: /링크로 담기/ }).click();
await page.getByLabel('링크 URL').fill('https://shop.example/items/105');

const registerRequest = page.waitForRequest(
request => request.method() === 'POST' && request.url().endsWith(ENDPOINTS.WISHLISTS)
);
await page.getByRole('button', { name: '위시리스트에 담기' }).click();

expect((await registerRequest).postDataJSON()).toEqual({
url: 'https://shop.example/items/105',
folderId: MOCK_FOLDER.id,
});
await expect(page.getByRole('dialog', { name: '링크로 담기' })).toBeHidden();
await expect(page).toHaveURL(new RegExp(`${FOLDER_PATH}$`));
});

test('전체 선택 후 빼면 불러오지 않은 위시까지 all 로 뺀다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.delete(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 0 });
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(FOLDER_PATH);
await page.getByRole('button', { name: '편집' }).click();
await page.getByRole('button', { name: '전체 선택' }).click();
await page.getByRole('button', { name: '삭제', exact: true }).click();

await expect(
page.getByRole('dialog', { name: `선택한 상품 ${MOCK_FOLDER.wishCount}개를 삭제할까요?` })
).toBeVisible();

const removeRequest = page.waitForRequest(
request =>
request.method() === 'DELETE' &&
new URL(request.url()).pathname === ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id)
);
await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click();

const searchParams = new URL((await removeRequest).url()).searchParams;
expect(searchParams.get('all')).toBe('true');
expect(searchParams.getAll('wishIds')).toEqual([]);
});

test('전체 선택 후 다른 폴더로 옮기면 all 로 옮긴다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.post(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id), {
folderId: MOCK_FOLDER.id,
wishCount: 0,
targetFolderId: OTHER_FOLDER?.id,
targetWishCount: MOCK_FOLDER.wishCount,
});
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(FOLDER_PATH);
await page.getByRole('button', { name: '편집' }).click();
await page.getByRole('button', { name: '전체 선택' }).click();
await page.getByRole('button', { name: '폴더 이동' }).click();

const moveRequest = page.waitForRequest(
request =>
request.method() === 'POST' &&
request.url().endsWith(ENDPOINTS.FOLDER_WISHES_MOVE(MOCK_FOLDER.id))
);
await page.getByRole('button', { name: new RegExp(OTHER_FOLDER?.name ?? '') }).click();

expect((await moveRequest).postDataJSON()).toEqual({
all: true,
targetFolderId: OTHER_FOLDER?.id,
});
});

for (const { folderResult, message } of [
{ folderResult: 'ADDED', message: '이미 담은 상품을 폴더에 추가했어요.' },
{ folderResult: 'ALREADY_IN_FOLDER', message: '이미 이 폴더에 있는 상품이에요.' },
] as const) {
test(`이미 담은 링크를 폴더에서 담으면 ${folderResult} 결과를 안내한다`, async ({
page,
api,
}) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.post(ENDPOINTS.WISHLISTS, { ...MOCK_WISH_ADDED_BY_LINK, folderResult });
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto(FOLDER_PATH);
await page.getByRole('button', { name: '추가', exact: true }).click();
await page.getByRole('button', { name: /링크로 담기/ }).click();
await page.getByLabel('링크 URL').fill('https://shop.example/items/105');
await page.getByRole('button', { name: '위시리스트에 담기' }).click();

await expect(page.getByText(message)).toBeVisible();
await expect(page).toHaveURL(new RegExp(`${FOLDER_PATH}$`));
});
}
5 changes: 3 additions & 2 deletions apps/web/src/apis/postWishLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { clientApi } from './client';
/**
* @param url 등록할 상품 링크
* @param isExternalShare 앱공유 유입 여부
* @param folderId 등록과 동시에 넣을 폴더
*/
export const postWishLink = async (url: string, isExternalShare = false) => {
export const postWishLink = async (url: string, isExternalShare = false, folderId?: number) => {
const { data } = await clientApi.post<ApiResponseT<PostWishLinkResponseT>>(
ENDPOINTS.WISHLISTS,
{ url },
{ url, ...(typeof folderId === 'number' && { folderId }) },
isExternalShare ? { headers: { 'X-Client-Entry-Point': 'EXTERNAL_SHARE' } } : {}
);

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/app/wish/_common/_consts/folder.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { ERROR_CODE } from '@piki/core';
import type { ApiErrorCodeT } from '@piki/core';

export const MAX_FOLDER_WISH_SELECT_COUNT = 100;

export const FOLDER_NAME_ERROR_CODES: readonly (ApiErrorCodeT | null)[] = [
ERROR_CODE.FOLDER_INVALID_NAME,
];
9 changes: 6 additions & 3 deletions apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ type PostFolderWishesVariablesT = {
wishIds: number[];
};

export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } = {}) => {
type UsePostFolderWishesOptionsT = {
onSuccess?: (folder: PostFolderWishesVariablesT['folder']) => void;
};

export const usePostFolderWishes = ({ onSuccess }: UsePostFolderWishesOptionsT = {}) => {
const queryClient = useQueryClient();

const { mutate: postFolderWishesMutation, isPending: isPostFolderWishesPending } = useMutation({
Expand All @@ -22,8 +26,7 @@ export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } =
onSuccess: (_, { folder }) => {
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
queryClient.invalidateQueries({ queryKey: ['folders'] });
toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`);
onSuccess?.();
onSuccess?.(folder);
},
onError: error => {
if (isGlobalNetError(error)) return;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app/wish/_common/_utils/formatWishCount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
const MAX_DISPLAY_COUNT = 999;

export const formatWishCount = (count: number) =>
count > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : String(count);
8 changes: 7 additions & 1 deletion apps/web/src/app/wish/_components/WishListContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import { useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';

import { Z_INDEX } from '@/consts/zIndex';
import { useGetWishesCount } from '@/hooks/useGetWishesCount';
Expand Down Expand Up @@ -40,7 +41,12 @@ function WishListContent() {
handleExitEditMode,
} = useWishEditMode();

const { postFolderWishesMutation } = usePostFolderWishes({ onSuccess: handleClearSelection });
const { postFolderWishesMutation } = usePostFolderWishes({
onSuccess: folder => {
handleClearSelection();
toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`);
},
});
const { deleteWishesMutation } = useDeleteWishes({ onSuccess: handleClearSelection });

/** 전체 위시에서는 기존 소속 폴더를 유지한 채 대상 폴더에 추가 */
Expand Down
7 changes: 2 additions & 5 deletions apps/web/src/app/wish/_components/WishTabSummary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import { useGetFolders } from '@/hooks/useGetFolders';

import { WISH_MODE } from '../_common/_consts/wishMode';
import type { WishModeT } from '../_common/_types/wishMode';
import { formatWishCount } from '../_common/_utils/formatWishCount';
import { WISH_TAB } from '../_consts/wishTab';
import { parseWishTabParam } from '../_utils/wishSearchParams';

const MAX_DISPLAY_COUNT = 999;

type WishTabSummaryProps = {
wishCount: number;
mode: WishModeT;
Expand All @@ -36,9 +35,7 @@ function WishTabSummary({ wishCount, mode, onEdit, onSearch }: WishTabSummaryPro

return (
<div className="flex items-center justify-between">
<p className="body-2-medium text-text-neutral-tertiary">
상품 {wishCount > MAX_DISPLAY_COUNT ? `${MAX_DISPLAY_COUNT}+` : wishCount}
</p>
<p className="body-2-medium text-text-neutral-tertiary">상품 {formatWishCount(wishCount)}</p>
{mode === WISH_MODE.DEFAULT && (
<div className="flex items-center gap-3 body-2-medium text-text-neutral-secondary">
<button
Expand Down
Loading
Loading