chore: remediate CI blockers for production readiness

This commit is contained in:
Ho Ngoc Hai
2026-05-07 13:08:20 +07:00
parent f82806e06d
commit b35ec55126
32 changed files with 401 additions and 113 deletions

View File

@@ -11,7 +11,7 @@ set -e
if [ "${RUN_MIGRATIONS}" = "true" ]; then
echo "[entrypoint] Running Prisma migrations..."
npx prisma migrate deploy --schema ./prisma/schema.prisma
npx prisma migrate deploy --schema /app/prisma/schema.prisma
echo "[entrypoint] Migrations complete."
fi

View File

@@ -146,12 +146,14 @@ describe('UpdateListingStatusCommand', () => {
'listing-1',
'ACTIVE',
'user-1',
'ADMIN',
'Đã xác minh thông tin',
);
expect(command.listingId).toBe('listing-1');
expect(command.newStatus).toBe('ACTIVE');
expect(command.userId).toBe('user-1');
expect(command.userRole).toBe('ADMIN');
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
});

View File

@@ -52,7 +52,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1');
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'admin-1', 'ADMIN');
const result = await handler.execute(command);
expect(result.status).toBe('ACTIVE');
@@ -64,7 +64,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'Vi phạm chính sách');
const command = new UpdateListingStatusCommand('listing-1', 'REJECTED', 'admin-1', 'ADMIN', 'Vi phạm chính sách');
const result = await handler.execute(command);
expect(result.status).toBe('REJECTED');
@@ -74,7 +74,7 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
const result = await handler.execute(command);
expect(result.status).toBe('SOLD');
@@ -83,7 +83,7 @@ describe('UpdateListingStatusHandler', () => {
it('throws NotFoundException for non-existent listing', async () => {
mockListingRepo.findById.mockResolvedValue(null);
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1');
const command = new UpdateListingStatusCommand('nonexistent', 'ACTIVE', 'admin-1', 'ADMIN');
await expect(handler.execute(command)).rejects.toThrow('Listing');
});
@@ -92,8 +92,28 @@ describe('UpdateListingStatusHandler', () => {
const listing = createListing('listing-1', 'DRAFT');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1');
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'seller-1', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
});
it('rejects moderation transitions from non-admin users', async () => {
const listing = createListing('listing-1', 'PENDING_REVIEW');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'ACTIVE', 'seller-1', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/quản trị viên/);
expect(mockListingRepo.update).not.toHaveBeenCalled();
});
it('rejects status updates from non-owner users', async () => {
const listing = createListing('listing-1', 'ACTIVE');
mockListingRepo.findById.mockResolvedValue(listing);
const command = new UpdateListingStatusCommand('listing-1', 'SOLD', 'other-user', 'SELLER');
await expect(handler.execute(command)).rejects.toThrow(/người bán/);
expect(mockListingRepo.update).not.toHaveBeenCalled();
});
});

View File

@@ -5,6 +5,7 @@ export class UpdateListingStatusCommand {
public readonly listingId: string,
public readonly newStatus: ListingStatus,
public readonly userId: string,
public readonly userRole?: string,
public readonly moderationNotes?: string,
) {}
}

View File

