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
4 changes: 4 additions & 0 deletions src/features/store/constants/store-review-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/** 매장 리뷰 조회 에러 메시지. */
export const STORE_REVIEW_ERRORS = {
INVALID_LIKES_CURSOR: '좋아요순 커서 형식이 올바르지 않습니다.',
} as const;
8 changes: 8 additions & 0 deletions src/features/store/dto/inputs/store-reviews.input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
Expand All @@ -8,6 +9,9 @@ import {
Min,
} from 'class-validator';

export const STORE_REVIEW_SORTS = ['LATEST', 'LIKES'] as const;
export type StoreReviewSort = (typeof STORE_REVIEW_SORTS)[number];

export class StoreReviewsInput {
@IsString()
@IsNotEmpty()
Expand All @@ -17,6 +21,10 @@ export class StoreReviewsInput {
@IsBoolean()
photoOnly?: boolean;

@IsOptional()
@IsIn(STORE_REVIEW_SORTS)
sort?: StoreReviewSort;

@IsOptional()
@IsString()
@IsNotEmpty()
Expand Down
72 changes: 66 additions & 6 deletions src/features/store/repositories/store-review.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,83 @@ export class StoreReviewRepository {
};
}

/** 매장 공개 리뷰 목록(최신순, 커서 id desc). soft-delete 제외. */
async listStoreReviews(args: {
/** 매장 리뷰 id 페이지(최신순, 커서 id desc). */
async listStoreReviewIdsLatest(args: {
storeId: bigint;
photoOnly: boolean;
limit: number;
cursor?: bigint;
}): Promise<StoreReviewRow[]> {
return this.prisma.review.findMany({
}): Promise<bigint[]> {
const rows = await this.prisma.review.findMany({
where: {
store_id: args.storeId,
...this.publicReviewWhere(args.photoOnly),
// 0n도 유효 인자(parseId("0")=0n). truthiness는 0n을 falsy로 떨궈
// zero cursor가 페이지를 리셋하므로 undefined로만 분기한다.
...(args.cursor !== undefined ? { id: { lt: args.cursor } } : {}),
},
select: { id: true },
orderBy: { id: 'desc' },
take: args.limit + 1,
});
return rows.map((row) => row.id);
}

/**
* 매장 리뷰 id 페이지(좋아요순 desc, 동률이면 id desc).
*
* soft-delete된 좋아요를 제외한 집계 기준 정렬이 Prisma orderBy(_count)로는
* 불가능하므로 raw 키셋 페이지네이션으로 조회한다. 커서는 이전 페이지 경계의
* (likeCount, id) 값을 그대로 받아 이어간다 — 경계 리뷰의 좋아요 수가 요청
* 사이에 변해도 페이지가 중복/누락되지 않는다.
*/
async listStoreReviewIdsByLikes(args: {
storeId: bigint;
photoOnly: boolean;
limit: number;
cursor?: { likeCount: number; id: bigint };
}): Promise<{ id: bigint; likeCount: number }[]> {
const photoFilter = args.photoOnly
? Prisma.sql`AND EXISTS (
SELECT 1 FROM review_media m
WHERE m.review_id = r.id AND m.deleted_at IS NULL
)`
: Prisma.empty;
const cursorHaving =
args.cursor !== undefined
? Prisma.sql`HAVING COUNT(l.id) < ${args.cursor.likeCount}
OR (COUNT(l.id) = ${args.cursor.likeCount} AND r.id < ${args.cursor.id})`
Comment on lines +90 to +91

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve a stable like-rank snapshot across pages

When any review's likes change between page requests, this predicate compares every review's live COUNT(l.id) with a stale boundary and therefore does not prevent duplicates or omissions as claimed. For example, after a first page ends at 9 likes, an unseen 8-like review that rises to 10 is excluded from every subsequent query, while an already-returned 10-like review that drops to 8 is returned again. The cursor must identify a stable ranking snapshot/version, or the API must otherwise account for already-seen rows, to provide mutation-safe pagination.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의도된 트레이드오프로 유지. 이 커서의 목적은 경계 리뷰의 좋아요 변동으로 인한 인접 페이지 즉시 중복/누락 방지까지이고, 임의 리뷰의 전역 변동까지 막으려면 랭킹 스냅샷·seen-set 커서가 필요해 공개 목록 조회에 과한 비용. 이미 릴리즈된 productReviews의 listProductReviewIdsByLikes와 동일 구현·동일 수용 기준이라 정합성 차원에서도 동일하게 둠.

: Prisma.empty;

const rows = await this.prisma.$queryRaw<
{ id: bigint; like_count: bigint }[]
>(Prisma.sql`
SELECT r.id AS id, COUNT(l.id) AS like_count
FROM review r
JOIN store s
ON s.id = r.store_id AND s.is_active = 1 AND s.deleted_at IS NULL
LEFT JOIN review_like l
ON l.review_id = r.id AND l.deleted_at IS NULL
WHERE r.store_id = ${args.storeId} AND r.deleted_at IS NULL
${photoFilter}
GROUP BY r.id
${cursorHaving}
ORDER BY like_count DESC, r.id DESC
LIMIT ${args.limit + 1}
`);
return rows.map((row) => ({
id: row.id,
likeCount: Number(row.like_count),
}));
}

/** id 페이지의 리뷰 본문 row 일괄 조회(정렬은 service에서 id 순서로 복원). */
async findStoreReviewRowsByIds(
reviewIds: bigint[],
): Promise<StoreReviewRow[]> {
if (reviewIds.length === 0) return [];
return this.prisma.review.findMany({
where: { id: { in: reviewIds }, deleted_at: null },
select: {
id: true,
rating: true,
Expand All @@ -82,8 +144,6 @@ export class StoreReviewRepository {
},
},
},
orderBy: { id: 'desc' },
take: args.limit + 1,
});
}

Expand Down
20 changes: 20 additions & 0 deletions src/features/store/resolvers/store-review-query.resolver.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,26 @@ describe('Store Review Query Resolver (real DB)', () => {
expect(result.items[0].isLiked).toBe(false);
});

it('storeReviews: sort=LIKES가 service까지 전달되어 좋아요순으로 반환한다', async () => {
const store = await createStore(prisma);
const unpopular = await makeReview(store.id);
const popular = await makeReview(store.id);
const liker = await createAccount(prisma, { account_type: 'USER' });
await prisma.reviewLike.create({
data: { review_id: popular.id, account_id: liker.id },
});

const result = await resolver.storeReviews(
{ storeId: store.id.toString(), sort: 'LIKES' },
undefined,
);

expect(result.items.map((r) => r.id)).toEqual([
popular.id.toString(),
unpopular.id.toString(),
]);
});

it('storeReviews: photoOnly 필터가 service까지 전달된다', async () => {
const store = await createStore(prisma);
await makeReview(store.id);
Expand Down
129 changes: 129 additions & 0 deletions src/features/store/services/store-review.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import type { PrismaClient } from '@prisma/client';

import { StoreReviewRepository } from '@/features/store/repositories/store-review.repository';
Expand Down Expand Up @@ -56,6 +57,26 @@ describe('StoreReviewService (real DB)', () => {
});
}

async function addLikes(reviewId: bigint, count: number) {
for (let i = 0; i < count; i += 1) {
const liker = await createAccount(prisma, { account_type: 'USER' });
await prisma.reviewLike.create({
data: { review_id: reviewId, account_id: liker.id },
});
}
}

async function addMedia(reviewId: bigint) {
await prisma.reviewMedia.create({
data: {
review_id: reviewId,
media_type: 'IMAGE',
media_url: 'a.png',
sort_order: 0,
},
});
}

it('리뷰가 없으면 빈 목록과 totalCount/photoTotalCount 0', async () => {
const store = await createStore(prisma);
const result = await service.storeReviews({ storeId: store.id.toString() });
Expand Down Expand Up @@ -218,6 +239,114 @@ describe('StoreReviewService (real DB)', () => {
expect(second.nextCursor).toBeNull();
});

it('좋아요순 정렬: soft-delete 좋아요 제외 집계, 동률이면 최신순', async () => {
const store = await createStore(prisma);
const zeroLikes = await makeReview(store.id, {});
const twoLikes = await makeReview(store.id, {});
const threeLikes = await makeReview(store.id, {});
await addLikes(twoLikes.id, 2);
await addLikes(threeLikes.id, 3);
// soft-delete된 좋아요는 집계에서 제외 → twoLikes는 2개 유지
const canceledLiker = await createAccount(prisma, { account_type: 'USER' });
await prisma.reviewLike.create({
data: {
review_id: twoLikes.id,
account_id: canceledLiker.id,
deleted_at: new Date(),
},
});

const result = await service.storeReviews({
storeId: store.id.toString(),
sort: 'LIKES',
});

expect(result.items.map((r) => r.id)).toEqual([
threeLikes.id.toString(),
twoLikes.id.toString(),
zeroLikes.id.toString(),
]);
expect(result.items.map((r) => r.likeCount)).toEqual([3, 2, 0]);
});

it('좋아요순 + photoOnly 조합: 사진 리뷰만 좋아요순으로 반환한다', async () => {
const store = await createStore(prisma);
const textOnlyPopular = await makeReview(store.id, {});
await addLikes(textOnlyPopular.id, 5);
const photoFew = await makeReview(store.id, {});
await addMedia(photoFew.id);
await addLikes(photoFew.id, 1);
const photoMany = await makeReview(store.id, {});
await addMedia(photoMany.id);
await addLikes(photoMany.id, 3);

const result = await service.storeReviews({
storeId: store.id.toString(),
sort: 'LIKES',
photoOnly: true,
});

// 좋아요 5개인 텍스트 리뷰는 photoOnly에서 제외된다
expect(result.items.map((r) => r.id)).toEqual([
photoMany.id.toString(),
photoFew.id.toString(),
]);
});

it('좋아요순 커서: (likeCount, id) 키셋으로 다음 페이지를 이어받는다', async () => {
const store = await createStore(prisma);
const reviewA = await makeReview(store.id, {});
const reviewB = await makeReview(store.id, {});
const reviewC = await makeReview(store.id, {});
await addLikes(reviewA.id, 2);
await addLikes(reviewB.id, 2);
await addLikes(reviewC.id, 1);

// 동률(2)은 id desc → B, A 순. limit=2로 첫 페이지 [B, A]
const page1 = await service.storeReviews({
storeId: store.id.toString(),
sort: 'LIKES',
limit: 2,
});
expect(page1.items.map((r) => r.id)).toEqual([
reviewB.id.toString(),
reviewA.id.toString(),
]);
expect(page1.hasMore).toBe(true);
expect(page1.nextCursor).toBe(`2:${reviewA.id.toString()}`);

const page2 = await service.storeReviews({
storeId: store.id.toString(),
sort: 'LIKES',
limit: 2,
cursor: page1.nextCursor ?? undefined,
});
expect(page2.items.map((r) => r.id)).toEqual([reviewC.id.toString()]);
expect(page2.hasMore).toBe(false);
expect(page2.nextCursor).toBeNull();
});

it('좋아요순 커서 형식이 잘못되면 BAD_USER_INPUT', async () => {
const store = await createStore(prisma);

await expect(
service.storeReviews({
storeId: store.id.toString(),
sort: 'LIKES',
cursor: 'abc',
}),
).rejects.toThrow(BadRequestException);

// 자릿수 폭탄: 안전 정수 범위를 벗어난 likeCount는 형식 오류로 거부
await expect(
service.storeReviews({
storeId: store.id.toString(),
sort: 'LIKES',
cursor: `${'1'.repeat(400)}:1`,
}),
).rejects.toThrow(BadRequestException);
});

it('비활성/삭제 매장의 리뷰는 목록·카운트에서 제외한다', async () => {
const store = await createStore(prisma, { is_active: false });
await makeReview(store.id, {});
Expand Down
Loading
Loading