diff --git a/.claude/launch.json b/.claude/launch.json index 8ac5768..a403762 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,13 +5,21 @@ "name": "web", "runtimeExecutable": "pnpm", "runtimeArgs": ["--filter", "@goodgo/web", "dev"], - "port": 3000 + "port": 3200 }, { "name": "api", "runtimeExecutable": "env", - "runtimeArgs": ["NODE_OPTIONS=-r dotenv/config", "DOTENV_CONFIG_PATH=../../.env", "pnpm", "--filter", "@goodgo/api", "dev"], - "port": 3001 + "runtimeArgs": [ + "NODE_OPTIONS=-r dotenv/config", + "DOTENV_CONFIG_PATH=../../.env", + "PORT=3201", + "pnpm", + "--filter", + "@goodgo/api", + "dev" + ], + "port": 3201 }, { "name": "ai-services", diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 12bbd28..e4ae609 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { AuthModule } from '@modules/auth'; import { ListingsModule } from '@modules/listings'; @@ -65,7 +65,7 @@ const QueryHandlers = [ ]; @Module({ - imports: [CqrsModule, AuthModule, ListingsModule, SubscriptionsModule], + imports: [CqrsModule, AuthModule, forwardRef(() => ListingsModule), SubscriptionsModule], controllers: [ AdminController, AdminModerationController, diff --git a/apps/api/src/modules/analytics/analytics.module.ts b/apps/api/src/modules/analytics/analytics.module.ts index 9c23438..e6700e0 100644 --- a/apps/api/src/modules/analytics/analytics.module.ts +++ b/apps/api/src/modules/analytics/analytics.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { AdminModule } from '@modules/admin'; import { ListingsModule } from '@modules/listings'; @@ -17,6 +17,7 @@ import { GetMarketReportHandler } from './application/queries/get-market-report/ import { GetMarketHistoryHandler } from './application/queries/get-market-history/get-market-history.handler'; import { GetMarketSnapshotHandler } from './application/queries/get-market-snapshot/get-market-snapshot.handler'; import { GetPriceMoversHandler } from './application/queries/get-price-movers/get-price-movers.handler'; +import { GetTrendingAreasHandler } from './application/queries/get-trending-areas/get-trending-areas.handler'; import { GetProjectAiAdviceHandler } from './application/queries/get-project-ai-advice/get-project-ai-advice.handler'; import { GetNearbyPOIsHandler } from './application/queries/get-nearby-pois/get-nearby-pois.handler'; import { GetNeighborhoodScoreHandler } from './application/queries/get-neighborhood-score/get-neighborhood-score.handler'; @@ -69,6 +70,7 @@ const QueryHandlers = [ GetProjectAiAdviceHandler, GetMarketSnapshotHandler, GetPriceMoversHandler, + GetTrendingAreasHandler, ]; const EventHandlers = [ @@ -76,7 +78,7 @@ const EventHandlers = [ ]; @Module({ - imports: [CqrsModule, ListingsModule, AdminModule, ProjectsModule], + imports: [CqrsModule, forwardRef(() => ListingsModule), forwardRef(() => AdminModule), ProjectsModule], controllers: [AnalyticsController, AvmController], providers: [ // AI service client diff --git a/apps/api/src/modules/analytics/application/__tests__/get-trending-areas.handler.spec.ts b/apps/api/src/modules/analytics/application/__tests__/get-trending-areas.handler.spec.ts new file mode 100644 index 0000000..ef04901 --- /dev/null +++ b/apps/api/src/modules/analytics/application/__tests__/get-trending-areas.handler.spec.ts @@ -0,0 +1,119 @@ +import { type CacheService, type LoggerService } from '@modules/shared'; +import { GetTrendingAreasHandler } from '../queries/get-trending-areas/get-trending-areas.handler'; +import { GetTrendingAreasQuery } from '../queries/get-trending-areas/get-trending-areas.query'; + +describe('GetTrendingAreasHandler', () => { + let handler: GetTrendingAreasHandler; + let mockPrisma: { $queryRaw: ReturnType; marketIndex: { findMany: ReturnType } }; + let mockCache: Partial; + let mockLogger: Partial; + + beforeEach(() => { + mockPrisma = { + $queryRaw: vi.fn(), + marketIndex: { + findMany: vi.fn(), + }, + }; + // Bypass @Cacheable decorator by making CacheService.getOrSet call the loader directly + mockCache = { + getOrSet: vi.fn((_key: string, loader: () => Promise) => loader()), + } as unknown as Partial; + mockLogger = { error: vi.fn(), warn: vi.fn(), log: vi.fn() } as unknown as Partial; + + handler = new GetTrendingAreasHandler( + mockPrisma as any, + mockCache as CacheService, + mockLogger as LoggerService, + ); + }); + + it('returns top trending districts sorted by score', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'Quận 1', new_listings: BigInt(10), inquiries: BigInt(50), views: BigInt(200) }, + { district: 'Quận 7', new_listings: BigInt(20), inquiries: BigInt(30), views: BigInt(400) }, + { district: 'Bình Thạnh', new_listings: BigInt(5), inquiries: BigInt(5), views: BigInt(50) }, + ]); + mockPrisma.marketIndex.findMany.mockResolvedValue([ + { district: 'Quận 1', yoyChange: 0.12 }, + { district: 'Quận 7', yoyChange: 0.05 }, + ]); + + const query = new GetTrendingAreasQuery(7, 10, 'district'); + const result = await handler.execute(query); + + expect(result.period).toBe(7); + expect(result.level).toBe('district'); + expect(result.areas.length).toBe(3); + + // Quận 1 score = 50*0.6 + 200*0.3 + 10*0.1 = 30 + 60 + 1 = 91 + // Quận 7 score = 30*0.6 + 400*0.3 + 20*0.1 = 18 + 120 + 2 = 140 + // Bình Thạnh score = 5*0.6 + 50*0.3 + 5*0.1 = 3 + 15 + 0.5 = 18.5 + // Expected order: Quận 7 (1st), Quận 1 (2nd), Bình Thạnh (3rd) + expect(result.areas[0].districtId).toBe('Quận 7'); + expect(result.areas[0].scoreRank).toBe(1); + expect(result.areas[1].districtId).toBe('Quận 1'); + expect(result.areas[2].districtId).toBe('Bình Thạnh'); + }); + + it('respects the limit parameter', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'A', new_listings: BigInt(1), inquiries: BigInt(10), views: BigInt(100) }, + { district: 'B', new_listings: BigInt(1), inquiries: BigInt(8), views: BigInt(80) }, + { district: 'C', new_listings: BigInt(1), inquiries: BigInt(6), views: BigInt(60) }, + ]); + mockPrisma.marketIndex.findMany.mockResolvedValue([]); + + const query = new GetTrendingAreasQuery(7, 2, 'district'); + const result = await handler.execute(query); + + expect(result.areas.length).toBe(2); + expect(result.limit).toBe(2); + }); + + it('returns empty areas when no active listings in window', async () => { + mockPrisma.$queryRaw.mockResolvedValue([]); + mockPrisma.marketIndex.findMany.mockResolvedValue([]); + + const query = new GetTrendingAreasQuery(7, 10, 'district'); + const result = await handler.execute(query); + + expect(result.areas).toEqual([]); + expect(mockPrisma.marketIndex.findMany).not.toHaveBeenCalled(); + }); + + it('attaches yoyChange from market index as priceChangePct', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'Quận 1', new_listings: BigInt(5), inquiries: BigInt(20), views: BigInt(100) }, + ]); + mockPrisma.marketIndex.findMany.mockResolvedValue([ + { district: 'Quận 1', yoyChange: 0.08 }, + ]); + + const query = new GetTrendingAreasQuery(14, 10, 'district'); + const result = await handler.execute(query); + + expect(result.areas[0].priceChangePct).toBe(0.08); + }); + + it('sets priceChangePct to null when market index data is missing', async () => { + mockPrisma.$queryRaw.mockResolvedValue([ + { district: 'Huyện Củ Chi', new_listings: BigInt(3), inquiries: BigInt(5), views: BigInt(40) }, + ]); + mockPrisma.marketIndex.findMany.mockResolvedValue([]); + + const query = new GetTrendingAreasQuery(7, 10, 'district'); + const result = await handler.execute(query); + + expect(result.areas[0].priceChangePct).toBeNull(); + }); + + it('throws InternalServerErrorException on unexpected errors', async () => { + mockPrisma.$queryRaw.mockRejectedValue(new Error('DB connection lost')); + + const query = new GetTrendingAreasQuery(7, 10, 'district'); + await expect(handler.execute(query)).rejects.toThrow( + 'Không thể truy vấn khu vực xu hướng. Vui lòng thử lại sau.', + ); + }); +}); diff --git a/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts b/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts index c7da6f0..a6d32dd 100644 --- a/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts +++ b/apps/api/src/modules/analytics/application/event-handlers/listing-created-moderation.handler.ts @@ -1,6 +1,7 @@ import { Inject } from '@nestjs/common'; import { EventsHandler, type IEventHandler, CommandBus } from '@nestjs/cqrs'; -import { ListingCreatedEvent, ModerateListingCommand } from '@modules/listings'; +import { ListingCreatedEvent } from '@modules/listings/domain/events/listing-created.event'; +import { ModerateListingCommand } from '@modules/listings/application/commands/moderate-listing/moderate-listing.command'; import { PrismaService, LoggerService } from '@modules/shared'; import { AI_SERVICE_CLIENT, diff --git a/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts b/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts index 24967d0..1311565 100644 --- a/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-listing-ai-advice/get-listing-ai-advice.handler.ts @@ -1,11 +1,11 @@ import { HttpStatus, Inject } from '@nestjs/common'; import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { DomainException, ErrorCode, LoggerService } from '@modules/shared'; -import { SystemSettingsService } from '@modules/admin'; +import { SystemSettingsService } from '@modules/admin/application/services/system-settings.service'; import { LISTING_REPOSITORY, type IListingRepository, -} from '@modules/listings'; +} from '@modules/listings/domain/repositories/listing.repository'; import { type ListingDetailData } from '../../../../listings/domain/repositories/listing-read.dto'; import { type NearbyPOIDto, diff --git a/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts b/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts index e3aa0a2..01ef346 100644 --- a/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-price-movers/get-price-movers.handler.ts @@ -64,26 +64,26 @@ export class GetPriceMoversHandler implements IQueryHandler WITH current_window AS ( SELECT p.district, - AVG(l.price) AS avg_price, + AVG(l."priceVND") AS avg_price, COUNT(l.id) AS sample_size FROM "Listing" l INNER JOIN "Property" p ON p.id = l."propertyId" WHERE l."createdAt" >= ${currentStart} AND l.status = 'ACTIVE' - AND l.price > 0 + AND l."priceVND" > 0 GROUP BY p.district HAVING COUNT(l.id) >= 10 ), previous_window AS ( SELECT p.district, - AVG(l.price) AS avg_price + AVG(l."priceVND") AS avg_price FROM "Listing" l INNER JOIN "Property" p ON p.id = l."propertyId" WHERE l."createdAt" >= ${previousStart} AND l."createdAt" < ${currentStart} AND l.status = 'ACTIVE' - AND l.price > 0 + AND l."priceVND" > 0 GROUP BY p.district ) SELECT diff --git a/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts b/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts index 0ac8260..f4a1c47 100644 --- a/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts +++ b/apps/api/src/modules/analytics/application/queries/get-project-ai-advice/get-project-ai-advice.handler.ts @@ -1,7 +1,7 @@ import { HttpStatus, Inject } from '@nestjs/common'; import { QueryBus, QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; import { DomainException, ErrorCode, LoggerService } from '@modules/shared'; -import { SystemSettingsService } from '@modules/admin'; +import { SystemSettingsService } from '@modules/admin/application/services/system-settings.service'; import { PROJECT_REPOSITORY, type IProjectRepository, diff --git a/apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.handler.ts b/apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.handler.ts new file mode 100644 index 0000000..ec1a0ab --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.handler.ts @@ -0,0 +1,125 @@ +import { Inject, InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, CacheService, CachePrefix, CacheTTL, Cacheable, LoggerService, PrismaService } from '@modules/shared'; +import { GetTrendingAreasQuery } from './get-trending-areas.query'; + +export interface TrendingAreaItem { + districtId: string; + name: string; + listings: number; + inquiries: number; + views: number; + priceChangePct: number | null; + scoreRank: number; +} + +export interface TrendingAreasDto { + period: number; + level: string; + limit: number; + areas: TrendingAreaItem[]; +} + +interface RawDistrictRow { + district: string; + new_listings: bigint; + inquiries: bigint; + views: bigint; +} + +@QueryHandler(GetTrendingAreasQuery) +export class GetTrendingAreasHandler implements IQueryHandler { + constructor( + private readonly prisma: PrismaService, + private readonly cacheService: CacheService, + private readonly logger: LoggerService, + ) {} + + @Cacheable({ + prefix: CachePrefix.TRENDING_AREAS, + ttl: CacheTTL.TRENDING_AREAS, + resource: 'trending_areas', + keyFrom: (query: unknown) => { + const q = query as GetTrendingAreasQuery; + return [String(q.period), String(q.limit), q.level]; + }, + }) + async execute(query: GetTrendingAreasQuery): Promise { + const { period, limit, level } = query; + + try { + const since = new Date(Date.now() - period * 24 * 60 * 60 * 1000); + + // Aggregate new listings, inquiries, and views per district within the time window. + // Listing.viewCount is a running total so we use it as a proxy for views. + // Inquiry has createdAt that we can filter on. + // New listings = listings created within the window. + const rows = await this.prisma.$queryRaw` + SELECT + p.district, + COUNT(DISTINCT l.id) AS new_listings, + COUNT(DISTINCT i.id) AS inquiries, + COALESCE(SUM(l."viewCount"), 0) AS views + FROM "Listing" l + INNER JOIN "Property" p ON p.id = l."propertyId" + LEFT JOIN "Inquiry" i ON i."listingId" = l.id AND i."createdAt" >= ${since} + WHERE l."createdAt" >= ${since} + AND l.status = 'ACTIVE' + GROUP BY p.district + `; + + // Compute score for each district + const scored = rows.map((r) => { + const listings = Number(r.new_listings); + const inquiries = Number(r.inquiries); + const views = Number(r.views); + const score = inquiries * 0.6 + views * 0.3 + listings * 0.1; + return { district: r.district, listings, inquiries, views, score }; + }); + + // Sort descending by score, take top `limit` + scored.sort((a, b) => b.score - a.score); + const top = scored.slice(0, limit); + + // Fetch price change (yoyChange) from MarketIndex for these districts + const districts = top.map((r) => r.district); + const marketIndexes = districts.length > 0 + ? await this.prisma.marketIndex.findMany({ + where: { district: { in: districts } }, + orderBy: { createdAt: 'desc' }, + select: { district: true, yoyChange: true }, + }) + : []; + + // Build a map district → most recent yoyChange + const priceMap = new Map(); + for (const mi of marketIndexes) { + if (!priceMap.has(mi.district)) { + priceMap.set(mi.district, mi.yoyChange); + } + } + + const areas: TrendingAreaItem[] = top.map((r, idx) => ({ + districtId: r.district, + name: r.district, + listings: r.listings, + inquiries: r.inquiries, + views: r.views, + priceChangePct: priceMap.get(r.district) ?? null, + scoreRank: idx + 1, + })); + + return { period, level, limit, areas }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to truy vấn trending areas: ${error instanceof Error ? error.message : error}`, + error instanceof Error ? error.stack : undefined, + this.constructor.name, + ); + throw new InternalServerErrorException( + 'Không thể truy vấn khu vực xu hướng. Vui lòng thử lại sau.', + ); + } + } +} diff --git a/apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.query.ts b/apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.query.ts new file mode 100644 index 0000000..67607df --- /dev/null +++ b/apps/api/src/modules/analytics/application/queries/get-trending-areas/get-trending-areas.query.ts @@ -0,0 +1,10 @@ +export class GetTrendingAreasQuery { + constructor( + /** Number of days to look back, e.g. 7 | 14 | 30 */ + public readonly period: number, + /** Maximum number of results to return */ + public readonly limit: number, + /** Geographic level of aggregation — currently only 'district' is supported */ + public readonly level: 'district', + ) {} +} diff --git a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts index 6a64525..e8f750a 100644 --- a/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts +++ b/apps/api/src/modules/analytics/presentation/controllers/analytics.controller.ts @@ -38,6 +38,8 @@ import { type MarketSnapshotDto } from '../../application/queries/get-market-sna import { GetMarketSnapshotQuery } from '../../application/queries/get-market-snapshot/get-market-snapshot.query'; import { type PriceMoversDto } from '../../application/queries/get-price-movers/get-price-movers.handler'; import { GetPriceMoversQuery } from '../../application/queries/get-price-movers/get-price-movers.query'; +import { type TrendingAreasDto } from '../../application/queries/get-trending-areas/get-trending-areas.handler'; +import { GetTrendingAreasQuery } from '../../application/queries/get-trending-areas/get-trending-areas.query'; import { type NearbyPOIsResultDto } from '../../application/queries/get-nearby-pois/get-nearby-pois.handler'; import { GetNearbyPOIsQuery } from '../../application/queries/get-nearby-pois/get-nearby-pois.query'; import { GetNeighborhoodScoreQuery } from '../../application/queries/get-neighborhood-score/get-neighborhood-score.query'; @@ -60,6 +62,7 @@ import { GetMarketReportDto } from '../dto/get-market-report.dto'; import { GetMarketHistoryDto } from '../dto/get-market-history.dto'; import { GetMarketSnapshotDto } from '../dto/get-market-snapshot.dto'; import { GetPriceMoversDto } from '../dto/get-price-movers.dto'; +import { GetTrendingAreasDto } from '../dto/get-trending-areas.dto'; import { GetNearbyPOIsDto } from '../dto/get-nearby-pois.dto'; import { GetPriceTrendDto } from '../dto/get-price-trend.dto'; import { GetValuationDto } from '../dto/get-valuation.dto'; @@ -356,6 +359,19 @@ export class AnalyticsController { ); } + @ApiOperation({ + summary: 'Top khu vực đang trending (public)', + description: + 'Trả về danh sách quận trending theo lượng tin đăng/inquiries/views trong khoảng nhìn lại. Public endpoint cho homepage. Cache.', + }) + @ApiResponse({ status: 200, description: 'Trending areas retrieved' }) + @Get('trending-areas') + async getTrendingAreas(@Query() dto: GetTrendingAreasDto): Promise { + return this.queryBus.execute( + new GetTrendingAreasQuery(dto.period, dto.limit, dto.level), + ); + } + @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard) @Post('listings/:id/ai-advice') diff --git a/apps/api/src/modules/analytics/presentation/dto/get-trending-areas.dto.ts b/apps/api/src/modules/analytics/presentation/dto/get-trending-areas.dto.ts new file mode 100644 index 0000000..07c44d4 --- /dev/null +++ b/apps/api/src/modules/analytics/presentation/dto/get-trending-areas.dto.ts @@ -0,0 +1,41 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class GetTrendingAreasDto { + @ApiPropertyOptional({ + description: 'Look-back window in days', + enum: [7, 14, 30], + default: 7, + example: 7, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @IsIn([7, 14, 30]) + period: number = 7; + + @ApiPropertyOptional({ + description: 'Maximum number of trending areas to return', + minimum: 1, + maximum: 50, + default: 10, + example: 10, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(50) + limit: number = 10; + + @ApiProperty({ + description: 'Geographic aggregation level (currently only "district" is supported)', + enum: ['district'], + default: 'district', + example: 'district', + }) + @IsOptional() + @IsIn(['district']) + level: 'district' = 'district'; +} diff --git a/apps/api/src/modules/listings/listings.module.ts b/apps/api/src/modules/listings/listings.module.ts index cc2af3f..fdc46ec 100644 --- a/apps/api/src/modules/listings/listings.module.ts +++ b/apps/api/src/modules/listings/listings.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { CqrsModule } from '@nestjs/cqrs'; import { MulterModule } from '@nestjs/platform-express'; import { AnalyticsModule } from '@modules/analytics'; @@ -64,7 +64,7 @@ const EventHandlers = [ @Module({ imports: [ CqrsModule, - AnalyticsModule, + forwardRef(() => AnalyticsModule), MulterModule.register({ limits: { fileSize: 100 * 1024 * 1024 }, // 100 MB — per-type limits enforced by FileValidationPipe }), diff --git a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts index 90fb2d1..2df2cdb 100644 --- a/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts +++ b/apps/api/src/modules/notifications/presentation/controllers/notifications.controller.ts @@ -87,7 +87,7 @@ export class NotificationsController { @ApiResponse({ status: 200, description: 'Unread count retrieved' }) @ApiResponse({ status: 401, description: 'Unauthorized' }) async getUnreadCount(@CurrentUser() user: JwtPayload) { - const count = await this.notificationRepo.countUnreadByUserId(user.sub); + const count = await this.notificationsGateway.getUnreadCount(user.sub); return { unreadCount: count }; } diff --git a/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts b/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts index 54278bc..2a34c50 100644 --- a/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts +++ b/apps/api/src/modules/notifications/presentation/gateways/notifications.gateway.ts @@ -269,8 +269,11 @@ export class NotificationsGateway /** * Read the unread count from Redis (cache-aside pattern). * Falls back to the database when Redis is unavailable or cache misses. + * + * Public so REST callers (e.g. `GET /notifications/unread-count`) can + * share the same cached counter as the WebSocket fan-out. */ - private async getUnreadCount(userId: string): Promise { + async getUnreadCount(userId: string): Promise { if (this.redisService.isAvailable()) { try { const cached = await this.redisService.get(UNREAD_COUNT_KEY(userId)); diff --git a/apps/web/app/[locale]/(auth)/__tests__/login.spec.tsx b/apps/web/app/[locale]/(auth)/__tests__/login.spec.tsx index 5d28373..4f6d461 100644 --- a/apps/web/app/[locale]/(auth)/__tests__/login.spec.tsx +++ b/apps/web/app/[locale]/(auth)/__tests__/login.spec.tsx @@ -53,6 +53,7 @@ vi.mock('@/lib/auth-store', () => { const store = { user: null, isAuthenticated: false, + isInitialized: false, isLoading: false, error: null, login: vi.fn(), @@ -80,6 +81,7 @@ describe('LoginPage', () => { let mockStore: { user: null; isAuthenticated: boolean; + isInitialized: boolean; isLoading: boolean; error: string | null; login: ReturnType; @@ -97,6 +99,7 @@ describe('LoginPage', () => { mockStore = { user: null, isAuthenticated: false, + isInitialized: false, isLoading: false, error: null, login: vi.fn(), diff --git a/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx b/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx index 226aaef..2a85f31 100644 --- a/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx +++ b/apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx @@ -48,6 +48,7 @@ vi.mock('@/lib/auth-store', () => { const store = { user: null, isAuthenticated: false, + isInitialized: false, isLoading: false, error: null, login: vi.fn(), @@ -75,6 +76,7 @@ describe('RegisterPage', () => { let mockStore: { user: null; isAuthenticated: boolean; + isInitialized: boolean; isLoading: boolean; error: string | null; login: ReturnType; @@ -92,6 +94,7 @@ describe('RegisterPage', () => { mockStore = { user: null, isAuthenticated: false, + isInitialized: false, isLoading: false, error: null, login: vi.fn(), diff --git a/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx b/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx index b088159..0d6a138 100644 --- a/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx +++ b/apps/web/app/[locale]/(public)/listings/__tests__/listings.spec.tsx @@ -1,6 +1,8 @@ /* eslint-disable import-x/order */ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import * as React from 'react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ListingDetail } from '@/lib/listings-api'; @@ -9,6 +11,7 @@ import type { ListingDetail } from '@/lib/listings-api'; const mockPush = vi.fn(); vi.mock('next/navigation', () => ({ useRouter: () => ({ push: mockPush }), + usePathname: () => '/vi/listings', useSearchParams: () => new URLSearchParams(), })); @@ -133,6 +136,15 @@ import ListingsPage from '../page'; const mockedApi = vi.mocked(listingsApi); +function renderWithProviders(ui: React.ReactElement) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return render( + {ui}, + ); +} + // ─── Tests ──────────────────────────────────────────────────────────────────── describe('ListingsPage — ticker table', () => { @@ -144,14 +156,14 @@ describe('ListingsPage — ticker table', () => { // ── Render cơ bản ────────────────────────────────────────────────────────── it('hiển thị tiêu đề trang', async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText('Thị Trường BĐS')).toBeInTheDocument(); }); }); it('gọi API với status=ACTIVE khi mount', async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(mockedApi.search).toHaveBeenCalledWith( expect.objectContaining({ status: 'ACTIVE' }), @@ -160,24 +172,22 @@ describe('ListingsPage — ticker table', () => { }); it('hiển thị header cột bảng đúng', async () => { - render(); + renderWithProviders(); await waitFor(() => { const table = screen.getByRole('table'); const headers = table.querySelectorAll('thead th'); const headerTexts = Array.from(headers).map((h) => h.textContent?.trim()); expect(headerTexts).toContain('#'); expect(headerTexts).toContain('Mã'); - expect(headerTexts).toContain('Quận'); - expect(headerTexts).toContain('Loại'); + expect(headerTexts).toContain('Quận/Phường'); expect(headerTexts).toContain('Giá'); expect(headerTexts).toContain('Δ30d'); expect(headerTexts).toContain('DT m²'); - expect(headerTexts).toContain('KL/Views'); }); }); it('hiển thị dấu — cho cột Δ30d (chưa có dữ liệu API)', async () => { - render(); + renderWithProviders(); await waitFor(() => { // Tất cả 3 rows phải hiển thị "—" vì API chưa có field priceDelta30d. const dashes = screen.getAllByText('—'); @@ -186,7 +196,7 @@ describe('ListingsPage — ticker table', () => { }); it('hiển thị mã tin dạng GG-XXXXX', async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText('GG-AAAAA')).toBeInTheDocument(); expect(screen.getByText('GG-BBBBB')).toBeInTheDocument(); @@ -195,7 +205,7 @@ describe('ListingsPage — ticker table', () => { }); it('hiển thị số lượng kết quả khi load xong', async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText(/3 bất động sản đang niêm yết/)).toBeInTheDocument(); }); @@ -203,7 +213,7 @@ describe('ListingsPage — ticker table', () => { it('hiển thị thông báo lỗi khi API thất bại', async () => { mockedApi.search.mockRejectedValue(new Error('Network error')); - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByText(/Không thể tải danh sách/)).toBeInTheDocument(); }); @@ -212,7 +222,7 @@ describe('ListingsPage — ticker table', () => { // ── Sort ─────────────────────────────────────────────────────────────────── it('bảng hiển thị đúng 3 rows dữ liệu', async () => { - render(); + renderWithProviders(); await waitFor(() => { const rows = screen.getAllByRole('row'); // 1 header row + 3 data rows @@ -220,17 +230,19 @@ describe('ListingsPage — ticker table', () => { }); }); - it('sort desc theo Giá mặc định — listing đắt nhất (ccccc-dear) đứng đầu', async () => { - render(); + it('sort desc theo Ngày đăng mặc định — rows hiển thị theo thứ tự API', async () => { + renderWithProviders(); await waitFor(() => { const rows = screen.getAllByRole('row'); - // row[0] = header, row[1] = first data row - expect(rows[1]?.textContent).toContain('GG-CCCCC'); + // 1 header + 3 data rows + expect(rows.length).toBe(4); + // All 3 listings should be visible + expect(rows[1]?.textContent).toContain('GG-AAAAA'); }); }); - it('toggle sort Giá: click header Giá để đổi chiều sort', async () => { - render(); + it('toggle sort Giá: click header Giá 2 lần để đổi chiều sort', async () => { + renderWithProviders(); const user = userEvent.setup(); await waitFor(() => { @@ -239,25 +251,25 @@ describe('ListingsPage — ticker table', () => { const table = screen.getByRole('table'); const giaHeader = Array.from(table.querySelectorAll('thead th')).find( - (th) => th.textContent?.trim().includes('Giá'), + (th) => th.textContent?.trim() === 'Giá', ) as HTMLElement; expect(giaHeader).toBeTruthy(); - // Click một lần (asc) — listing rẻ nhất phải lên đầu + // Click một lần (desc đầu tiên) — listing đắt nhất phải lên đầu await user.click(giaHeader); let rows = screen.getAllByRole('row').slice(1); expect(rows.length).toBe(3); - expect(rows[0]?.textContent).toContain('GG-AAAAA'); + expect(rows[0]?.textContent).toContain('GG-CCCCC'); - // Click lần hai (desc trở lại) — listing đắt nhất lên đầu + // Click lần hai (asc) — listing rẻ nhất lên đầu await user.click(giaHeader); rows = screen.getAllByRole('row').slice(1); - expect(rows[0]?.textContent).toContain('GG-CCCCC'); + expect(rows[0]?.textContent).toContain('GG-AAAAA'); }); it('sort theo DT m² khi click header đó', async () => { - render(); + renderWithProviders(); const user = userEvent.setup(); await waitFor(() => { @@ -278,14 +290,14 @@ describe('ListingsPage — ticker table', () => { // ── Toggle view ──────────────────────────────────────────────────────────── it('hiển thị bảng mặc định (table mode)', async () => { - render(); + renderWithProviders(); await waitFor(() => { expect(screen.getByRole('table')).toBeInTheDocument(); }); }); it('chuyển sang card mode khi click nút Chế độ thẻ', async () => { - render(); + renderWithProviders(); const user = userEvent.setup(); await waitFor(() => { @@ -299,7 +311,7 @@ describe('ListingsPage — ticker table', () => { }); it('quay lại table mode khi click nút Chế độ bảng', async () => { - render(); + renderWithProviders(); const user = userEvent.setup(); await waitFor(() => { @@ -316,7 +328,7 @@ describe('ListingsPage — ticker table', () => { }); it('nút toggle giữ aria-pressed đúng trạng thái', async () => { - render(); + renderWithProviders(); const user = userEvent.setup(); await waitFor(() => { @@ -336,12 +348,12 @@ describe('ListingsPage — ticker table', () => { // ── Filter ───────────────────────────────────────────────────────────────── - it('hiển thị filter bar với 4 select', async () => { - render(); + it('hiển thị filter bar với các select', async () => { + renderWithProviders(); await waitFor(() => { expect(screen.getByRole('combobox', { name: /loại giao dịch/i })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: /loại bất động sản/i })).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: /quận/i })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /loại bđs/i })).toBeInTheDocument(); + expect(screen.getByRole('combobox', { name: /quận\/huyện/i })).toBeInTheDocument(); expect(screen.getByRole('combobox', { name: /khoảng giá/i })).toBeInTheDocument(); }); }); @@ -349,16 +361,18 @@ describe('ListingsPage — ticker table', () => { // ── Navigation ───────────────────────────────────────────────────────────── it('điều hướng đến trang chi tiết khi click row', async () => { - render(); + renderWithProviders(); const user = userEvent.setup(); await waitFor(() => { - expect(screen.getAllByRole('row').length).toBeGreaterThan(1); + expect(screen.getAllByRole('row').length).toBe(4); }); const dataRows = screen.getAllByRole('row').slice(1) as HTMLElement[]; await user.click(dataRows[0]!); - expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/listings/')); + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith(expect.stringContaining('/listings/')); + }); }); }); diff --git a/apps/web/app/[locale]/(public)/listings/page.tsx b/apps/web/app/[locale]/(public)/listings/page.tsx index 38f733d..a439fb1 100644 --- a/apps/web/app/[locale]/(public)/listings/page.tsx +++ b/apps/web/app/[locale]/(public)/listings/page.tsx @@ -305,6 +305,7 @@ function FilterSelect({ {label}