@@ -1,6 +1,6 @@
import { Inject, InternalServerErrorException } from '@nestjs/common';
import { CommandHandler, EventBus, type ICommandHandler } from '@nestjs/cqrs';
import { DomainException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { DomainException, ForbiddenException, NotFoundException, CacheService, CachePrefix, LoggerService } from '@modules/shared';
import { LISTING_REPOSITORY, type IListingRepository } from '../../../domain/repositories/listing.repository';
import { ModerationService } from '../../../domain/services/moderation.service';
import { UpdateListingStatusCommand } from './update-listing-status.command';
@@ -22,6 +22,23 @@ export class UpdateListingStatusHandler implements ICommandHandler<UpdateListing
throw new NotFoundException('Listing', command.listingId);
}
const isAdmin = command.userRole === 'ADMIN';
const isOwner = listing.sellerId === command.userId;
const isAssignedAgent = listing.agentId !== null && listing.agentId === command.userId;
const isModerationTransition =
(listing.status === 'PENDING_REVIEW' && command.newStatus === 'ACTIVE') ||
command.newStatus === 'REJECTED';
if (isModerationTransition && !isAdmin) {
throw new ForbiddenException('Chỉ quản trị viên mới có thể duyệt hoặc từ chối tin đăng');
}
if (!isAdmin && !isOwner && !isAssignedAgent) {
throw new ForbiddenException(
'Chỉ người bán, môi giới được giao hoặc quản trị viên mới có thể cập nhật trạng thái tin đăng',
);
}
this.moderationService.applyStatusTransition(
listing,
command.newStatus,

View File

@@ -387,7 +387,7 @@ export class ListingsController {
@CurrentUser() user: JwtPayload,
): Promise<{ status: string }> {
return this.commandBus.execute(
new UpdateListingStatusCommand(id, dto.status, user.sub, dto.moderationNotes),
new UpdateListingStatusCommand(id, dto.status, user.sub, user.role, dto.moderationNotes),
);
}

View File

@@ -90,6 +90,11 @@ describe('middleware authentication guard', () => {
expect(mockRedirectFn).not.toHaveBeenCalled();
});
it('allows unauthenticated user to reach /pricing', () => {
middleware(makeRequest('/pricing', false));
expect(mockRedirectFn).not.toHaveBeenCalled();
});
it('allows unauthenticated user to reach /login', () => {
middleware(makeRequest('/login', false));
expect(mockRedirectFn).not.toHaveBeenCalled();

View File

@@ -16,21 +16,28 @@ import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
const DEMO_PASSWORD = 'Velik@2026';
const ENABLE_DEMO_ACCOUNTS = process.env['NEXT_PUBLIC_ENABLE_DEMO_ACCOUNTS'] === 'true';
const DEMO_PASSWORD = process.env['NEXT_PUBLIC_DEMO_PASSWORD'] ?? '';
const DEMO_ACCOUNTS: {
type DemoAccount = {
phone: string;
name: string;
role: 'ADMIN' | 'AGENT' | 'SELLER' | 'BUYER' | 'DEVELOPER' | 'PARK_OPERATOR';
badgeClass: string;
}[] = [
{ phone: '+84876677771', name: 'Hồ Ngọc Hải', role: 'ADMIN', badgeClass: 'bg-red-500/10 text-red-600 border-red-500/20' },
{ phone: '+84900000002', name: 'Nguyễn Văn An', role: 'AGENT', badgeClass: 'bg-blue-500/10 text-blue-600 border-blue-500/20' },
{ phone: '+84900000005', name: 'Phạm Đức Dũng', role: 'SELLER', badgeClass: 'bg-amber-500/10 text-amber-600 border-amber-500/20' },
{ phone: '+84900000004', name: 'Lê Minh Cường', role: 'BUYER', badgeClass: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20' },
{ phone: '+84912000001', name: 'CĐT Vingroup', role: 'DEVELOPER', badgeClass: 'bg-violet-500/10 text-violet-600 border-violet-500/20' },
{ phone: '+84912000002', name: 'KCN VSIP', role: 'PARK_OPERATOR', badgeClass: 'bg-cyan-500/10 text-cyan-600 border-cyan-500/20' },
];
};
function parseDemoAccounts(): DemoAccount[] {
const raw = process.env['NEXT_PUBLIC_DEMO_ACCOUNTS'];
if (!raw) return [];
try {
const parsed = JSON.parse(raw) as DemoAccount[];
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
const DEMO_ACCOUNTS = parseDemoAccounts();
export default function LoginPage() {
const router = useRouter();
@@ -39,6 +46,7 @@ export default function LoginPage() {
const [showPassword, setShowPassword] = useState(false);
const [demoOpen, setDemoOpen] = useState(true);
const t = useTranslations('auth');
const showDemoAccounts = ENABLE_DEMO_ACCOUNTS && DEMO_PASSWORD && DEMO_ACCOUNTS.length > 0;
const oauthError = searchParams.get('error');
const oauthErrorMessage = oauthError
@@ -76,48 +84,49 @@ export default function LoginPage() {
<CardDescription>{t('loginDescription')}</CardDescription>
</CardHeader>
<CardContent>
{/* Demo accounts panel — MVP only */}
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5">
<button
type="button"
onClick={() => setDemoOpen(!demoOpen)}
className="flex w-full items-center justify-between px-3 py-2 text-sm font-medium"
aria-expanded={demoOpen}
>
<span className="inline-flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" aria-hidden="true" />
{t('demoAccountsTitle')}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${demoOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{demoOpen && (
<div className="space-y-2 border-t border-primary/20 p-3">
<p className="text-xs text-muted-foreground">
{t('demoAccountsHint')} <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{DEMO_PASSWORD}</code>
</p>
<ul className="flex flex-col gap-1.5">
{DEMO_ACCOUNTS.map((acc) => (
<li key={acc.phone}>
<button
type="button"
onClick={() => fillDemoAccount(acc.phone)}
className="flex w-full items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-left text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<Badge variant="outline" className={`shrink-0 ${acc.badgeClass}`}>
{acc.role}
</Badge>
<span className="flex-1 truncate font-medium">{acc.name}</span>
<span className="font-mono text-muted-foreground">{acc.phone}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
{showDemoAccounts && (
<div className="mb-4 rounded-lg border border-primary/20 bg-primary/5">
<button
type="button"
onClick={() => setDemoOpen(!demoOpen)}
className="flex w-full items-center justify-between px-3 py-2 text-sm font-medium"
aria-expanded={demoOpen}
>
<span className="inline-flex items-center gap-2">
<Sparkles className="h-4 w-4 text-primary" aria-hidden="true" />
{t('demoAccountsTitle')}
</span>
<ChevronDown
className={`h-4 w-4 text-muted-foreground transition-transform ${demoOpen ? 'rotate-180' : ''}`}
aria-hidden="true"
/>
</button>
{demoOpen && (
<div className="space-y-2 border-t border-primary/20 p-3">
<p className="text-xs text-muted-foreground">
{t('demoAccountsHint')} <code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">{DEMO_PASSWORD}</code>
</p>
<ul className="flex flex-col gap-1.5">
{DEMO_ACCOUNTS.map((acc) => (
<li key={acc.phone}>
<button
type="button"
onClick={() => fillDemoAccount(acc.phone)}
className="flex w-full items-center gap-2 rounded-md border bg-card px-2.5 py-1.5 text-left text-xs transition-colors hover:border-primary/40 hover:bg-primary/5"
>
<Badge variant="outline" className={`shrink-0 ${acc.badgeClass}`}>
{acc.role}
</Badge>
<span className="flex-1 truncate font-medium">{acc.name}</span>
<span className="font-mono text-muted-foreground">{acc.phone}</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{oauthErrorMessage && (

View File

@@ -15,6 +15,13 @@ vi.mock('next/link', () => ({
),
}));
// Mock locale-aware navigation links
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
}));
// Mock next/dynamic to render children directly
vi.mock('next/dynamic', () => ({
default: () => {

View File

@@ -1,7 +1,6 @@
'use client';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import * as React from 'react';
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
import { AiAdviceCards } from '@/components/listings/ai-advice-cards';
@@ -16,6 +15,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
import { Link } from '@/i18n/navigation';
import { analyticsApi, type NearbyPOI } from '@/lib/analytics-api';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
import { composeWhyThisLocation, derivePersonas } from '@/lib/listing-personas';

View File

@@ -132,6 +132,32 @@ describe('CheckoutModal', () => {
provider: 'VNPAY',
type: 'SUBSCRIPTION',
amountVND: 499000,
returnUrl: 'http://localhost:3000/vi/payment/return',
}),
);
});
});
it('uses the locale root payment return route from dashboard checkout', async () => {
Object.defineProperty(window, 'location', {
writable: true,
value: {
...window.location,
href: 'http://localhost:3000/vi/dashboard/subscription',
origin: 'http://localhost:3000',
pathname: '/vi/dashboard/subscription',
},
});
render(
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
);
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
await waitFor(() => {
expect(mockCreatePayment).toHaveBeenCalledWith(
expect.objectContaining({
returnUrl: 'http://localhost:3000/vi/payment/return',
}),
);
});

View File

@@ -120,7 +120,9 @@ function CheckoutModalInner({
}
// Step 2: Create payment and redirect to gateway
const returnUrl = `${window.location.origin}${window.location.pathname.replace(/\/pricing$/, '')}/payment/return`;
const localeMatch = window.location.pathname.match(/^\/(vi|en)(\/|$)/);
const localePrefix = localeMatch?.[1] ? `/${localeMatch[1]}` : '';
const returnUrl = `${window.location.origin}${localePrefix}/payment/return`;
const idempotencyKey = `sub-${plan.tier}-${billingCycle}-${Date.now()}`;

View File

@@ -176,7 +176,7 @@ export const adminApi = {
apiClient.get<UserDetail>(`/admin/users/${userId}`),
updateUserStatus: (userId: string, isActive: boolean, reason?: string) =>
apiClient.post<{ success: boolean }>('/admin/users/status', {
apiClient.patch<{ success: boolean }>('/admin/users/status', {
userId,
isActive,
reason,

View File

@@ -121,6 +121,9 @@ export const apiClient = {
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'PATCH', body, headers }),
put: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'PUT', body, headers }),
delete: <T>(endpoint: string, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'DELETE', headers }),
};

View File

@@ -268,7 +268,7 @@ export const listingsApi = {
},
updateStatus: (id: string, status: ListingStatus, moderationNotes?: string) =>
apiClient.post<{ status: string }>(`/listings/${id}/status`, {
apiClient.patch<{ status: string }>(`/listings/${id}/status`, {
status,
moderationNotes,
}),

View File

@@ -16,7 +16,9 @@ const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:300
export async function fetchListingById(id: string): Promise<ListingDetail | null> {
try {
const res = await fetch(`${API_BASE_URL}/listings/${id}`, {
next: { revalidate: 300 }, // ISR: re-validate every 5 min
// Listing detail includes mutable status, price, legal and moderation data.
// Avoid serving stale details after admin/user actions.
cache: 'no-store',
});
if (!res.ok) return null;

View File

@@ -71,7 +71,7 @@ export const subscriptionApi = {
}),
upgradeSubscription: (newPlanTier: string) =>
apiClient.post<{ message: string }>('/subscriptions/upgrade', {
apiClient.put<{ message: string }>('/subscriptions/upgrade', {
newPlanTier,
}),

View File

@@ -18,6 +18,7 @@ const publicPaths = [
'/du-an', // projects (real estate developments)
'/chuyen-nhuong', // property transfers
'/bang-gia', // pricing
'/pricing',
'/about',
'/contact',
'/privacy',