feat(ADM-018): completed feature
This commit is contained in:
115
project/src/modules/reviews/api/reviews.routes.ts
Normal file
115
project/src/modules/reviews/api/reviews.routes.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type pg from 'pg';
|
||||
import { z } from 'zod';
|
||||
import type { Authenticate } from '../../../shared/auth.js';
|
||||
import { requireRole } from '../../../shared/auth.js';
|
||||
import { AppError } from '../../../shared/errors.js';
|
||||
import { parseJson } from '../../../shared/http-input.js';
|
||||
import { ReviewsService } from '../application/reviews-service.js';
|
||||
import {
|
||||
DuplicateReviewError,
|
||||
InvalidReviewError,
|
||||
ReviewNotEligibleError,
|
||||
ReviewNotFoundError,
|
||||
} from '../domain/errors.js';
|
||||
import { PgOrderItemVerifier } from '../infrastructure/pg-order-item-verifier.js';
|
||||
import { PgReviewsRepository } from '../infrastructure/pg-reviews-repository.js';
|
||||
|
||||
export interface ReviewsRoutesDeps {
|
||||
pool: pg.Pool;
|
||||
authenticate: Authenticate;
|
||||
}
|
||||
|
||||
const submitSchema = z
|
||||
.object({
|
||||
productId: z.uuid(),
|
||||
orderItemId: z.uuid(),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
title: z.string().min(1).max(200),
|
||||
body: z.string().min(1).max(5000),
|
||||
})
|
||||
.strip();
|
||||
|
||||
const moderateSchema = z.object({ status: z.enum(['published', 'rejected']) });
|
||||
const moderateParamSchema = z.object({ id: z.uuid() });
|
||||
const productIdQuerySchema = z.object({ productId: z.uuid() });
|
||||
const adminListSchema = z.object({
|
||||
status: z.enum(['pending', 'published', 'rejected']).optional(),
|
||||
limit: z.coerce.number().int().positive().max(100).optional().default(20),
|
||||
offset: z.coerce.number().int().min(0).optional().default(0),
|
||||
});
|
||||
|
||||
export async function registerReviewsRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: ReviewsRoutesDeps,
|
||||
): Promise<void> {
|
||||
const repository = new PgReviewsRepository(deps.pool);
|
||||
const verifier = new PgOrderItemVerifier(deps.pool);
|
||||
const service = new ReviewsService(repository, verifier);
|
||||
|
||||
app.post('/reviews', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
const input = parseJson(submitSchema, request.body);
|
||||
try {
|
||||
const review = await service.submit({ ...input, userId: user.id });
|
||||
return reply.code(201).send(serializeReview(review));
|
||||
} catch (error) {
|
||||
throw mapError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/reviews/:id/moderate', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { id } = moderateParamSchema.parse(request.params);
|
||||
const { status } = moderateSchema.parse(request.body);
|
||||
try {
|
||||
const review = await service.moderate(id, status);
|
||||
return reply.send(serializeReview(review));
|
||||
} catch (error) {
|
||||
throw mapError(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/reviews', async (request, reply) => {
|
||||
const { productId } = productIdQuerySchema.parse(request.query);
|
||||
const result = await service.listPublishedByProduct(productId);
|
||||
return reply.send(result);
|
||||
});
|
||||
|
||||
app.get('/reviews/admin', async (request, reply) => {
|
||||
const user = await deps.authenticate(request);
|
||||
requireRole(user, 'admin');
|
||||
const { status, limit, offset } = adminListSchema.parse(request.query ?? {});
|
||||
const result = await service.listAll({ status, limit, offset });
|
||||
return reply.send(result);
|
||||
});
|
||||
}
|
||||
|
||||
function mapError(error: unknown): Error {
|
||||
if (error instanceof DuplicateReviewError)
|
||||
return new AppError(409, 'REVIEW_DUPLICATE', error.message);
|
||||
if (error instanceof ReviewNotEligibleError)
|
||||
return new AppError(403, 'REVIEW_NOT_ELIGIBLE', error.message);
|
||||
if (error instanceof ReviewNotFoundError)
|
||||
return new AppError(404, 'REVIEW_NOT_FOUND', error.message);
|
||||
if (error instanceof InvalidReviewError)
|
||||
return new AppError(422, 'INVALID_REVIEW', error.message);
|
||||
return error instanceof Error ? error : new Error('Unknown review error');
|
||||
}
|
||||
|
||||
function serializeReview(review: ReturnType<typeof Object>) {
|
||||
return {
|
||||
id: (review as { id: string }).id,
|
||||
productId: (review as { productId: string }).productId,
|
||||
userId: (review as { userId: string }).userId,
|
||||
orderId: (review as { orderId: string }).orderId,
|
||||
orderItemId: (review as { orderItemId: string }).orderItemId,
|
||||
rating: (review as { rating: number }).rating,
|
||||
title: (review as { title: string }).title,
|
||||
body: (review as { body: string }).body,
|
||||
status: (review as { status: string }).status,
|
||||
createdAt: (review as { createdAt: Date }).createdAt.toISOString(),
|
||||
updatedAt: (review as { updatedAt: Date }).updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
51
project/src/modules/reviews/application/reviews-service.ts
Normal file
51
project/src/modules/reviews/application/reviews-service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
DuplicateReviewError,
|
||||
InvalidReviewError,
|
||||
ReviewNotEligibleError,
|
||||
ReviewNotFoundError,
|
||||
} from '../domain/errors.js';
|
||||
import type { OrderItemVerifier, ReviewsRepository } from '../domain/ports.js';
|
||||
import type {
|
||||
ProductReviewAggregate,
|
||||
PublishedReview,
|
||||
Review,
|
||||
SubmitReviewCommand,
|
||||
} from '../domain/review.js';
|
||||
|
||||
export class ReviewsService {
|
||||
constructor(
|
||||
private readonly repo: ReviewsRepository,
|
||||
private readonly verifier: OrderItemVerifier,
|
||||
) {}
|
||||
|
||||
async submit(command: SubmitReviewCommand): Promise<Review> {
|
||||
if (!Number.isInteger(command.rating) || command.rating < 1 || command.rating > 5) {
|
||||
throw new InvalidReviewError('Rating must be an integer 1..5');
|
||||
}
|
||||
const existing = await this.repo.findByOrderItemId(command.orderItemId);
|
||||
if (existing) throw new DuplicateReviewError();
|
||||
const eligible = await this.verifier.isDeliveredForUser(command.userId, command.orderItemId);
|
||||
if (!eligible) throw new ReviewNotEligibleError();
|
||||
const inserted = await this.repo.insert(command);
|
||||
if (!inserted) throw new DuplicateReviewError();
|
||||
return inserted;
|
||||
}
|
||||
|
||||
async moderate(id: string, status: 'published' | 'rejected'): Promise<Review> {
|
||||
const updated = await this.repo.setStatus(id, status);
|
||||
if (!updated) throw new ReviewNotFoundError();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async listPublishedByProduct(
|
||||
productId: string,
|
||||
): Promise<{ items: PublishedReview[]; aggregate: ProductReviewAggregate }> {
|
||||
const items = await this.repo.listPublishedByProduct(productId);
|
||||
const aggregate = await this.repo.aggregate(productId);
|
||||
return { items, aggregate };
|
||||
}
|
||||
|
||||
async listAll(params?: { status?: string; limit?: number; offset?: number }) {
|
||||
return this.repo.listAll(params);
|
||||
}
|
||||
}
|
||||
27
project/src/modules/reviews/domain/errors.ts
Normal file
27
project/src/modules/reviews/domain/errors.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export class ReviewNotEligibleError extends Error {
|
||||
constructor() {
|
||||
super('Only delivered order items can be reviewed');
|
||||
this.name = 'ReviewNotEligibleError';
|
||||
}
|
||||
}
|
||||
|
||||
export class DuplicateReviewError extends Error {
|
||||
constructor() {
|
||||
super('A review already exists for this order item');
|
||||
this.name = 'DuplicateReviewError';
|
||||
}
|
||||
}
|
||||
|
||||
export class ReviewNotFoundError extends Error {
|
||||
constructor() {
|
||||
super('Review not found');
|
||||
this.name = 'ReviewNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidReviewError extends Error {
|
||||
constructor(message = 'Invalid review') {
|
||||
super(message);
|
||||
this.name = 'InvalidReviewError';
|
||||
}
|
||||
}
|
||||
19
project/src/modules/reviews/domain/ports.ts
Normal file
19
project/src/modules/reviews/domain/ports.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type {
|
||||
ProductReviewAggregate,
|
||||
PublishedReview,
|
||||
Review,
|
||||
SubmitReviewCommand,
|
||||
} from './review.js';
|
||||
|
||||
export interface ReviewsRepository {
|
||||
insert(review: SubmitReviewCommand): Promise<Review | undefined>;
|
||||
setStatus(id: string, status: 'published' | 'rejected'): Promise<Review | undefined>;
|
||||
listPublishedByProduct(productId: string): Promise<PublishedReview[]>;
|
||||
listAll(params?: { status?: string; limit?: number; offset?: number }): Promise<{ items: Review[]; total: number }>;
|
||||
aggregate(productId: string): Promise<ProductReviewAggregate>;
|
||||
findByOrderItemId(orderItemId: string): Promise<Review | undefined>;
|
||||
}
|
||||
|
||||
export interface OrderItemVerifier {
|
||||
isDeliveredForUser(userId: string, orderItemId: string): Promise<boolean>;
|
||||
}
|
||||
40
project/src/modules/reviews/domain/review.ts
Normal file
40
project/src/modules/reviews/domain/review.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export type ReviewStatus = 'pending' | 'published' | 'rejected';
|
||||
|
||||
export interface Review {
|
||||
id: string;
|
||||
userId: string;
|
||||
productId: string;
|
||||
orderId: string;
|
||||
orderItemId: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
body: string;
|
||||
status: ReviewStatus;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface SubmitReviewCommand {
|
||||
userId: string;
|
||||
productId: string;
|
||||
orderItemId: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface ProductReviewAggregate {
|
||||
productId: string;
|
||||
averageRating: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface PublishedReview {
|
||||
id: string;
|
||||
productId: string;
|
||||
userId: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
body: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
19
project/src/modules/reviews/index.ts
Normal file
19
project/src/modules/reviews/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/** Public API of the reviews module. */
|
||||
export { registerReviewsRoutes, type ReviewsRoutesDeps } from './api/reviews.routes.js';
|
||||
export { ReviewsService } from './application/reviews-service.js';
|
||||
export { PgReviewsRepository } from './infrastructure/pg-reviews-repository.js';
|
||||
export { PgOrderItemVerifier } from './infrastructure/pg-order-item-verifier.js';
|
||||
export {
|
||||
ReviewNotEligibleError,
|
||||
DuplicateReviewError,
|
||||
ReviewNotFoundError,
|
||||
InvalidReviewError,
|
||||
} from './domain/errors.js';
|
||||
export type { ReviewsRepository, OrderItemVerifier } from './domain/ports.js';
|
||||
export type {
|
||||
Review,
|
||||
ReviewStatus,
|
||||
SubmitReviewCommand,
|
||||
PublishedReview,
|
||||
ProductReviewAggregate,
|
||||
} from './domain/review.js';
|
||||
@@ -0,0 +1,22 @@
|
||||
import type pg from 'pg';
|
||||
import type { OrderItemVerifier } from '../domain/ports.js';
|
||||
|
||||
/**
|
||||
* Verifies that an order item is delivered and owned by the user. Order items
|
||||
* are considered delivered when their order state is DELIVERED.
|
||||
*/
|
||||
export class PgOrderItemVerifier implements OrderItemVerifier {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async isDeliveredForUser(userId: string, orderItemId: string): Promise<boolean> {
|
||||
const result = await this.pool.query<{ exists: boolean }>(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM orders_items oi
|
||||
JOIN orders_orders oo ON oo.id = oi.order_id
|
||||
WHERE oi.id = $1 AND oo.user_id = $2 AND oo.state = 'DELIVERED'
|
||||
) AS exists`,
|
||||
[orderItemId, userId],
|
||||
);
|
||||
return result.rows[0]?.exists ?? false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type pg from 'pg';
|
||||
import type { ReviewsRepository } from '../domain/ports.js';
|
||||
import type {
|
||||
ProductReviewAggregate,
|
||||
PublishedReview,
|
||||
Review,
|
||||
ReviewStatus,
|
||||
SubmitReviewCommand,
|
||||
} from '../domain/review.js';
|
||||
|
||||
interface ReviewRow {
|
||||
id: string;
|
||||
user_id: string;
|
||||
product_id: string;
|
||||
order_id: string;
|
||||
order_item_id: string;
|
||||
rating: number;
|
||||
title: string;
|
||||
body: string;
|
||||
status: ReviewStatus;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
}
|
||||
|
||||
export class PgReviewsRepository implements ReviewsRepository {
|
||||
constructor(private readonly pool: pg.Pool) {}
|
||||
|
||||
async insert(command: SubmitReviewCommand): Promise<Review | undefined> {
|
||||
const result = await this.pool.query<ReviewRow>(
|
||||
`INSERT INTO reviews_reviews (user_id, product_id, order_id, order_item_id, rating, title, body)
|
||||
VALUES ($1, $2, (SELECT order_id FROM orders_items WHERE id = $3), $3, $4, $5, $6)
|
||||
ON CONFLICT (order_item_id) DO NOTHING
|
||||
RETURNING *`,
|
||||
[
|
||||
command.userId,
|
||||
command.productId,
|
||||
command.orderItemId,
|
||||
command.rating,
|
||||
command.title,
|
||||
command.body,
|
||||
],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toReview(row) : undefined;
|
||||
}
|
||||
|
||||
async setStatus(id: string, status: 'published' | 'rejected'): Promise<Review | undefined> {
|
||||
const result = await this.pool.query<ReviewRow>(
|
||||
`UPDATE reviews_reviews SET status = $2, updated_at = now() WHERE id = $1 RETURNING *`,
|
||||
[id, status],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toReview(row) : undefined;
|
||||
}
|
||||
|
||||
async listPublishedByProduct(productId: string): Promise<PublishedReview[]> {
|
||||
const result = await this.pool.query<ReviewRow>(
|
||||
`SELECT * FROM reviews_reviews WHERE product_id = $1 AND status = 'published' ORDER BY created_at DESC`,
|
||||
[productId],
|
||||
);
|
||||
return result.rows.map(toPublishedReview);
|
||||
}
|
||||
|
||||
async aggregate(productId: string): Promise<ProductReviewAggregate> {
|
||||
const result = await this.pool.query<{ product_id: string; average: string; count: string }>(
|
||||
`SELECT $1::uuid AS product_id,
|
||||
COALESCE(AVG(rating), 0)::float AS average,
|
||||
COUNT(*)::int AS count
|
||||
FROM reviews_reviews WHERE product_id = $1 AND status = 'published'`,
|
||||
[productId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return {
|
||||
productId: row?.product_id ?? productId,
|
||||
averageRating: Number(row?.average ?? 0),
|
||||
count: Number(row?.count ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
async findByOrderItemId(orderItemId: string): Promise<Review | undefined> {
|
||||
const result = await this.pool.query<ReviewRow>(
|
||||
'SELECT * FROM reviews_reviews WHERE order_item_id = $1',
|
||||
[orderItemId],
|
||||
);
|
||||
const row = result.rows[0];
|
||||
return row ? toReview(row) : undefined;
|
||||
}
|
||||
|
||||
async listAll(params?: {
|
||||
status?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{ items: Review[]; total: number }> {
|
||||
const conditions: string[] = [];
|
||||
const values: unknown[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (params?.status) {
|
||||
conditions.push(`status = $${idx++}`);
|
||||
values.push(params.status);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const limit = params?.limit ?? 20;
|
||||
const offset = params?.offset ?? 0;
|
||||
|
||||
const countResult = await this.pool.query<{ count: string }>(
|
||||
`SELECT COUNT(*)::text AS count FROM reviews_reviews ${where}`,
|
||||
values,
|
||||
);
|
||||
const total = parseInt(countResult.rows[0]?.count ?? '0', 10);
|
||||
|
||||
const result = await this.pool.query<ReviewRow>(
|
||||
`SELECT * FROM reviews_reviews ${where} ORDER BY created_at DESC LIMIT $${idx++} OFFSET $${idx}`,
|
||||
[...values, limit, offset],
|
||||
);
|
||||
|
||||
return { items: result.rows.map(toReview), total };
|
||||
}
|
||||
}
|
||||
|
||||
function toReview(row: ReviewRow): Review {
|
||||
return {
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
productId: row.product_id,
|
||||
orderId: row.order_id,
|
||||
orderItemId: row.order_item_id,
|
||||
rating: row.rating,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toPublishedReview(row: ReviewRow): PublishedReview {
|
||||
return {
|
||||
id: row.id,
|
||||
productId: row.product_id,
|
||||
userId: row.user_id,
|
||||
rating: row.rating,
|
||||
title: row.title,
|
||||
body: row.body,
|
||||
createdAt: row.created_at,
|
||||
};
|
||||
}
|
||||
23
project/src/modules/reviews/tests/boundary.test.ts
Normal file
23
project/src/modules/reviews/tests/boundary.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function sourceFiles(dir: string): string[] {
|
||||
return readdirSync(dir).flatMap((entry) => {
|
||||
const path = join(dir, entry);
|
||||
return statSync(path).isDirectory() ? sourceFiles(path) : path.endsWith('.ts') ? [path] : [];
|
||||
});
|
||||
}
|
||||
|
||||
describe('reviews persistence boundary', () => {
|
||||
it('does not own other module tables', () => {
|
||||
const dir = new URL('..', import.meta.url);
|
||||
for (const file of sourceFiles(dir.pathname)) {
|
||||
if (!file.endsWith('.ts') || file.endsWith('boundary.test.ts')) continue;
|
||||
const source = readFileSync(file, 'utf8');
|
||||
expect(source).not.toMatch(
|
||||
/catalog_|inventory_|pricing_|shipping_|promotions_|cart_|users_addresses/,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
115
project/src/modules/reviews/tests/reviews-service.test.ts
Normal file
115
project/src/modules/reviews/tests/reviews-service.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ReviewsService } from '../application/reviews-service.js';
|
||||
import {
|
||||
DuplicateReviewError,
|
||||
InvalidReviewError,
|
||||
ReviewNotEligibleError,
|
||||
ReviewNotFoundError,
|
||||
} from '../domain/errors.js';
|
||||
import type { OrderItemVerifier, ReviewsRepository } from '../domain/ports.js';
|
||||
import type {
|
||||
ProductReviewAggregate,
|
||||
PublishedReview,
|
||||
Review,
|
||||
SubmitReviewCommand,
|
||||
} from '../domain/review.js';
|
||||
|
||||
const REVIEW: Review = {
|
||||
id: 'review-1',
|
||||
userId: 'user-1',
|
||||
productId: 'product-1',
|
||||
orderId: 'order-1',
|
||||
orderItemId: 'item-1',
|
||||
rating: 5,
|
||||
title: 'Great',
|
||||
body: 'Excellent product',
|
||||
status: 'pending',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
function repo(overrides: Partial<ReviewsRepository> = {}): ReviewsRepository {
|
||||
return {
|
||||
insert: async () => REVIEW,
|
||||
setStatus: async () => ({ ...REVIEW, status: 'published' }),
|
||||
listPublishedByProduct: async () => [],
|
||||
listAll: async () => ({ items: [], total: 0 }),
|
||||
aggregate: async () =>
|
||||
({ productId: 'product-1', averageRating: 0, count: 0 }) satisfies ProductReviewAggregate,
|
||||
findByOrderItemId: async () => undefined,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function verifier(eligible: boolean): OrderItemVerifier {
|
||||
return { isDeliveredForUser: async () => eligible };
|
||||
}
|
||||
|
||||
const SUBMIT: SubmitReviewCommand = {
|
||||
userId: 'user-1',
|
||||
productId: 'product-1',
|
||||
orderItemId: 'item-1',
|
||||
rating: 5,
|
||||
title: 'Great',
|
||||
body: 'Excellent product',
|
||||
};
|
||||
|
||||
describe('ReviewsService', () => {
|
||||
it('rejects submission when order item is not delivered (AC1)', async () => {
|
||||
const service = new ReviewsService(repo(), verifier(false));
|
||||
await expect(service.submit(SUBMIT)).rejects.toBeInstanceOf(ReviewNotEligibleError);
|
||||
});
|
||||
|
||||
it('rejects duplicate review (AC2)', async () => {
|
||||
const service = new ReviewsService(
|
||||
repo({ findByOrderItemId: async () => REVIEW }),
|
||||
verifier(true),
|
||||
);
|
||||
await expect(service.submit(SUBMIT)).rejects.toBeInstanceOf(DuplicateReviewError);
|
||||
});
|
||||
|
||||
it('rejects invalid rating', async () => {
|
||||
const service = new ReviewsService(repo(), verifier(true));
|
||||
await expect(service.submit({ ...SUBMIT, rating: 6 })).rejects.toBeInstanceOf(
|
||||
InvalidReviewError,
|
||||
);
|
||||
});
|
||||
|
||||
it('creates review when eligible', async () => {
|
||||
const service = new ReviewsService(repo(), verifier(true));
|
||||
const review = await service.submit(SUBMIT);
|
||||
expect(review.id).toBe('review-1');
|
||||
});
|
||||
|
||||
it('moderates review and throws when missing', async () => {
|
||||
const service = new ReviewsService(repo({ setStatus: async () => undefined }), verifier(true));
|
||||
await expect(service.moderate('missing', 'published')).rejects.toBeInstanceOf(
|
||||
ReviewNotFoundError,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns published reviews with aggregate', async () => {
|
||||
const aggregate: ProductReviewAggregate = {
|
||||
productId: 'product-1',
|
||||
averageRating: 4.5,
|
||||
count: 2,
|
||||
};
|
||||
const items: PublishedReview[] = [
|
||||
{
|
||||
id: REVIEW.id,
|
||||
productId: REVIEW.productId,
|
||||
userId: REVIEW.userId,
|
||||
rating: REVIEW.rating,
|
||||
title: REVIEW.title,
|
||||
body: REVIEW.body,
|
||||
createdAt: REVIEW.createdAt,
|
||||
},
|
||||
];
|
||||
const service = new ReviewsService(
|
||||
repo({ listPublishedByProduct: async () => items, aggregate: async () => aggregate }),
|
||||
verifier(true),
|
||||
);
|
||||
const result = await service.listPublishedByProduct('product-1');
|
||||
expect(result).toEqual({ items, aggregate });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user