Compare commits
4 Commits
199de240b1
...
6b23bfb756
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b23bfb756 | ||
|
|
2788b35108 | ||
|
|
5a119df806 | ||
|
|
7d26436461 |
@@ -0,0 +1,97 @@
|
||||
import { ConflictException } from '@modules/shared';
|
||||
import { CreateProjectCommand } from '../commands/create-project/create-project.command';
|
||||
import { CreateProjectHandler } from '../commands/create-project/create-project.handler';
|
||||
|
||||
function makeCommand(overrides: Partial<Record<string, unknown>> = {}): CreateProjectCommand {
|
||||
return new CreateProjectCommand(
|
||||
(overrides.name as string) ?? 'Test Project',
|
||||
(overrides.slug as string) ?? 'test-project',
|
||||
(overrides.developer as string) ?? 'DevCorp',
|
||||
(overrides.developerLogo as string | null) ?? null,
|
||||
(overrides.totalUnits as number) ?? 100,
|
||||
(overrides.status as 'PLANNING') ?? 'PLANNING',
|
||||
(overrides.latitude as number) ?? 10.82,
|
||||
(overrides.longitude as number) ?? 106.83,
|
||||
(overrides.address as string) ?? 'addr',
|
||||
(overrides.ward as string) ?? 'ward',
|
||||
(overrides.district as string) ?? 'dist',
|
||||
(overrides.city as string) ?? 'HCM',
|
||||
null, null, null, null, null, null, null, null, null, null,
|
||||
[],
|
||||
null, null,
|
||||
(overrides.suitableFor as string[]) ?? [],
|
||||
(overrides.whyThisLocation as string | null) ?? null,
|
||||
(overrides.ownerId as string | null) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CreateProjectHandler', () => {
|
||||
let handler: CreateProjectHandler;
|
||||
let mockRepo: {
|
||||
findBySlug: ReturnType<typeof vi.fn>;
|
||||
save: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findBySlug: vi.fn().mockResolvedValue(null),
|
||||
save: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
handler = new CreateProjectHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should create project and return id + slug', async () => {
|
||||
const cmd = makeCommand();
|
||||
const result = await handler.execute(cmd);
|
||||
|
||||
expect(result.id).toBeDefined();
|
||||
expect(result.slug).toBe('test-project');
|
||||
expect(mockRepo.save).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should generate unique id via cuid2', async () => {
|
||||
const r1 = await handler.execute(makeCommand({ slug: 'slug-1' }));
|
||||
mockRepo.findBySlug.mockResolvedValue(null);
|
||||
const r2 = await handler.execute(makeCommand({ slug: 'slug-2' }));
|
||||
|
||||
expect(r1.id).not.toBe(r2.id);
|
||||
});
|
||||
|
||||
it('should throw ConflictException when slug already exists', async () => {
|
||||
mockRepo.findBySlug.mockResolvedValue({ id: 'existing' });
|
||||
|
||||
await expect(handler.execute(makeCommand())).rejects.toThrow(ConflictException);
|
||||
expect(mockRepo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass suitableFor and whyThisLocation to entity', async () => {
|
||||
const cmd = makeCommand({ suitableFor: ['family'], whyThisLocation: 'Near metro' });
|
||||
await handler.execute(cmd);
|
||||
|
||||
const saved = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.suitableFor).toEqual(['family']);
|
||||
expect(saved.whyThisLocation).toBe('Near metro');
|
||||
});
|
||||
|
||||
it('should pass ownerId to entity', async () => {
|
||||
const cmd = makeCommand({ ownerId: 'dev-1' });
|
||||
await handler.execute(cmd);
|
||||
|
||||
const saved = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.ownerId).toBe('dev-1');
|
||||
});
|
||||
|
||||
it('should default completedUnits to 0', async () => {
|
||||
await handler.execute(makeCommand());
|
||||
|
||||
const saved = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.completedUnits).toBe(0);
|
||||
});
|
||||
|
||||
it('should default isVerified to false', async () => {
|
||||
await handler.execute(makeCommand());
|
||||
|
||||
const saved = mockRepo.save.mock.calls[0][0];
|
||||
expect(saved.isVerified).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { DeleteProjectCommand } from '../commands/delete-project/delete-project.command';
|
||||
import { DeleteProjectHandler } from '../commands/delete-project/delete-project.handler';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Vinhomes Grand Park', slug: 'vinhomes-grand-park',
|
||||
developer: 'Vingroup', developerLogo: null, totalUnits: 10000,
|
||||
completedUnits: 5000, status: 'UNDER_CONSTRUCTION',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10.8231,
|
||||
longitude: 106.8368, address: 'addr', ward: 'ward',
|
||||
district: 'dist', city: 'HCM', minPrice: null, maxPrice: null,
|
||||
pricePerM2Range: null, totalArea: null, buildingCount: null,
|
||||
floorCount: null, unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: 'dev-user-1', ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntity(id = 'proj-1', overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentEntity {
|
||||
return new ProjectDevelopmentEntity(id, makeProps(overrides), new Date(), new Date());
|
||||
}
|
||||
|
||||
describe('DeleteProjectHandler', () => {
|
||||
let handler: DeleteProjectHandler;
|
||||
let mockRepo: {
|
||||
findById: ReturnType<typeof vi.fn>;
|
||||
delete: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
handler = new DeleteProjectHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should delete project as ADMIN', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new DeleteProjectCommand('proj-1', 'admin-1', 'ADMIN');
|
||||
await handler.execute(cmd);
|
||||
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const cmd = new DeleteProjectCommand('missing', 'admin-1', 'ADMIN');
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(NotFoundException);
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow DEVELOPER to delete own project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-user-1' }));
|
||||
|
||||
const cmd = new DeleteProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER');
|
||||
await handler.execute(cmd);
|
||||
|
||||
expect(mockRepo.delete).toHaveBeenCalledWith('proj-1');
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER deleting another owners project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'other-dev' }));
|
||||
|
||||
const cmd = new DeleteProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER');
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
expect(mockRepo.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny USER role from deleting', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new DeleteProjectCommand('proj-1', 'user-1', 'USER');
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should deny AGENT role from deleting', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new DeleteProjectCommand('proj-1', 'agent-1', 'AGENT');
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { GetProjectStatsHandler } from '../queries/get-project-stats/get-project-stats.handler';
|
||||
import { GetProjectStatsQuery } from '../queries/get-project-stats/get-project-stats.query';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Test', slug: 'test', developer: 'Dev', developerLogo: null,
|
||||
totalUnits: 100, completedUnits: 0, status: 'PLANNING',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
minPrice: null, maxPrice: null, pricePerM2Range: null,
|
||||
totalArea: null, buildingCount: null, floorCount: null,
|
||||
unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: 'dev-1', ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntity(id = 'proj-1', overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentEntity {
|
||||
return new ProjectDevelopmentEntity(id, makeProps(overrides), new Date(), new Date());
|
||||
}
|
||||
|
||||
describe('GetProjectStatsHandler', () => {
|
||||
let handler: GetProjectStatsHandler;
|
||||
let mockRepo: { findById: ReturnType<typeof vi.fn> };
|
||||
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
|
||||
|
||||
const defaultStatsRow = [{
|
||||
linked: BigInt(5), active: BigInt(3),
|
||||
inquiries: BigInt(10), unread: BigInt(2), saves: BigInt(7),
|
||||
}];
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = { findById: vi.fn() };
|
||||
mockPrisma = { $queryRaw: vi.fn().mockResolvedValue(defaultStatsRow) };
|
||||
handler = new GetProjectStatsHandler(mockRepo as any, mockPrisma as any);
|
||||
});
|
||||
|
||||
it('should return stats for ADMIN', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const query = new GetProjectStatsQuery('proj-1', 'admin-1', 'ADMIN');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.projectId).toBe('proj-1');
|
||||
expect(result.linkedListingCount).toBe(5);
|
||||
expect(result.activeListingCount).toBe(3);
|
||||
expect(result.totalInquiries).toBe(10);
|
||||
expect(result.unreadInquiries).toBe(2);
|
||||
expect(result.savedByUsers).toBe(7);
|
||||
});
|
||||
|
||||
it('should return stats for DEVELOPER who owns the project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-1' }));
|
||||
|
||||
const query = new GetProjectStatsQuery('proj-1', 'dev-1', 'DEVELOPER');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.projectId).toBe('proj-1');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const query = new GetProjectStatsQuery('missing', 'admin-1', 'ADMIN');
|
||||
await expect(handler.execute(query)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER viewing stats of another owners project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'other-dev' }));
|
||||
|
||||
const query = new GetProjectStatsQuery('proj-1', 'dev-1', 'DEVELOPER');
|
||||
await expect(handler.execute(query)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should deny USER role', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const query = new GetProjectStatsQuery('proj-1', 'user-1', 'USER');
|
||||
await expect(handler.execute(query)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should handle empty stats row', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
const query = new GetProjectStatsQuery('proj-1', 'admin-1', 'ADMIN');
|
||||
const result = await handler.execute(query);
|
||||
|
||||
expect(result.linkedListingCount).toBe(0);
|
||||
expect(result.activeListingCount).toBe(0);
|
||||
expect(result.totalInquiries).toBe(0);
|
||||
expect(result.unreadInquiries).toBe(0);
|
||||
expect(result.savedByUsers).toBe(0);
|
||||
});
|
||||
|
||||
it('should construct query DTO correctly', () => {
|
||||
const q = new GetProjectStatsQuery('p1', 'u1', 'ADMIN');
|
||||
expect(q.projectId).toBe('p1');
|
||||
expect(q.requesterUserId).toBe('u1');
|
||||
expect(q.requesterRole).toBe('ADMIN');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import { GetProjectQuery } from '../queries/get-project/get-project.query';
|
||||
import { GetProjectHandler } from '../queries/get-project/get-project.handler';
|
||||
import { ListProjectsQuery } from '../queries/list-projects/list-projects.query';
|
||||
import { ListProjectsHandler } from '../queries/list-projects/list-projects.handler';
|
||||
|
||||
describe('GetProjectQuery', () => {
|
||||
it('should store slugOrId', () => {
|
||||
const q = new GetProjectQuery('my-slug');
|
||||
expect(q.slugOrId).toBe('my-slug');
|
||||
});
|
||||
});
|
||||
|
||||
describe('GetProjectHandler', () => {
|
||||
let handler: GetProjectHandler;
|
||||
let mockRepo: {
|
||||
findDetailBySlug: ReturnType<typeof vi.fn>;
|
||||
findDetailById: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findDetailBySlug: vi.fn().mockResolvedValue(null),
|
||||
findDetailById: vi.fn().mockResolvedValue(null),
|
||||
};
|
||||
handler = new GetProjectHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should try slug first', async () => {
|
||||
const detail = { id: 'p1', name: 'Project' };
|
||||
mockRepo.findDetailBySlug.mockResolvedValue(detail);
|
||||
|
||||
const result = await handler.execute(new GetProjectQuery('my-slug'));
|
||||
|
||||
expect(result).toBe(detail);
|
||||
expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('my-slug');
|
||||
expect(mockRepo.findDetailById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fall back to id when slug returns null', async () => {
|
||||
const detail = { id: 'p1', name: 'Project' };
|
||||
mockRepo.findDetailById.mockResolvedValue(detail);
|
||||
|
||||
const result = await handler.execute(new GetProjectQuery('some-id'));
|
||||
|
||||
expect(result).toBe(detail);
|
||||
expect(mockRepo.findDetailBySlug).toHaveBeenCalledWith('some-id');
|
||||
expect(mockRepo.findDetailById).toHaveBeenCalledWith('some-id');
|
||||
});
|
||||
|
||||
it('should return null when neither slug nor id match', async () => {
|
||||
const result = await handler.execute(new GetProjectQuery('nope'));
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListProjectsQuery', () => {
|
||||
it('should store all params', () => {
|
||||
const q = new ListProjectsQuery('search', 'PLANNING', 'HCM', 'Q1', 'Dev', true, 2, 10, 'owner-1');
|
||||
expect(q.query).toBe('search');
|
||||
expect(q.status).toBe('PLANNING');
|
||||
expect(q.city).toBe('HCM');
|
||||
expect(q.district).toBe('Q1');
|
||||
expect(q.developer).toBe('Dev');
|
||||
expect(q.isVerified).toBe(true);
|
||||
expect(q.page).toBe(2);
|
||||
expect(q.limit).toBe(10);
|
||||
expect(q.ownerId).toBe('owner-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListProjectsHandler', () => {
|
||||
let handler: ListProjectsHandler;
|
||||
let mockRepo: { search: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = { search: vi.fn() };
|
||||
handler = new ListProjectsHandler(mockRepo as any);
|
||||
});
|
||||
|
||||
it('should delegate to repo.search with correct params', async () => {
|
||||
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
||||
mockRepo.search.mockResolvedValue(expected);
|
||||
|
||||
const q = new ListProjectsQuery('q', 'COMPLETED', 'HCM', 'Q2', 'VG', false, 1, 20, 'own-1');
|
||||
const result = await handler.execute(q);
|
||||
|
||||
expect(mockRepo.search).toHaveBeenCalledWith({
|
||||
query: 'q', status: 'COMPLETED', city: 'HCM', district: 'Q2',
|
||||
developer: 'VG', isVerified: false, ownerId: 'own-1', page: 1, limit: 20,
|
||||
});
|
||||
expect(result).toBe(expected);
|
||||
});
|
||||
|
||||
it('should return paginated result from repo', async () => {
|
||||
const data = { data: [{ id: 'p1' }], total: 1, page: 1, limit: 10, totalPages: 1 };
|
||||
mockRepo.search.mockResolvedValue(data);
|
||||
|
||||
const result = await handler.execute(
|
||||
new ListProjectsQuery(undefined, undefined, undefined, undefined, undefined, undefined, 1, 10),
|
||||
);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
import { UpdateProjectCommand } from '../commands/update-project/update-project.command';
|
||||
import { UpdateProjectHandler } from '../commands/update-project/update-project.handler';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Vinhomes Grand Park', slug: 'vinhomes-grand-park',
|
||||
developer: 'Vingroup', developerLogo: null, totalUnits: 10000,
|
||||
completedUnits: 5000, status: 'UNDER_CONSTRUCTION',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10.8231,
|
||||
longitude: 106.8368, address: 'addr', ward: 'ward',
|
||||
district: 'dist', city: 'HCM', minPrice: null, maxPrice: null,
|
||||
pricePerM2Range: null, totalArea: null, buildingCount: null,
|
||||
floorCount: null, unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: 'dev-user-1', ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeEntity(id = 'proj-1', overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentEntity {
|
||||
return new ProjectDevelopmentEntity(id, makeProps(overrides), new Date(), new Date());
|
||||
}
|
||||
|
||||
describe('UpdateProjectHandler', () => {
|
||||
let handler: UpdateProjectHandler;
|
||||
let mockRepo: {
|
||||
findById: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockRepo = {
|
||||
findById: vi.fn(),
|
||||
update: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
// NOTE: EventBusService is imported as `type` in the handler, so the
|
||||
// constructor param and publish call are stripped from compiled output.
|
||||
const mockEventBus = { publish: vi.fn() };
|
||||
|
||||
handler = new UpdateProjectHandler(mockRepo as any, mockEventBus as any);
|
||||
});
|
||||
|
||||
it('should update project as ADMIN', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'admin-1', 'ADMIN', 'Updated Name');
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
|
||||
expect(result.id).toBe('proj-1');
|
||||
expect(mockRepo.update).toHaveBeenCalledTimes(1);
|
||||
const updated: ProjectDevelopmentEntity = mockRepo.update.mock.calls[0][0];
|
||||
expect(updated.name).toBe('Updated Name');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when project not found', async () => {
|
||||
mockRepo.findById.mockResolvedValue(null);
|
||||
|
||||
const cmd = new UpdateProjectCommand('missing', 'admin-1', 'ADMIN', 'Name');
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(NotFoundException);
|
||||
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow DEVELOPER to update their own project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-user-1' }));
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER', 'New Name');
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
expect(result.id).toBe('proj-1');
|
||||
expect(mockRepo.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER editing another owners project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'other-dev' }));
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'dev-user-1', 'DEVELOPER', 'Hacked');
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
expect(mockRepo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should deny DEVELOPER reassigning ownerId', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1', { ownerId: 'dev-user-1' }));
|
||||
|
||||
// UpdateProjectCommand params: id, requesterUserId, requesterRole,
|
||||
// name, developer, developerLogo, totalUnits, completedUnits,
|
||||
// status, description, amenities, masterPlanUrl,
|
||||
// minPrice, maxPrice, pricePerM2Range, totalArea,
|
||||
// buildingCount, floorCount, unitTypes, media, documents,
|
||||
// tags, isVerified, startDate, completionDate,
|
||||
// suitableFor, whyThisLocation, ownerId
|
||||
const cmd = new UpdateProjectCommand(
|
||||
'proj-1', 'dev-user-1', 'DEVELOPER',
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined,
|
||||
'another-user', // ownerId (28th param)
|
||||
);
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should allow ADMIN to reassign ownerId', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity('proj-1'));
|
||||
|
||||
const cmd = new UpdateProjectCommand(
|
||||
'proj-1', 'admin-1', 'ADMIN',
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
undefined, undefined, undefined, undefined,
|
||||
undefined, undefined,
|
||||
'new-owner', // ownerId (28th param)
|
||||
);
|
||||
|
||||
const result = await handler.execute(cmd);
|
||||
expect(result.id).toBe('proj-1');
|
||||
const updated: ProjectDevelopmentEntity = mockRepo.update.mock.calls[0][0];
|
||||
expect(updated.ownerId).toBe('new-owner');
|
||||
});
|
||||
|
||||
it('should deny USER role from editing any project', async () => {
|
||||
mockRepo.findById.mockResolvedValue(makeEntity());
|
||||
|
||||
const cmd = new UpdateProjectCommand('proj-1', 'user-1', 'USER', 'Name');
|
||||
|
||||
await expect(handler.execute(cmd)).rejects.toThrow(ForbiddenException);
|
||||
});
|
||||
|
||||
it('should only update provided fields', async () => {
|
||||
const entity = makeEntity();
|
||||
mockRepo.findById.mockResolvedValue(entity);
|
||||
|
||||
const cmd = new UpdateProjectCommand(
|
||||
'proj-1', 'admin-1', 'ADMIN',
|
||||
undefined, undefined, undefined, undefined, undefined,
|
||||
'COMPLETED',
|
||||
);
|
||||
|
||||
await handler.execute(cmd);
|
||||
|
||||
const updated: ProjectDevelopmentEntity = mockRepo.update.mock.calls[0][0];
|
||||
expect(updated.status).toBe('COMPLETED');
|
||||
expect(updated.name).toBe('Vinhomes Grand Park'); // unchanged
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../entities/project-development.entity';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Vinhomes Grand Park', slug: 'vinhomes-grand-park',
|
||||
developer: 'Vingroup', developerLogo: null, totalUnits: 10000,
|
||||
completedUnits: 5000, status: 'UNDER_CONSTRUCTION',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10.8231,
|
||||
longitude: 106.8368, address: 'addr', ward: 'ward',
|
||||
district: 'dist', city: 'HCM', minPrice: null, maxPrice: null,
|
||||
pricePerM2Range: null, totalArea: null, buildingCount: null,
|
||||
floorCount: null, unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: null, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ProjectDevelopmentEntity', () => {
|
||||
const now = new Date();
|
||||
|
||||
it('should expose all getters correctly', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
|
||||
expect(entity.id).toBe('id-1');
|
||||
expect(entity.name).toBe('Vinhomes Grand Park');
|
||||
expect(entity.slug).toBe('vinhomes-grand-park');
|
||||
expect(entity.developer).toBe('Vingroup');
|
||||
expect(entity.developerLogo).toBeNull();
|
||||
expect(entity.totalUnits).toBe(10000);
|
||||
expect(entity.completedUnits).toBe(5000);
|
||||
expect(entity.status).toBe('UNDER_CONSTRUCTION');
|
||||
expect(entity.latitude).toBe(10.8231);
|
||||
expect(entity.longitude).toBe(106.8368);
|
||||
expect(entity.address).toBe('addr');
|
||||
expect(entity.city).toBe('HCM');
|
||||
expect(entity.tags).toEqual([]);
|
||||
expect(entity.suitableFor).toEqual([]);
|
||||
expect(entity.isVerified).toBe(false);
|
||||
expect(entity.ownerId).toBeNull();
|
||||
expect(entity.createdAt).toBe(now);
|
||||
expect(entity.updatedAt).toBe(now);
|
||||
});
|
||||
|
||||
it('should support nullable fields', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps({
|
||||
startDate: new Date('2024-01-01'),
|
||||
completionDate: new Date('2026-12-31'),
|
||||
description: 'A project',
|
||||
amenities: { pool: true },
|
||||
masterPlanUrl: 'https://example.com/plan.pdf',
|
||||
minPrice: BigInt(1_000_000_000),
|
||||
maxPrice: BigInt(5_000_000_000),
|
||||
pricePerM2Range: { min: 50, max: 80 },
|
||||
totalArea: 250.5,
|
||||
buildingCount: 10,
|
||||
floorCount: 30,
|
||||
unitTypes: { studio: 100 },
|
||||
media: [{ url: 'img.jpg' }],
|
||||
documents: [{ url: 'doc.pdf' }],
|
||||
whyThisLocation: 'Near metro',
|
||||
ownerId: 'dev-1',
|
||||
}), now, now);
|
||||
|
||||
expect(entity.startDate).toEqual(new Date('2024-01-01'));
|
||||
expect(entity.minPrice).toBe(BigInt(1_000_000_000));
|
||||
expect(entity.media).toEqual([{ url: 'img.jpg' }]);
|
||||
expect(entity.ownerId).toBe('dev-1');
|
||||
expect(entity.whyThisLocation).toBe('Near metro');
|
||||
});
|
||||
|
||||
it('should update only provided fields via updateDetails', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
|
||||
entity.updateDetails({ name: 'New Name', status: 'COMPLETED' });
|
||||
|
||||
expect(entity.name).toBe('New Name');
|
||||
expect(entity.status).toBe('COMPLETED');
|
||||
expect(entity.developer).toBe('Vingroup'); // unchanged
|
||||
});
|
||||
|
||||
it('should allow setting fields to null via updateDetails', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps({
|
||||
description: 'desc', masterPlanUrl: 'url',
|
||||
}), now, now);
|
||||
|
||||
entity.updateDetails({ description: null, masterPlanUrl: null });
|
||||
|
||||
expect(entity.description).toBeNull();
|
||||
expect(entity.masterPlanUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should update ownerId via updateDetails', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps({ ownerId: 'old' }), now, now);
|
||||
|
||||
entity.updateDetails({ ownerId: 'new-owner' });
|
||||
|
||||
expect(entity.ownerId).toBe('new-owner');
|
||||
});
|
||||
|
||||
it('should update suitableFor and whyThisLocation', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
|
||||
entity.updateDetails({ suitableFor: ['family', 'investor'], whyThisLocation: 'Prime location' });
|
||||
|
||||
expect(entity.suitableFor).toEqual(['family', 'investor']);
|
||||
expect(entity.whyThisLocation).toBe('Prime location');
|
||||
});
|
||||
|
||||
it('should update updatedAt when updateDetails is called', () => {
|
||||
const oldDate = new Date('2020-01-01');
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), oldDate, oldDate);
|
||||
|
||||
entity.updateDetails({ name: 'X' });
|
||||
|
||||
expect(entity.updatedAt.getTime()).toBeGreaterThan(oldDate.getTime());
|
||||
});
|
||||
|
||||
it('should implement equals from BaseEntity', () => {
|
||||
const a = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
const b = new ProjectDevelopmentEntity('id-1', makeProps({ name: 'Other' }), now, now);
|
||||
const c = new ProjectDevelopmentEntity('id-2', makeProps(), now, now);
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
|
||||
it('should support domain events (AggregateRoot)', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
|
||||
expect(entity.domainEvents).toEqual([]);
|
||||
expect(entity.commit()).toEqual([]);
|
||||
});
|
||||
|
||||
it('should update media and documents', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
|
||||
entity.updateDetails({
|
||||
media: [{ url: 'new.jpg', type: 'image' }],
|
||||
documents: [{ url: 'doc.pdf' }],
|
||||
});
|
||||
|
||||
expect(entity.media).toEqual([{ url: 'new.jpg', type: 'image' }]);
|
||||
expect(entity.documents).toEqual([{ url: 'doc.pdf' }]);
|
||||
});
|
||||
|
||||
it('should update tags and isVerified', () => {
|
||||
const entity = new ProjectDevelopmentEntity('id-1', makeProps(), now, now);
|
||||
|
||||
entity.updateDetails({ tags: ['luxury', 'new'], isVerified: true });
|
||||
|
||||
expect(entity.tags).toEqual(['luxury', 'new']);
|
||||
expect(entity.isVerified).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import { PrismaProjectDevelopmentRepository } from '../repositories/prisma-project-development.repository';
|
||||
import { ProjectDevelopmentEntity, type ProjectDevelopmentProps } from '../../domain/entities/project-development.entity';
|
||||
|
||||
function makeProps(overrides: Partial<ProjectDevelopmentProps> = {}): ProjectDevelopmentProps {
|
||||
return {
|
||||
name: 'Test', slug: 'test', developer: 'Dev', developerLogo: null,
|
||||
totalUnits: 100, completedUnits: 0, status: 'PLANNING',
|
||||
startDate: null, completionDate: null, description: null,
|
||||
amenities: null, masterPlanUrl: null, latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
minPrice: null, maxPrice: null, pricePerM2Range: null,
|
||||
totalArea: null, buildingCount: null, floorCount: null,
|
||||
unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: null, ...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeRawRow(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'p1', name: 'Test', slug: 'test', developer: 'Dev',
|
||||
developerLogo: null, totalUnits: 100, completedUnits: 0,
|
||||
status: 'PLANNING', startDate: null, completionDate: null,
|
||||
description: null, amenities: null, masterPlanUrl: null,
|
||||
lat: 10, lng: 106, address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
minPrice: null, maxPrice: null, pricePerM2Range: null,
|
||||
totalArea: null, buildingCount: null, floorCount: null,
|
||||
unitTypes: null, media: null, documents: null,
|
||||
tags: [], suitableFor: [], whyThisLocation: null,
|
||||
isVerified: false, ownerId: null,
|
||||
createdAt: new Date(), updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('PrismaProjectDevelopmentRepository', () => {
|
||||
let repo: PrismaProjectDevelopmentRepository;
|
||||
let mockPrisma: {
|
||||
$queryRaw: ReturnType<typeof vi.fn>;
|
||||
$executeRaw: ReturnType<typeof vi.fn>;
|
||||
projectDevelopment: { delete: ReturnType<typeof vi.fn> };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
$queryRaw: vi.fn(),
|
||||
$executeRaw: vi.fn().mockResolvedValue(1),
|
||||
projectDevelopment: { delete: vi.fn().mockResolvedValue({}) },
|
||||
};
|
||||
repo = new PrismaProjectDevelopmentRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
it('findById should return entity when found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([makeRawRow()]);
|
||||
|
||||
const result = await repo.findById('p1');
|
||||
|
||||
expect(result).toBeInstanceOf(ProjectDevelopmentEntity);
|
||||
expect(result!.id).toBe('p1');
|
||||
expect(result!.name).toBe('Test');
|
||||
});
|
||||
|
||||
it('findById should return null when not found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
|
||||
const result = await repo.findById('missing');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('findBySlug should return entity when found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([makeRawRow({ slug: 'my-slug' })]);
|
||||
|
||||
const result = await repo.findBySlug('my-slug');
|
||||
expect(result).toBeInstanceOf(ProjectDevelopmentEntity);
|
||||
expect(result!.slug).toBe('my-slug');
|
||||
});
|
||||
|
||||
it('findBySlug should return null when not found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
expect(await repo.findBySlug('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('findDetailBySlug should return detail data when found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([makeRawRow({ propertyCount: 5 })]);
|
||||
|
||||
const result = await repo.findDetailBySlug('test');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe('p1');
|
||||
expect(result!.propertyCount).toBe(5);
|
||||
});
|
||||
|
||||
it('findDetailBySlug should return null when not found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
expect(await repo.findDetailBySlug('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('findDetailById should return detail data when found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([makeRawRow({ propertyCount: 3 })]);
|
||||
|
||||
const result = await repo.findDetailById('p1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.propertyCount).toBe(3);
|
||||
});
|
||||
|
||||
it('findDetailById should return null when not found', async () => {
|
||||
mockPrisma.$queryRaw.mockResolvedValue([]);
|
||||
expect(await repo.findDetailById('nope')).toBeNull();
|
||||
});
|
||||
|
||||
it('save should call $executeRaw', async () => {
|
||||
const entity = new ProjectDevelopmentEntity('p1', makeProps(), new Date(), new Date());
|
||||
await repo.save(entity);
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('update should call $executeRaw', async () => {
|
||||
const entity = new ProjectDevelopmentEntity('p1', makeProps(), new Date(), new Date());
|
||||
await repo.update(entity);
|
||||
expect(mockPrisma.$executeRaw).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('delete should call prisma.projectDevelopment.delete', async () => {
|
||||
await repo.delete('p1');
|
||||
expect(mockPrisma.projectDevelopment.delete).toHaveBeenCalledWith({ where: { id: 'p1' } });
|
||||
});
|
||||
|
||||
it('search should return paginated results', async () => {
|
||||
// First call: count query; second call: data query
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
||||
.mockResolvedValueOnce([makeRawRow({ propertyCount: 2 })]);
|
||||
|
||||
const result = await repo.search({ page: 1, limit: 10 });
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(1);
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0].id).toBe('p1');
|
||||
});
|
||||
|
||||
it('search should handle null tags/suitableFor in raw rows', async () => {
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ count: BigInt(1) }])
|
||||
.mockResolvedValueOnce([makeRawRow({ tags: null, suitableFor: null, propertyCount: 0 })]);
|
||||
|
||||
const result = await repo.search({ page: 1, limit: 20 });
|
||||
|
||||
expect(result.data[0].tags).toEqual([]);
|
||||
expect(result.data[0].suitableFor).toEqual([]);
|
||||
});
|
||||
|
||||
it('search should default page=1 and limit=20', async () => {
|
||||
mockPrisma.$queryRaw
|
||||
.mockResolvedValueOnce([{ count: BigInt(0) }])
|
||||
.mockResolvedValueOnce([]);
|
||||
|
||||
const result = await repo.search({});
|
||||
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
import { NotFoundException } from '@modules/shared';
|
||||
import { ProjectsController } from '../controllers/projects.controller';
|
||||
|
||||
describe('ProjectsController', () => {
|
||||
let controller: ProjectsController;
|
||||
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
||||
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(() => {
|
||||
mockCommandBus = { execute: vi.fn() };
|
||||
mockQueryBus = { execute: vi.fn() };
|
||||
controller = new ProjectsController(mockCommandBus as any, mockQueryBus as any);
|
||||
});
|
||||
|
||||
// ── listProjects ──────────────────────────────────────────────────
|
||||
|
||||
describe('listProjects', () => {
|
||||
it('should shape projects with developer object and thumbnailUrl', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [{
|
||||
id: 'p1', name: 'Test', slug: 'test',
|
||||
developer: 'Vingroup', developerLogo: 'logo.png',
|
||||
media: [{ url: 'img.jpg', type: 'image' }],
|
||||
}],
|
||||
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listProjects({ page: 1, limit: 20 } as any);
|
||||
|
||||
expect(result.data[0].developer).toEqual({
|
||||
id: 'Vingroup', name: 'Vingroup', logo: 'logo.png',
|
||||
});
|
||||
expect(result.data[0].thumbnailUrl).toBe('img.jpg');
|
||||
expect(result.data[0]).not.toHaveProperty('media');
|
||||
});
|
||||
|
||||
it('should handle null media', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [{ id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null, media: null }],
|
||||
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listProjects({} as any);
|
||||
expect(result.data[0].thumbnailUrl).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty media array', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [{ id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null, media: [] }],
|
||||
total: 1, page: 1, limit: 20, totalPages: 1,
|
||||
});
|
||||
|
||||
const result = await controller.listProjects({} as any);
|
||||
expect(result.data[0].thumbnailUrl).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── listMyProjects ────────────────────────────────────────────────
|
||||
|
||||
describe('listMyProjects', () => {
|
||||
it('should pass user.sub as ownerId', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
data: [], total: 0, page: 1, limit: 20, totalPages: 0,
|
||||
});
|
||||
|
||||
const user = { sub: 'dev-1', role: 'DEVELOPER' };
|
||||
await controller.listMyProjects(user as any, {} as any);
|
||||
|
||||
const query = mockQueryBus.execute.mock.calls[0][0];
|
||||
expect(query.ownerId).toBe('dev-1');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProjectStats ───────────────────────────────────────────────
|
||||
|
||||
describe('getProjectStats', () => {
|
||||
it('should pass id, user.sub, and user.role', async () => {
|
||||
const stats = { projectId: 'p1', linkedListingCount: 5 };
|
||||
mockQueryBus.execute.mockResolvedValue(stats);
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const result = await controller.getProjectStats(user as any, 'p1');
|
||||
|
||||
expect(result).toBe(stats);
|
||||
const query = mockQueryBus.execute.mock.calls[0][0];
|
||||
expect(query.projectId).toBe('p1');
|
||||
expect(query.requesterUserId).toBe('admin-1');
|
||||
expect(query.requesterRole).toBe('ADMIN');
|
||||
});
|
||||
});
|
||||
|
||||
// ── getProject ────────────────────────────────────────────────────
|
||||
|
||||
describe('getProject', () => {
|
||||
it('should shape detail with media array preserved', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null,
|
||||
media: [{ url: 'img.jpg' }],
|
||||
});
|
||||
|
||||
const result = await controller.getProject('s');
|
||||
expect(result.media).toEqual([{ url: 'img.jpg' }]);
|
||||
expect(result.developer).toEqual({ id: 'D', name: 'D', logo: null });
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when result is null', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue(null);
|
||||
|
||||
await expect(controller.getProject('nope')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should handle null media in detail', async () => {
|
||||
mockQueryBus.execute.mockResolvedValue({
|
||||
id: 'p1', name: 'T', slug: 's', developer: 'D', developerLogo: null,
|
||||
media: null,
|
||||
});
|
||||
|
||||
const result = await controller.getProject('s');
|
||||
expect(result.media).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── createProject ─────────────────────────────────────────────────
|
||||
|
||||
describe('createProject', () => {
|
||||
it('should set ownerId to user.sub for DEVELOPER', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' });
|
||||
|
||||
const user = { sub: 'dev-1', role: 'DEVELOPER' };
|
||||
const dto = {
|
||||
name: 'P', slug: 'p', developer: 'D', totalUnits: 10,
|
||||
status: 'PLANNING', latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
};
|
||||
|
||||
await controller.createProject(user as any, dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.ownerId).toBe('dev-1');
|
||||
});
|
||||
|
||||
it('should set ownerId to null for ADMIN', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const dto = {
|
||||
name: 'P', slug: 'p', developer: 'D', totalUnits: 10,
|
||||
status: 'PLANNING', latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
};
|
||||
|
||||
await controller.createProject(user as any, dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.ownerId).toBeNull();
|
||||
});
|
||||
|
||||
it('should convert minPrice/maxPrice to BigInt', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' });
|
||||
|
||||
const user = { sub: 'dev-1', role: 'DEVELOPER' };
|
||||
const dto = {
|
||||
name: 'P', slug: 'p', developer: 'D', totalUnits: 10,
|
||||
status: 'PLANNING', latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
minPrice: '1000000000', maxPrice: '5000000000',
|
||||
};
|
||||
|
||||
await controller.createProject(user as any, dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.minPrice).toBe(BigInt('1000000000'));
|
||||
expect(cmd.maxPrice).toBe(BigInt('5000000000'));
|
||||
});
|
||||
|
||||
it('should convert startDate/completionDate to Date', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'new-1', slug: 'test' });
|
||||
|
||||
const user = { sub: 'dev-1', role: 'DEVELOPER' };
|
||||
const dto = {
|
||||
name: 'P', slug: 'p', developer: 'D', totalUnits: 10,
|
||||
status: 'PLANNING', latitude: 10, longitude: 106,
|
||||
address: 'a', ward: 'w', district: 'd', city: 'HCM',
|
||||
startDate: '2024-01-01', completionDate: '2026-12-31',
|
||||
};
|
||||
|
||||
await controller.createProject(user as any, dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.startDate).toBeInstanceOf(Date);
|
||||
expect(cmd.completionDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateProject ─────────────────────────────────────────────────
|
||||
|
||||
describe('updateProject', () => {
|
||||
it('should pass user info and dto params to UpdateProjectCommand', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const dto = { name: 'Updated', status: 'COMPLETED' };
|
||||
|
||||
await controller.updateProject(user as any, 'p1', dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.id).toBe('p1');
|
||||
expect(cmd.requesterUserId).toBe('admin-1');
|
||||
expect(cmd.requesterRole).toBe('ADMIN');
|
||||
expect(cmd.name).toBe('Updated');
|
||||
expect(cmd.status).toBe('COMPLETED');
|
||||
});
|
||||
|
||||
it('should convert minPrice to BigInt when provided', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const dto = { minPrice: '2000000000' };
|
||||
|
||||
await controller.updateProject(user as any, 'p1', dto as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.minPrice).toBe(BigInt('2000000000'));
|
||||
});
|
||||
|
||||
it('should leave minPrice as undefined when not in dto', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue({ id: 'p1' });
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
await controller.updateProject(user as any, 'p1', {} as any);
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.minPrice).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── deleteProject ─────────────────────────────────────────────────
|
||||
|
||||
describe('deleteProject', () => {
|
||||
it('should return { success: true }', async () => {
|
||||
mockCommandBus.execute.mockResolvedValue(undefined);
|
||||
|
||||
const user = { sub: 'admin-1', role: 'ADMIN' };
|
||||
const result = await controller.deleteProject(user as any, 'p1');
|
||||
|
||||
expect(result).toEqual({ success: true });
|
||||
|
||||
const cmd = mockCommandBus.execute.mock.calls[0][0];
|
||||
expect(cmd.id).toBe('p1');
|
||||
expect(cmd.requesterUserId).toBe('admin-1');
|
||||
expect(cmd.requesterRole).toBe('ADMIN');
|
||||
});
|
||||
});
|
||||
});
|
||||
196
apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx
Normal file
196
apps/web/app/[locale]/(admin)/__tests__/layout.spec.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử AdminLayout: sidebar, nav links, auth guard, mobile header.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl
|
||||
const viMessages = await import('@/messages/vi.json');
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace?: string) => {
|
||||
const messages = viMessages.default ?? viMessages;
|
||||
const ns = namespace
|
||||
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
|
||||
: (messages as unknown as Record<string, unknown>);
|
||||
return (key: string) => {
|
||||
if (!ns) return key;
|
||||
const parts = key.split('.');
|
||||
let val: unknown = ns;
|
||||
for (const p of parts) {
|
||||
val = (val as Record<string, unknown>)?.[p];
|
||||
}
|
||||
return typeof val === 'string' ? val : key;
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/admin',
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href as string} {...props}>{children}</a>
|
||||
),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/language-switcher', () => ({
|
||||
LanguageSwitcher: () => <div data-testid="lang-switcher" />,
|
||||
}));
|
||||
|
||||
const mockLogout = vi.fn();
|
||||
const mockAuthStore = vi.fn(() => ({
|
||||
user: { id: '1', fullName: 'Admin User', role: 'ADMIN', email: 'admin@goodgo.vn' },
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
logout: mockLogout,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-store', () => ({
|
||||
useAuthStore: (...args: unknown[]) => mockAuthStore(...args),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/utils', () => ({
|
||||
cn: (...classes: (string | undefined | false | null)[]) => classes.filter(Boolean).join(' '),
|
||||
}));
|
||||
|
||||
import AdminLayout from '../layout';
|
||||
|
||||
describe('AdminLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAuthStore.mockReturnValue({
|
||||
user: { id: '1', fullName: 'Admin User', role: 'ADMIN', email: 'admin@goodgo.vn' },
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
logout: mockLogout,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders children in main content area', () => {
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div data-testid="admin-page">Trang admin</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('admin-page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sidebar navigation', () => {
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
// There are two nav elements with the same label (sidebar + inner nav)
|
||||
const navEls = screen.getAllByRole('navigation');
|
||||
expect(navEls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders admin nav link to /admin/users', () => {
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(screen.getByRole('link', { name: /users|người dùng/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main content with role="main"', () => {
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders main content with id="main-content"', () => {
|
||||
const { container } = render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(container.querySelector('#main-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows admin username in sidebar', () => {
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(screen.getByText('Admin User')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when not initialized', () => {
|
||||
mockAuthStore.mockReturnValue({
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isInitialized: false,
|
||||
logout: mockLogout,
|
||||
});
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div data-testid="hidden">Hidden</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('hidden')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when user is not ADMIN', () => {
|
||||
mockAuthStore.mockReturnValue({
|
||||
user: { id: '2', fullName: 'Regular User', role: 'USER', email: 'user@goodgo.vn' },
|
||||
isAuthenticated: true,
|
||||
isInitialized: true,
|
||||
logout: mockLogout,
|
||||
});
|
||||
const { container } = render(
|
||||
<AdminLayout>
|
||||
<div data-testid="hidden">Hidden</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
expect(screen.queryByTestId('hidden')).not.toBeInTheDocument();
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it('calls logout when logout button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
const logoutBtn = screen.getByRole('button', { name: /logout|đăng xuất/i });
|
||||
await user.click(logoutBtn);
|
||||
expect(mockLogout).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens mobile sidebar when menu button is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<AdminLayout>
|
||||
<div>Content</div>
|
||||
</AdminLayout>,
|
||||
);
|
||||
// Mobile sidebar starts translated off-screen (has -translate-x-full class)
|
||||
const aside = container.querySelector('aside');
|
||||
expect(aside).toBeInTheDocument();
|
||||
// Before open: contains -translate-x-full
|
||||
expect(aside?.className).toContain('-translate-x-full');
|
||||
|
||||
// Click open menu button
|
||||
const openBtn = screen.getByRole('button', { name: /openMenu|mở menu/i });
|
||||
await user.click(openBtn);
|
||||
|
||||
// After open: translate-x-0 replaces -translate-x-full
|
||||
expect(aside?.className).toContain('translate-x-0');
|
||||
expect(aside?.className).not.toContain('-translate-x-full');
|
||||
});
|
||||
});
|
||||
61
apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx
Normal file
61
apps/web/app/[locale]/(auth)/__tests__/layout.spec.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử AuthLayout: wrapper căn giữa cho trang đăng nhập / đăng ký.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import AuthLayout from '../layout';
|
||||
|
||||
describe('AuthLayout', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<AuthLayout>
|
||||
<div data-testid="form">Form đăng nhập</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('form')).toBeInTheDocument();
|
||||
expect(screen.getByText('Form đăng nhập')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has role="main" on the outer element', () => {
|
||||
render(
|
||||
<AuthLayout>
|
||||
<div>Nội dung</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has id="main-content" for skip-nav accessibility', () => {
|
||||
const { container } = render(
|
||||
<AuthLayout>
|
||||
<div>Nội dung</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(container.querySelector('#main-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('centres the form inside a max-width container', () => {
|
||||
const { container } = render(
|
||||
<AuthLayout>
|
||||
<div>Form</div>
|
||||
</AuthLayout>,
|
||||
);
|
||||
// Inner div has w-full max-w-md
|
||||
const inner = container.querySelector('.max-w-md');
|
||||
expect(inner).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
render(
|
||||
<AuthLayout>
|
||||
<h1 data-testid="heading">Tiêu đề</h1>
|
||||
<p data-testid="body">Nội dung</p>
|
||||
</AuthLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('heading')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
170
apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx
Normal file
170
apps/web/app/[locale]/(public)/__tests__/layout.spec.tsx
Normal file
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử PublicLayout: navbar, ticker strip, footer, main content.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
// Mock next-intl
|
||||
const viMessages = await import('@/messages/vi.json');
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: (namespace?: string) => {
|
||||
const messages = viMessages.default ?? viMessages;
|
||||
const ns = namespace
|
||||
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
|
||||
: (messages as unknown as Record<string, unknown>);
|
||||
return (key: string) => {
|
||||
if (!ns) return key;
|
||||
const parts = key.split('.');
|
||||
let val: unknown = ns;
|
||||
for (const p of parts) {
|
||||
val = (val as Record<string, unknown>)?.[p];
|
||||
}
|
||||
return typeof val === 'string' ? val : key;
|
||||
};
|
||||
},
|
||||
useLocale: () => 'vi',
|
||||
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||
}));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||
<a href={href as string} {...props}>{children}</a>
|
||||
),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
|
||||
usePathname: () => '/',
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-store', () => ({
|
||||
useAuthStore: () => ({
|
||||
user: null,
|
||||
logout: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/providers/theme-provider', () => ({
|
||||
useTheme: () => ({ theme: 'light', toggleTheme: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/notifications/notification-bell', () => ({
|
||||
NotificationBell: () => <button aria-label="Thông báo">🔔</button>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/comparison/compare-floating-bar', () => ({
|
||||
CompareFloatingBar: () => <div data-testid="compare-bar" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/ticker-strip', () => ({
|
||||
TickerStrip: ({ items }: { items: unknown[] }) => (
|
||||
<div data-testid="ticker-strip" aria-label="ticker">{items.length} items</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/navbar', () => ({
|
||||
Navbar: ({ brand, links }: { brand: string; links: { label: string }[] }) => (
|
||||
<nav data-testid="navbar" aria-label="main-nav">
|
||||
<span>{brand}</span>
|
||||
{links.map((l) => <span key={l.label}>{l.label}</span>)}
|
||||
</nav>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/design-system/footer', () => ({
|
||||
Footer: ({ brand, copyright }: { brand: string; copyright: string }) => (
|
||||
<footer data-testid="footer">
|
||||
<span>{brand}</span>
|
||||
<span>{copyright}</span>
|
||||
</footer>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/ui/language-switcher', () => ({
|
||||
LanguageSwitcher: () => <div data-testid="lang-switcher" />,
|
||||
}));
|
||||
|
||||
import PublicLayout from '../layout';
|
||||
|
||||
describe('PublicLayout', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders main content wrapper with role="main"', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div data-testid="page">Trang chính</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByRole('main')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('page')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children inside main', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<h1>Tiêu đề trang</h1>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByText('Tiêu đề trang')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the navbar', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('navbar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the ticker strip', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('ticker-strip')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the footer', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the compare floating bar', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('compare-bar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ticker strip has 8 district items', () => {
|
||||
render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(screen.getByText('8 items')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('main content has id="main-content" for skip-nav', () => {
|
||||
const { container } = render(
|
||||
<PublicLayout>
|
||||
<div>Content</div>
|
||||
</PublicLayout>,
|
||||
);
|
||||
expect(container.querySelector('#main-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
/* eslint-disable import-x/order */
|
||||
/**
|
||||
* Kiểm thử SearchLayout: layout đơn giản chỉ render children.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import * as React from 'react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import SearchLayout from '../layout';
|
||||
|
||||
describe('SearchLayout', () => {
|
||||
it('renders children', () => {
|
||||
render(
|
||||
<SearchLayout>
|
||||
<div data-testid="child">Nội dung tìm kiếm</div>
|
||||
</SearchLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('child')).toBeInTheDocument();
|
||||
expect(screen.getByText('Nội dung tìm kiếm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple children', () => {
|
||||
render(
|
||||
<SearchLayout>
|
||||
<p data-testid="a">A</p>
|
||||
<p data-testid="b">B</p>
|
||||
</SearchLayout>,
|
||||
);
|
||||
expect(screen.getByTestId('a')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not add extra wrapper markup', () => {
|
||||
const { container } = render(
|
||||
<SearchLayout>
|
||||
<span id="only-child">Span</span>
|
||||
</SearchLayout>,
|
||||
);
|
||||
// The layout returns children directly, so the span should be the root child
|
||||
expect(container.querySelector('#only-child')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type { HeatmapPoint } from '../district-heatmap';
|
||||
|
||||
vi.mock('mapbox-gl', () => {
|
||||
class MockMap {
|
||||
addControl = vi.fn();
|
||||
fitBounds = vi.fn();
|
||||
setStyle = vi.fn();
|
||||
remove = vi.fn();
|
||||
on = vi.fn();
|
||||
}
|
||||
class MockNavigationControl {}
|
||||
class MockMarker {
|
||||
setLngLat() { return this; }
|
||||
setPopup() { return this; }
|
||||
addTo() { return this; }
|
||||
remove() {}
|
||||
}
|
||||
class MockPopup {
|
||||
setHTML() { return this; }
|
||||
setLngLat() { return this; }
|
||||
addTo() { return this; }
|
||||
remove() {}
|
||||
}
|
||||
class MockLngLatBounds {
|
||||
extend() { return this; }
|
||||
isEmpty() { return false; }
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: MockMap,
|
||||
NavigationControl: MockNavigationControl,
|
||||
Marker: MockMarker,
|
||||
Popup: MockPopup,
|
||||
LngLatBounds: MockLngLatBounds,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('@/lib/mapbox-style', () => ({
|
||||
useMapboxStyle: () => 'mapbox://styles/mapbox/light-v11',
|
||||
MAPBOX_STYLE_DARK: 'mapbox://styles/mapbox/dark-v11',
|
||||
}));
|
||||
|
||||
import { DistrictHeatmap } from '../district-heatmap';
|
||||
|
||||
const sampleData: HeatmapPoint[] = [
|
||||
{ district: 'Quan 1', avgPriceM2: 80_000_000, totalListings: 120, medianPrice: '7500000000' },
|
||||
{ district: 'Quan 7', avgPriceM2: 45_000_000, totalListings: 85, medianPrice: '4500000000' },
|
||||
];
|
||||
|
||||
describe('DistrictHeatmap', () => {
|
||||
it('renders the map container', () => {
|
||||
render(<DistrictHeatmap data={sampleData} city="Ho Chi Minh" />);
|
||||
// Legend is always visible
|
||||
expect(screen.getByText('Giá trung bình/m²')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows token missing fallback when NEXT_PUBLIC_MAPBOX_TOKEN is absent', () => {
|
||||
delete (process.env as Record<string, string | undefined>)['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||
render(<DistrictHeatmap data={[]} city="Ho Chi Minh" />);
|
||||
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders legend labels', () => {
|
||||
render(<DistrictHeatmap data={sampleData} city="Ho Chi Minh" />);
|
||||
expect(screen.getByText('Thấp')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cao')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts optional className', () => {
|
||||
const { container } = render(
|
||||
<DistrictHeatmap data={sampleData} city="Ho Chi Minh" className="h-[600px]" />,
|
||||
);
|
||||
expect(container.firstChild).toHaveClass('h-[600px]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/chuyen-nhuong-api', () => ({
|
||||
CATEGORY_LABELS: { FURNITURE: 'Nội thất', APPLIANCE: 'Thiết bị', OFFICE_EQUIPMENT: 'Văn phòng', KITCHEN: 'Bếp', PREMISES: 'Mặt bằng', FULL_UNIT: 'Trọn gói' },
|
||||
CATEGORY_ICONS: {
|
||||
FURNITURE: () => <span data-testid="icon-furniture" />,
|
||||
APPLIANCE: () => <span data-testid="icon-appliance" />,
|
||||
OFFICE_EQUIPMENT: () => <span data-testid="icon-office" />,
|
||||
KITCHEN: () => <span data-testid="icon-kitchen" />,
|
||||
PREMISES: () => <span data-testid="icon-premises" />,
|
||||
FULL_UNIT: () => <span data-testid="icon-full" />,
|
||||
},
|
||||
STATUS_LABELS: { DRAFT: 'Nháp', PENDING_REVIEW: 'Chờ duyệt', ACTIVE: 'Đang đăng', RESERVED: 'Đã đặt cọc', SOLD: 'Đã bán', EXPIRED: 'Hết hạn', REJECTED: 'Từ chối' },
|
||||
CONDITION_LABELS: { NEW: 'Mới', LIKE_NEW: 'Như mới', GOOD: 'Tốt', FAIR: 'Trung bình', WORN: 'Cũ' },
|
||||
CONDITION_COLORS: { NEW: 'bg-green-100 text-green-800', LIKE_NEW: 'bg-blue-100 text-blue-800', GOOD: 'bg-emerald-100 text-emerald-800', FAIR: 'bg-amber-100 text-amber-800', WORN: 'bg-red-100 text-red-800' },
|
||||
}));
|
||||
|
||||
import { ChuyenNhuongDetailClient } from '../chuyen-nhuong-detail-client';
|
||||
|
||||
const listing = {
|
||||
id: 't1',
|
||||
sellerId: 'u1',
|
||||
category: 'FURNITURE' as const,
|
||||
status: 'ACTIVE' as const,
|
||||
title: 'Bộ nội thất văn phòng',
|
||||
description: 'Mô tả chi tiết',
|
||||
address: '123 Nguyễn Huệ',
|
||||
ward: 'Bến Nghé',
|
||||
district: 'Quận 1',
|
||||
city: 'Hồ Chí Minh',
|
||||
askingPriceVND: '15000000',
|
||||
aiEstimatePriceVND: '14000000',
|
||||
aiConfidence: 0.85,
|
||||
isNegotiable: true,
|
||||
areaM2: null,
|
||||
viewCount: 42,
|
||||
saveCount: 10,
|
||||
inquiryCount: 5,
|
||||
contactName: 'Nguyễn Văn A',
|
||||
contactPhone: '0912345678',
|
||||
items: [
|
||||
{ id: 'i1', name: 'Bàn', brand: null, modelName: null, category: 'FURNITURE' as const, condition: 'GOOD' as const, purchaseYear: 2022, originalPriceVND: 5000000, askingPriceVND: '3000000', aiEstimatePriceVND: null, quantity: 1, notes: null },
|
||||
],
|
||||
businessType: 'Quán cà phê',
|
||||
monthlyRentVND: '20000000',
|
||||
depositMonths: 3,
|
||||
remainingLeaseMo: 18,
|
||||
footTraffic: 'Cao',
|
||||
pricingSource: 'MANUAL' as const,
|
||||
} as never;
|
||||
|
||||
describe('ChuyenNhuongDetailClient', () => {
|
||||
it('renders listing title', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText('Bộ nội thất văn phòng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status badge', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText('Đang đăng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders negotiable badge', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText('Thương lượng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders asking price', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
// formatVND uses Intl.NumberFormat('vi-VN')
|
||||
expect(screen.getAllByText(/15\.000\.000/).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders AI confidence', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText('85%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders contact info', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText('Nguyễn Văn A')).toBeInTheDocument();
|
||||
expect(screen.getByText('0912345678')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders business info section', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText('Thông tin kinh doanh')).toBeInTheDocument();
|
||||
expect(screen.getByText('Quán cà phê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders items table heading', () => {
|
||||
render(<ChuyenNhuongDetailClient listing={listing} />);
|
||||
expect(screen.getByText(/Danh sách vật phẩm/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/chuyen-nhuong-api', () => ({
|
||||
CATEGORY_LABELS: { FURNITURE: 'Nội thất', APPLIANCE: 'Thiết bị', OFFICE_EQUIPMENT: 'Văn phòng', KITCHEN: 'Bếp', PREMISES: 'Mặt bằng', FULL_UNIT: 'Trọn gói' },
|
||||
CATEGORY_ICONS: {
|
||||
FURNITURE: () => <span data-testid="icon-furniture" />,
|
||||
APPLIANCE: () => <span data-testid="icon-appliance" />,
|
||||
OFFICE_EQUIPMENT: () => <span data-testid="icon-office" />,
|
||||
KITCHEN: () => <span data-testid="icon-kitchen" />,
|
||||
PREMISES: () => <span data-testid="icon-premises" />,
|
||||
FULL_UNIT: () => <span data-testid="icon-full" />,
|
||||
},
|
||||
CONDITION_LABELS: { NEW: 'Mới', LIKE_NEW: 'Như mới', GOOD: 'Tốt', FAIR: 'Trung bình', WORN: 'Cũ' },
|
||||
transferApi: { estimate: vi.fn(), create: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/transfer-wizard-store', () => {
|
||||
const state = {
|
||||
currentStep: 0,
|
||||
category: null as string | null,
|
||||
items: [] as unknown[],
|
||||
title: '',
|
||||
description: '',
|
||||
address: '',
|
||||
district: '',
|
||||
city: '',
|
||||
askingPriceVND: 0,
|
||||
pricingSource: 'MANUAL',
|
||||
isNegotiable: false,
|
||||
aiEstimate: null,
|
||||
isEstimating: false,
|
||||
setCategory: vi.fn(),
|
||||
setStep: vi.fn(),
|
||||
addItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
setAiEstimate: vi.fn(),
|
||||
setIsEstimating: vi.fn(),
|
||||
setListingDetails: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
};
|
||||
return {
|
||||
useTransferWizardStore: () => state,
|
||||
};
|
||||
});
|
||||
|
||||
import { TransferWizardClient } from '../transfer-wizard-client';
|
||||
|
||||
describe('TransferWizardClient', () => {
|
||||
it('renders wizard title', () => {
|
||||
render(<TransferWizardClient />);
|
||||
expect(screen.getByText('Đăng tin chuyển nhượng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders step indicators (4 steps)', () => {
|
||||
render(<TransferWizardClient />);
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders current step label "Danh mục" for step 0', () => {
|
||||
render(<TransferWizardClient />);
|
||||
expect(screen.getByText('Danh mục')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders category selection buttons', () => {
|
||||
render(<TransferWizardClient />);
|
||||
expect(screen.getByText('Nội thất')).toBeInTheDocument();
|
||||
expect(screen.getByText('Thiết bị')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders navigation buttons', () => {
|
||||
render(<TransferWizardClient />);
|
||||
expect(screen.getByText(/Quay lại/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Tiếp theo/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables back button on first step', () => {
|
||||
render(<TransferWizardClient />);
|
||||
const backBtn = screen.getByText(/Quay lại/).closest('button');
|
||||
expect(backBtn).toBeDisabled();
|
||||
});
|
||||
});
|
||||
45
apps/web/components/design-system/__tests__/badge.spec.tsx
Normal file
45
apps/web/components/design-system/__tests__/badge.spec.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Badge } from '../badge';
|
||||
|
||||
describe('Badge (design-system)', () => {
|
||||
it('renders children', () => {
|
||||
render(<Badge>Hoạt động</Badge>);
|
||||
expect(screen.getByText('Hoạt động')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as a span', () => {
|
||||
render(<Badge data-testid="b">Test</Badge>);
|
||||
expect(screen.getByTestId('b').tagName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('applies default variant classes', () => {
|
||||
render(<Badge data-testid="b">Default</Badge>);
|
||||
expect(screen.getByTestId('b')).toHaveClass('bg-muted');
|
||||
});
|
||||
|
||||
it('applies primary variant', () => {
|
||||
render(<Badge data-testid="b" variant="primary">Primary</Badge>);
|
||||
expect(screen.getByTestId('b')).toHaveClass('bg-primary/10');
|
||||
});
|
||||
|
||||
it('applies warning variant', () => {
|
||||
render(<Badge data-testid="b" variant="warning">Warning</Badge>);
|
||||
expect(screen.getByTestId('b')).toHaveClass('bg-warning/10');
|
||||
});
|
||||
|
||||
it('applies destructive variant', () => {
|
||||
render(<Badge data-testid="b" variant="destructive">Error</Badge>);
|
||||
expect(screen.getByTestId('b')).toHaveClass('bg-destructive/10');
|
||||
});
|
||||
|
||||
it('applies outline variant', () => {
|
||||
render(<Badge data-testid="b" variant="outline">Outline</Badge>);
|
||||
expect(screen.getByTestId('b')).toHaveClass('bg-transparent');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<Badge data-testid="b" className="custom-class">Custom</Badge>);
|
||||
expect(screen.getByTestId('b')).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CompactHeader } from '../compact-header';
|
||||
|
||||
describe('CompactHeader', () => {
|
||||
it('renders a header element', () => {
|
||||
render(<CompactHeader />);
|
||||
expect(screen.getByRole('banner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders logo when provided', () => {
|
||||
render(<CompactHeader logo={<span data-testid="logo" />} />);
|
||||
expect(screen.getByTestId('logo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders breadcrumb when provided', () => {
|
||||
render(<CompactHeader breadcrumb={<span>Trang chủ</span>} />);
|
||||
expect(screen.getByText('Trang chủ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search slot when provided', () => {
|
||||
render(<CompactHeader search={<input data-testid="search" />} />);
|
||||
expect(screen.getByTestId('search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actions when provided', () => {
|
||||
render(<CompactHeader actions={<button data-testid="act">Đăng nhập</button>} />);
|
||||
expect(screen.getByTestId('act')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render logo slot when omitted', () => {
|
||||
const { container } = render(<CompactHeader />);
|
||||
// no extra wrapping div for logo
|
||||
const header = container.querySelector('header') as HTMLElement;
|
||||
// Slot divs: just the ml-auto actions div should be present
|
||||
expect(header.children.length).toBe(1);
|
||||
});
|
||||
|
||||
it('is sticky and has border-b class', () => {
|
||||
render(<CompactHeader data-testid="ch" />);
|
||||
expect(screen.getByRole('banner')).toHaveClass('sticky', 'border-b');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<CompactHeader className="custom-header" />);
|
||||
expect(screen.getByRole('banner')).toHaveClass('custom-header');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
import { DensityToggle } from '../density-toggle';
|
||||
|
||||
// Mock the zustand store to avoid localStorage/persist issues in jsdom
|
||||
const mockToggleDensity = vi.fn();
|
||||
let mockDensity = 'regular';
|
||||
|
||||
vi.mock('@/lib/preferences-store', () => ({
|
||||
usePreferencesStore: () => ({
|
||||
get density() {
|
||||
return mockDensity;
|
||||
},
|
||||
toggleDensity: mockToggleDensity,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('DensityToggle', () => {
|
||||
beforeEach(() => {
|
||||
mockDensity = 'regular';
|
||||
mockToggleDensity.mockClear();
|
||||
});
|
||||
|
||||
it('renders a button with role="switch"', () => {
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has aria-checked="false" when density is regular', () => {
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'false');
|
||||
});
|
||||
|
||||
it('has aria-checked="true" when density is compact', () => {
|
||||
mockDensity = 'compact';
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-checked', 'true');
|
||||
});
|
||||
|
||||
it('calls toggleDensity on click', () => {
|
||||
render(<DensityToggle />);
|
||||
fireEvent.click(screen.getByRole('switch'));
|
||||
expect(mockToggleDensity).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('applies custom aria-label via label prop', () => {
|
||||
render(<DensityToggle label="Toggle view" />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-label', 'Toggle view');
|
||||
});
|
||||
|
||||
it('has a default aria-label', () => {
|
||||
render(<DensityToggle />);
|
||||
expect(screen.getByRole('switch')).toHaveAttribute('aria-label');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<DensityToggle className="extra" />);
|
||||
expect(screen.getByRole('switch')).toHaveClass('extra');
|
||||
});
|
||||
});
|
||||
41
apps/web/components/design-system/__tests__/divider.spec.tsx
Normal file
41
apps/web/components/design-system/__tests__/divider.spec.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Divider } from '../divider';
|
||||
|
||||
describe('Divider', () => {
|
||||
it('renders with role="separator"', () => {
|
||||
render(<Divider />);
|
||||
expect(screen.getByRole('separator')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('defaults to horizontal orientation', () => {
|
||||
render(<Divider data-testid="d" />);
|
||||
expect(screen.getByTestId('d')).toHaveAttribute('aria-orientation', 'horizontal');
|
||||
});
|
||||
|
||||
it('renders vertical orientation', () => {
|
||||
render(<Divider data-testid="d" orientation="vertical" />);
|
||||
expect(screen.getByTestId('d')).toHaveAttribute('aria-orientation', 'vertical');
|
||||
expect(screen.getByTestId('d')).toHaveClass('h-full', 'w-px');
|
||||
});
|
||||
|
||||
it('applies strong variant class', () => {
|
||||
render(<Divider data-testid="d" strong />);
|
||||
expect(screen.getByTestId('d')).toHaveClass('bg-border-strong');
|
||||
});
|
||||
|
||||
it('applies default border class without strong', () => {
|
||||
render(<Divider data-testid="d" />);
|
||||
expect(screen.getByTestId('d')).toHaveClass('bg-border');
|
||||
});
|
||||
|
||||
it('horizontal adds h-px and w-full', () => {
|
||||
render(<Divider data-testid="d" orientation="horizontal" />);
|
||||
expect(screen.getByTestId('d')).toHaveClass('h-px', 'w-full');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<Divider data-testid="d" className="my-custom" />);
|
||||
expect(screen.getByTestId('d')).toHaveClass('my-custom');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { EmptyState } from '../empty-state';
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title', () => {
|
||||
render(<EmptyState title="Không có dữ liệu" />);
|
||||
expect(screen.getByText('Không có dữ liệu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description when provided', () => {
|
||||
render(<EmptyState title="Title" description="Mô tả phụ" />);
|
||||
expect(screen.getByText('Mô tả phụ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render description when omitted', () => {
|
||||
render(<EmptyState title="Title" />);
|
||||
expect(screen.queryByText('Mô tả phụ')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon node', () => {
|
||||
render(<EmptyState title="Title" icon={<span data-testid="icon" />} />);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action node', () => {
|
||||
render(
|
||||
<EmptyState
|
||||
title="Title"
|
||||
action={<button data-testid="action">Thêm mới</button>}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId('action')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<EmptyState data-testid="es" title="T" className="custom" />);
|
||||
expect(screen.getByTestId('es')).toHaveClass('custom');
|
||||
});
|
||||
});
|
||||
87
apps/web/components/design-system/__tests__/footer.spec.tsx
Normal file
87
apps/web/components/design-system/__tests__/footer.spec.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Footer, type FooterProps } from '../footer';
|
||||
|
||||
const renderLink: FooterProps['renderLink'] = ({ href, children, className }) => (
|
||||
<a href={href} className={className}>{children}</a>
|
||||
);
|
||||
|
||||
const defaultProps: FooterProps = {
|
||||
brand: 'GoodGo',
|
||||
description: 'Nền tảng bất động sản',
|
||||
copyright: '© 2024 GoodGo',
|
||||
linkGroups: [
|
||||
{
|
||||
title: 'Sản phẩm',
|
||||
links: [
|
||||
{ label: 'Bán', href: '/ban' },
|
||||
{ label: 'Thuê', href: '/thue' },
|
||||
],
|
||||
},
|
||||
],
|
||||
renderLink,
|
||||
};
|
||||
|
||||
describe('Footer', () => {
|
||||
it('renders brand name', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.getByText('GoodGo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders description', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.getByText('Nền tảng bất động sản')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders copyright text', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.getByText('© 2024 GoodGo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders link group title', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.getByText('Sản phẩm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders link group links', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.getByText('Bán')).toBeInTheDocument();
|
||||
expect(screen.getByText('Thuê')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders contact address when provided', () => {
|
||||
render(
|
||||
<Footer
|
||||
{...defaultProps}
|
||||
contact={{ address: '123 Nguyễn Huệ, TP.HCM' }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('123 Nguyễn Huệ, TP.HCM')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders contact phone with tel link', () => {
|
||||
render(<Footer {...defaultProps} contact={{ phone: '0901234567' }} />);
|
||||
expect(screen.getByRole('link', { name: '0901234567' })).toHaveAttribute(
|
||||
'href',
|
||||
'tel:0901234567',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders contact email with mailto link', () => {
|
||||
render(<Footer {...defaultProps} contact={{ email: 'hello@goodgo.vn' }} />);
|
||||
expect(screen.getByRole('link', { name: 'hello@goodgo.vn' })).toHaveAttribute(
|
||||
'href',
|
||||
'mailto:hello@goodgo.vn',
|
||||
);
|
||||
});
|
||||
|
||||
it('renders footer element with role="contentinfo"', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.getByRole('contentinfo')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render contact section when omitted', () => {
|
||||
render(<Footer {...defaultProps} />);
|
||||
expect(screen.queryByText(/Nguyễn Huệ/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { KpiCard } from '../kpi-card';
|
||||
|
||||
describe('KpiCard', () => {
|
||||
it('renders label', () => {
|
||||
render(<KpiCard label="Giá TB" value="2.5 tỷ" />);
|
||||
expect(screen.getByText('Giá TB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders value', () => {
|
||||
render(<KpiCard label="Label" value="100" />);
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders footnote', () => {
|
||||
render(<KpiCard label="Label" value="100" footnote="Hôm nay" />);
|
||||
expect(screen.getByText('Hôm nay')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PriceDelta when delta provided', () => {
|
||||
render(<KpiCard label="Label" value="100" delta={1.5} />);
|
||||
expect(screen.getByText('+1.50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon', () => {
|
||||
render(<KpiCard label="L" value="V" icon={<span data-testid="icon" />} />);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders loading skeleton when loading=true', () => {
|
||||
const { container } = render(<KpiCard label="L" value="V" loading />);
|
||||
expect(container.querySelector('[aria-busy]')).toBeInTheDocument();
|
||||
expect(container.querySelector('.animate-pulse')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show value when loading', () => {
|
||||
render(<KpiCard label="Label" value="100" loading />);
|
||||
expect(screen.queryByText('100')).toBeNull();
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<KpiCard data-testid="kc" label="L" value="V" className="extra" />);
|
||||
expect(screen.getByTestId('kc')).toHaveClass('extra');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { MarketIndex } from '../market-index';
|
||||
|
||||
describe('MarketIndex', () => {
|
||||
it('renders index name', () => {
|
||||
render(<MarketIndex name="GGX Market" value="1,234" changePercent={2.5} />);
|
||||
expect(screen.getByText('GGX Market')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders value', () => {
|
||||
render(<MarketIndex name="GGX" value="1,234" changePercent={2.5} />);
|
||||
expect(screen.getByText('1,234')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PriceDelta for positive changePercent', () => {
|
||||
render(<MarketIndex name="GGX" value="1,234" changePercent={2.5} />);
|
||||
expect(screen.getByText('+2.50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PriceDelta for negative changePercent', () => {
|
||||
render(<MarketIndex name="GGX" value="1,234" changePercent={-1.2} />);
|
||||
expect(screen.getByText('-1.20%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders default window "24h" when change is provided', () => {
|
||||
render(<MarketIndex name="GGX" value="1,234" changePercent={1} change="+10" />);
|
||||
expect(screen.getByText(/24h/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders custom window when specified', () => {
|
||||
render(
|
||||
<MarketIndex name="GGX" value="1,234" changePercent={1} window="7 ngày" />,
|
||||
);
|
||||
expect(screen.getByText('7 ngày')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders change value with window in parentheses', () => {
|
||||
render(
|
||||
<MarketIndex name="GGX" value="1,234" changePercent={1} change="+50" window="24h" />,
|
||||
);
|
||||
expect(screen.getByText('+50 (24h)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders value with data-numeric', () => {
|
||||
render(<MarketIndex name="GGX" value="1,234" changePercent={1} />);
|
||||
const elements = document.querySelectorAll('[data-numeric]');
|
||||
expect(elements.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(
|
||||
<MarketIndex data-testid="mi" name="GGX" value="1,234" changePercent={1} className="extra" />,
|
||||
);
|
||||
expect(screen.getByTestId('mi')).toHaveClass('extra');
|
||||
});
|
||||
});
|
||||
45
apps/web/components/design-system/__tests__/numeric.spec.tsx
Normal file
45
apps/web/components/design-system/__tests__/numeric.spec.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Numeric } from '../numeric';
|
||||
|
||||
describe('Numeric', () => {
|
||||
it('formats VND by default', () => {
|
||||
render(<Numeric value={1000000} />);
|
||||
// Intl.NumberFormat vi-VN with currency VND — should contain digits
|
||||
expect(screen.getByText(/1\.000\.000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats percent', () => {
|
||||
render(<Numeric value={5.5} format="percent" />);
|
||||
expect(screen.getByText('+5.5%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats negative percent without plus sign', () => {
|
||||
render(<Numeric value={-3.2} format="percent" />);
|
||||
expect(screen.getByText('-3.2%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats decimal', () => {
|
||||
render(<Numeric value={1234.5} format="decimal" fractionDigits={2} />);
|
||||
// vi-VN decimal uses comma as decimal separator
|
||||
expect(screen.getByText(/1\.234/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats compact', () => {
|
||||
render(<Numeric value={2000000} format="compact" />);
|
||||
expect(screen.getByText(/\d/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as span with data-numeric attribute', () => {
|
||||
render(<Numeric value={100} data-testid="num" />);
|
||||
const el = screen.getByTestId('num');
|
||||
expect(el.tagName).toBe('SPAN');
|
||||
expect(el).toHaveAttribute('data-numeric');
|
||||
});
|
||||
|
||||
it('has tabular-nums and font-mono classes', () => {
|
||||
render(<Numeric value={100} data-testid="num" />);
|
||||
const el = screen.getByTestId('num');
|
||||
expect(el).toHaveClass('tabular-nums', 'font-mono');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PriceDelta } from '../price-delta';
|
||||
|
||||
describe('PriceDelta', () => {
|
||||
it('renders formatted positive value with default unit %', () => {
|
||||
render(<PriceDelta value={3.14} />);
|
||||
expect(screen.getByText('+3.14%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders negative value', () => {
|
||||
render(<PriceDelta value={-2.5} />);
|
||||
expect(screen.getByText('-2.50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders zero value as neutral', () => {
|
||||
render(<PriceDelta value={0} />);
|
||||
// 0 is not > 0 so no "+" prefix
|
||||
expect(screen.getByText('0.00%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with custom unit', () => {
|
||||
render(<PriceDelta value={1.5} unit=" tr" precision={1} />);
|
||||
expect(screen.getByText('+1.5 tr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides icon when hideIcon=true', () => {
|
||||
const { container } = render(<PriceDelta value={2} hideIcon />);
|
||||
// With hideIcon, the ArrowUp/Down/Minus svg is not rendered
|
||||
expect(container.querySelector('svg')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders icon svg by default', () => {
|
||||
const { container } = render(<PriceDelta value={2} />);
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has data-numeric attribute on root span', () => {
|
||||
render(<PriceDelta data-testid="pd" value={1} />);
|
||||
expect(screen.getByTestId('pd')).toHaveAttribute('data-numeric');
|
||||
});
|
||||
|
||||
it('has font-mono class', () => {
|
||||
render(<PriceDelta data-testid="pd" value={1} />);
|
||||
expect(screen.getByTestId('pd')).toHaveClass('font-mono');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<PriceDelta data-testid="pd" value={1} className="extra" />);
|
||||
expect(screen.getByTestId('pd')).toHaveClass('extra');
|
||||
});
|
||||
|
||||
it('renders direction override independently of value sign', () => {
|
||||
// Positive value with forced down direction still shows the down arrow
|
||||
const { container } = render(<PriceDelta value={5} direction="down" />);
|
||||
// Should still render an svg (ArrowDown)
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
// Value text is formatted from the `value` prop
|
||||
expect(screen.getByText('+5.00%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
45
apps/web/components/design-system/__tests__/signal.spec.tsx
Normal file
45
apps/web/components/design-system/__tests__/signal.spec.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Signal } from '../signal';
|
||||
|
||||
describe('Signal', () => {
|
||||
it('renders without a label', () => {
|
||||
const { container } = render(<Signal direction="up" />);
|
||||
expect(container.querySelector('span')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label text when provided', () => {
|
||||
render(<Signal direction="up" label="Tăng" />);
|
||||
expect(screen.getByText('Tăng')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon with aria-hidden for up direction', () => {
|
||||
const { container } = render(<Signal direction="up" label="Up" />);
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon with aria-hidden for down direction', () => {
|
||||
const { container } = render(<Signal direction="down" label="Down" />);
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon with aria-hidden for neutral direction', () => {
|
||||
const { container } = render(<Signal direction="neutral" label="Neutral" />);
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an inline span', () => {
|
||||
const { container } = render(<Signal direction="up" label="x" />);
|
||||
expect(container.firstChild?.nodeName).toBe('SPAN');
|
||||
});
|
||||
|
||||
it('merges custom className onto root span', () => {
|
||||
const { container } = render(<Signal direction="up" className="extra" label="x" />);
|
||||
expect(container.firstChild).toHaveClass('extra');
|
||||
});
|
||||
|
||||
it('contains an svg icon', () => {
|
||||
const { container } = render(<Signal direction="up" />);
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Skeleton } from '../skeleton';
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('renders base skeleton with animate-pulse', () => {
|
||||
const { container } = render(<Skeleton />);
|
||||
expect(container.firstChild).toHaveClass('animate-pulse');
|
||||
});
|
||||
|
||||
it('base skeleton has aria-hidden', () => {
|
||||
const { container } = render(<Skeleton />);
|
||||
expect(container.firstChild).toHaveAttribute('aria-hidden');
|
||||
});
|
||||
|
||||
it('Skeleton.Row renders row placeholder', () => {
|
||||
const { container } = render(<Skeleton.Row />);
|
||||
expect(container.firstChild).toHaveAttribute('aria-hidden');
|
||||
// Should have multiple skeleton blocks
|
||||
expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Skeleton.Card renders card placeholder', () => {
|
||||
const { container } = render(<Skeleton.Card />);
|
||||
expect(container.firstChild).toHaveAttribute('aria-hidden');
|
||||
});
|
||||
|
||||
it('Skeleton.Table renders header and default 5 rows', () => {
|
||||
const { container } = render(<Skeleton.Table />);
|
||||
expect(container.firstChild).toHaveAttribute('aria-hidden');
|
||||
// header + 5 rows = 6 row-like blocks; just check children exist
|
||||
expect(container.children.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('Skeleton.Table renders custom row count', () => {
|
||||
const { container } = render(<Skeleton.Table rows={3} />);
|
||||
// container.firstChild has header div + 3 SkeletonRow children
|
||||
const el = container.firstChild as HTMLElement;
|
||||
// header + 3 rows
|
||||
expect(el.children.length).toBe(4);
|
||||
});
|
||||
|
||||
it('merges custom className on base', () => {
|
||||
render(<Skeleton data-testid="sk" className="h-10" />);
|
||||
expect(screen.getByTestId('sk')).toHaveClass('h-10');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StatCard } from '../stat-card';
|
||||
|
||||
describe('StatCard', () => {
|
||||
it('renders label', () => {
|
||||
render(<StatCard label="Giá TB/m²" value="25" />);
|
||||
expect(screen.getByText('Giá TB/m²')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders string value', () => {
|
||||
render(<StatCard label="Label" value="25 tr/m²" />);
|
||||
expect(screen.getByText('25 tr/m²')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders number value', () => {
|
||||
render(<StatCard label="Label" value={1234} />);
|
||||
expect(screen.getByText('1234')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders unit when provided', () => {
|
||||
render(<StatCard label="Label" value="25" unit="tr/m²" />);
|
||||
expect(screen.getByText('tr/m²')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders PriceDelta when delta provided', () => {
|
||||
const { container } = render(<StatCard label="Label" value="25" delta={3.5} />);
|
||||
expect(container.querySelector('[data-numeric]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render delta section when delta is undefined', () => {
|
||||
render(<StatCard label="Label" value="25" />);
|
||||
// no PriceDelta text like +x%
|
||||
expect(screen.queryByText(/\+\d/)).toBeNull();
|
||||
});
|
||||
|
||||
it('renders sublabel', () => {
|
||||
render(<StatCard label="Label" value="25" sublabel="7 ngày" />);
|
||||
expect(screen.getByText('7 ngày')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders icon node', () => {
|
||||
render(
|
||||
<StatCard label="Label" value="25" icon={<span data-testid="icon" />} />,
|
||||
);
|
||||
expect(screen.getByTestId('icon')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<StatCard data-testid="sc" label="L" value="V" className="extra" />);
|
||||
expect(screen.getByTestId('sc')).toHaveClass('extra');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { StatusChip, type PropertyStatus } from '../status-chip';
|
||||
|
||||
const statuses: PropertyStatus[] = ['active', 'pending', 'sold', 'rented', 'rejected', 'draft'];
|
||||
|
||||
const labels: Record<PropertyStatus, string> = {
|
||||
active: 'Đang bán',
|
||||
pending: 'Chờ duyệt',
|
||||
sold: 'Đã bán',
|
||||
rented: 'Đã thuê',
|
||||
rejected: 'Từ chối',
|
||||
draft: 'Bản nháp',
|
||||
};
|
||||
|
||||
describe('StatusChip', () => {
|
||||
statuses.forEach((status) => {
|
||||
it(`renders label for status "${status}"`, () => {
|
||||
render(<StatusChip status={status} />);
|
||||
expect(screen.getByText(labels[status])).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows dot indicator by default', () => {
|
||||
const { container } = render(<StatusChip status="active" />);
|
||||
// dot is a span with aria-hidden
|
||||
expect(container.querySelector('[aria-hidden]')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides dot when hideDot=true', () => {
|
||||
const { container } = render(<StatusChip status="active" hideDot />);
|
||||
expect(container.querySelector('[aria-hidden]')).toBeNull();
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<StatusChip data-testid="sc" status="active" className="extra" />);
|
||||
expect(screen.getByTestId('sc')).toHaveClass('extra');
|
||||
});
|
||||
|
||||
it('applies active color classes', () => {
|
||||
render(<StatusChip data-testid="sc" status="active" />);
|
||||
expect(screen.getByTestId('sc')).toHaveClass('bg-signal-up-bg');
|
||||
});
|
||||
|
||||
it('applies rejected color classes', () => {
|
||||
render(<StatusChip data-testid="sc" status="rejected" />);
|
||||
expect(screen.getByTestId('sc')).toHaveClass('bg-destructive/10');
|
||||
});
|
||||
});
|
||||
42
apps/web/components/design-system/__tests__/surface.spec.tsx
Normal file
42
apps/web/components/design-system/__tests__/surface.spec.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Surface, SurfaceElevated } from '../surface';
|
||||
|
||||
describe('Surface', () => {
|
||||
it('renders children', () => {
|
||||
render(<Surface>Content</Surface>);
|
||||
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has bg-background class', () => {
|
||||
render(<Surface data-testid="s">x</Surface>);
|
||||
expect(screen.getByTestId('s')).toHaveClass('bg-background');
|
||||
});
|
||||
|
||||
it('has rounded-lg class', () => {
|
||||
render(<Surface data-testid="s">x</Surface>);
|
||||
expect(screen.getByTestId('s')).toHaveClass('rounded-lg');
|
||||
});
|
||||
|
||||
it('merges custom className', () => {
|
||||
render(<Surface data-testid="s" className="p-4">x</Surface>);
|
||||
expect(screen.getByTestId('s')).toHaveClass('p-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SurfaceElevated', () => {
|
||||
it('renders children', () => {
|
||||
render(<SurfaceElevated>Elevated</SurfaceElevated>);
|
||||
expect(screen.getByText('Elevated')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has bg-background-elevated class', () => {
|
||||
render(<SurfaceElevated data-testid="se">x</SurfaceElevated>);
|
||||
expect(screen.getByTestId('se')).toHaveClass('bg-background-elevated');
|
||||
});
|
||||
|
||||
it('has shadow-elevation-1 class', () => {
|
||||
render(<SurfaceElevated data-testid="se">x</SurfaceElevated>);
|
||||
expect(screen.getByTestId('se')).toHaveClass('shadow-elevation-1');
|
||||
});
|
||||
});
|
||||
131
apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx
Normal file
131
apps/web/components/du-an/__tests__/du-an-detail-client.spec.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('next/dynamic', () => ({
|
||||
__esModule: true,
|
||||
default: () => {
|
||||
const Stub = () => <div data-testid="dynamic-stub" />;
|
||||
Stub.displayName = 'DynamicStub';
|
||||
return Stub;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('next/image', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/analytics-api', () => ({
|
||||
analyticsApi: {
|
||||
getNearbyPOIs: vi.fn().mockResolvedValue({ pois: [] }),
|
||||
getProjectAiAdvice: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/listings-api', () => ({
|
||||
listingsApi: { getNeighborhoodScore: vi.fn().mockRejectedValue(new Error('noop')) },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/du-an-api', () => ({
|
||||
PROJECT_PROPERTY_TYPE_LABELS: { APARTMENT: 'Căn hộ', VILLA: 'Biệt thự' },
|
||||
PROJECT_STATUS_COLORS: { PRE_SALE: 'bg-blue-100 text-blue-800', SELLING: 'bg-green-100 text-green-800' },
|
||||
PROJECT_STATUS_LABELS: { PRE_SALE: 'Mở bán sắp tới', SELLING: 'Đang bán' },
|
||||
duAnApi: { submitInquiry: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/currency', () => ({
|
||||
formatPrice: (v: string | number) => `${v} VNĐ`,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/project-personas', () => ({
|
||||
composeWhyThisProject: () => null,
|
||||
deriveProjectPersonas: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('@/components/du-an/project-ai-advice-card', () => ({
|
||||
ProjectAiAdviceCard: () => <div data-testid="ai-advice-stub" />,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/listings/image-gallery', () => ({
|
||||
ImageGallery: () => <div data-testid="gallery-stub" />,
|
||||
}));
|
||||
|
||||
import { DuAnDetailClient } from '../du-an-detail-client';
|
||||
|
||||
const project = {
|
||||
id: 'p1',
|
||||
name: 'Vinhomes Grand Park',
|
||||
slug: 'vinhomes-grand-park',
|
||||
status: 'SELLING' as const,
|
||||
propertyTypes: ['APARTMENT' as const],
|
||||
address: '128 Nguyễn Xiển',
|
||||
district: 'Quận 9',
|
||||
city: 'Hồ Chí Minh',
|
||||
latitude: 10.84,
|
||||
longitude: 106.83,
|
||||
description: 'Dự án lớn nhất TP.HCM',
|
||||
totalArea: 271000,
|
||||
totalUnits: 43000,
|
||||
minPrice: '2000000000',
|
||||
completionDate: '2025-12-01',
|
||||
developer: { name: 'Vingroup', logoUrl: null, totalProjects: 12 },
|
||||
blocks: [{ id: 'b1', name: 'S1', totalUnits: 500, availableUnits: 20, floors: 35 }],
|
||||
amenities: [{ id: 'a1', name: 'Hồ bơi', category: 'Thể thao' }],
|
||||
priceRanges: [],
|
||||
priceHistory: [],
|
||||
media: [],
|
||||
linkedListingCount: 5,
|
||||
pois: [],
|
||||
neighborhoodScores: [],
|
||||
documents: [],
|
||||
suitableFor: [],
|
||||
whyThisLocation: null,
|
||||
} as never;
|
||||
|
||||
describe('DuAnDetailClient', () => {
|
||||
it('renders project name', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getByText('Vinhomes Grand Park')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status badge', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getByText('Đang bán')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders property type badge', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getByText('Căn hộ')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders developer name', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getAllByText('Vingroup').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders quick stats', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getByText(/271\.000/)).toBeInTheDocument(); // total area
|
||||
expect(screen.getByText('43000')).toBeInTheDocument(); // total units
|
||||
});
|
||||
|
||||
it('renders blocks section', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getByText('Phân khu / Block')).toBeInTheDocument();
|
||||
expect(screen.getByText('S1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders inquiry form', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getAllByText('Nhận tư vấn').length).toBeGreaterThan(0);
|
||||
expect(screen.getByLabelText('Họ tên')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tabs', () => {
|
||||
render(<DuAnDetailClient project={project} />);
|
||||
expect(screen.getByRole('tab', { name: 'Tiện ích' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Vị trí' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Giá' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
vi.mock('@/lib/analytics-api', () => ({
|
||||
analyticsApi: { getProjectAiAdvice: vi.fn().mockReturnValue(new Promise(() => {})) },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/api-client', () => ({
|
||||
ApiError: class extends Error { status = 500; },
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/auth-store', () => ({
|
||||
useAuthStore: (selector: (s: { user: null }) => unknown) => selector({ user: null }),
|
||||
}));
|
||||
|
||||
import { ProjectAiAdviceCard } from '../project-ai-advice-card';
|
||||
|
||||
function wrapper({ children }: { children: React.ReactNode }) {
|
||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
||||
}
|
||||
|
||||
describe('ProjectAiAdviceCard', () => {
|
||||
it('renders trigger button initially', () => {
|
||||
render(<ProjectAiAdviceCard projectId="p1" />, { wrapper });
|
||||
expect(screen.getByText('Xem phân tích AI về dự án')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows loading state when clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<ProjectAiAdviceCard projectId="p1" />, { wrapper });
|
||||
await user.click(screen.getByText('Xem phân tích AI về dự án'));
|
||||
expect(screen.getByText(/AI đang phân tích/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
73
apps/web/components/du-an/__tests__/project-map.spec.tsx
Normal file
73
apps/web/components/du-an/__tests__/project-map.spec.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('mapbox-gl', () => {
|
||||
class MockMap {
|
||||
addControl = vi.fn();
|
||||
fitBounds = vi.fn();
|
||||
flyTo = vi.fn();
|
||||
setStyle = vi.fn();
|
||||
remove = vi.fn();
|
||||
on = vi.fn();
|
||||
}
|
||||
class MockNavigationControl {}
|
||||
class MockAttributionControl {}
|
||||
class MockMarker {
|
||||
setLngLat() { return this; }
|
||||
setPopup() { return this; }
|
||||
addTo() { return this; }
|
||||
remove() {}
|
||||
}
|
||||
class MockPopup {
|
||||
setHTML() { return this; }
|
||||
setLngLat() { return this; }
|
||||
addTo() { return this; }
|
||||
remove() {}
|
||||
}
|
||||
class MockLngLatBounds {
|
||||
extend() { return this; }
|
||||
isEmpty() { return false; }
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: MockMap,
|
||||
NavigationControl: MockNavigationControl,
|
||||
AttributionControl: MockAttributionControl,
|
||||
Marker: MockMarker,
|
||||
Popup: MockPopup,
|
||||
LngLatBounds: MockLngLatBounds,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('@/lib/mapbox-style', () => ({ useMapboxStyle: () => 'mapbox://styles/mapbox/light-v11' }));
|
||||
vi.mock('@/lib/currency', () => ({ formatPrice: (v: string | number) => `${v} VNĐ` }));
|
||||
vi.mock('@/lib/du-an-api', () => ({
|
||||
PROJECT_STATUS_LABELS: { SELLING: 'Đang bán' },
|
||||
PROJECT_STATUS_COLORS: { SELLING: 'bg-green-100 text-green-800' },
|
||||
}));
|
||||
|
||||
import { ProjectMap } from '../project-map';
|
||||
|
||||
const projects = [
|
||||
{ id: 'p1', name: 'Vinhomes', slug: 'vinhomes', status: 'SELLING' as const, district: 'Q9', city: 'HCMC', minPrice: '2000000000', latitude: 10.84, longitude: 106.83 },
|
||||
] as never[];
|
||||
|
||||
describe('ProjectMap', () => {
|
||||
it('renders fallback when no token', () => {
|
||||
delete (process.env as Record<string, string | undefined>)['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||
render(<ProjectMap projects={[]} />);
|
||||
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows project count overlay', () => {
|
||||
render(<ProjectMap projects={projects} />);
|
||||
expect(screen.getByText(/1 dự án trên bản đồ/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows 0 projects when empty', () => {
|
||||
render(<ProjectMap projects={[]} />);
|
||||
expect(screen.getByText(/0 dự án trên bản đồ/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/khu-cong-nghiep-api', () => ({
|
||||
PARK_STATUS_LABELS: { OPERATIONAL: 'Hoạt động', PLANNING: 'Quy hoạch', UNDER_CONSTRUCTION: 'Đang xây dựng', FULL: 'Đã lấp đầy' },
|
||||
PARK_STATUS_COLORS: { OPERATIONAL: 'bg-green-100 text-green-800', PLANNING: 'bg-blue-100 text-blue-800', UNDER_CONSTRUCTION: 'bg-amber-100 text-amber-800', FULL: 'bg-red-100 text-red-800' },
|
||||
REGION_LABELS: { NORTH: 'Miền Bắc', CENTRAL: 'Miền Trung', SOUTH: 'Miền Nam' },
|
||||
}));
|
||||
|
||||
import { KhuCongNghiepDetailClient } from '../khu-cong-nghiep-detail-client';
|
||||
|
||||
const park = {
|
||||
id: 'ip1',
|
||||
name: 'KCN Tân Bình',
|
||||
nameEn: 'Tan Binh IP',
|
||||
slug: 'kcn-tan-binh',
|
||||
developer: 'Becamex',
|
||||
operator: 'Becamex IDC',
|
||||
status: 'OPERATIONAL' as const,
|
||||
latitude: 10.8,
|
||||
longitude: 106.6,
|
||||
address: '123 Đại lộ',
|
||||
district: 'Tân Bình',
|
||||
province: 'Bình Dương',
|
||||
region: 'SOUTH' as const,
|
||||
totalAreaHa: 500,
|
||||
leasableAreaHa: 400,
|
||||
occupancyRate: 85,
|
||||
remainingAreaHa: 60,
|
||||
tenantCount: 120,
|
||||
listingCount: 15,
|
||||
establishedYear: 2005,
|
||||
isVerified: true,
|
||||
landRentUsdM2Year: '55.0000',
|
||||
rbfRentUsdM2Month: '4.5000',
|
||||
rbwRentUsdM2Month: '3.2000',
|
||||
managementFeeUsd: '0.8000',
|
||||
targetIndustries: ['Điện tử', 'Cơ khí'],
|
||||
certifications: ['ISO 14001'],
|
||||
description: 'KCN hàng đầu',
|
||||
infrastructure: { power: '110kV', water: '50,000 m³/ngày' },
|
||||
connectivity: { airport: { name: 'Tân Sơn Nhất', distanceKm: 25 } },
|
||||
incentives: { cit: '2 năm miễn thuế' },
|
||||
existingTenants: [{ name: 'Samsung', country: 'Hàn Quốc', industry: 'Điện tử' }],
|
||||
documents: [{ name: 'Brochure.pdf', url: '/docs/b.pdf' }],
|
||||
} as never;
|
||||
|
||||
describe('KhuCongNghiepDetailClient', () => {
|
||||
it('renders park name', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('KCN Tân Bình')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders English name', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('Tan Binh IP')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders status badge', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('Hoạt động')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders verified badge', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders quick stats', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('500 ha')).toBeInTheDocument();
|
||||
expect(screen.getByText('85%')).toBeInTheDocument();
|
||||
expect(screen.getByText('120')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders target industries', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('Điện tử')).toBeInTheDocument();
|
||||
expect(screen.getByText('Cơ khí')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders rent info', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByText('$55.0000/m²/năm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders tabs', () => {
|
||||
render(<KhuCongNghiepDetailClient park={park} />);
|
||||
expect(screen.getByRole('tab', { name: 'Hạ tầng' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: 'Doanh nghiệp' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <a {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('@/components/khu-cong-nghiep/listing-card', () => ({
|
||||
IndustrialListingCard: ({ listing }: { listing: { id: string; title?: string } }) => (
|
||||
<div data-testid={`listing-${listing.id}`}>listing</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/use-khu-cong-nghiep', () => ({
|
||||
useIndustrialListingsSearch: () => ({
|
||||
data: { data: [], total: 0, page: 1, totalPages: 1 },
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/khu-cong-nghiep-api', () => ({
|
||||
PROPERTY_TYPE_LABELS: { FACTORY: 'Nhà xưởng', WAREHOUSE: 'Kho bãi', LAND: 'Đất CN' },
|
||||
LEASE_TYPE_LABELS: { LONG_TERM: 'Dài hạn', SHORT_TERM: 'Ngắn hạn' },
|
||||
}));
|
||||
|
||||
import { ListingSearchClient } from '../listing-search-client';
|
||||
|
||||
describe('ListingSearchClient', () => {
|
||||
it('renders page heading', () => {
|
||||
render(<ListingSearchClient />);
|
||||
expect(screen.getByText('Cho Thuê Bất Động Sản Công Nghiệp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders search input', () => {
|
||||
render(<ListingSearchClient />);
|
||||
expect(screen.getByPlaceholderText(/Tìm kiếm/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders filter dropdowns', () => {
|
||||
render(<ListingSearchClient />);
|
||||
expect(screen.getByLabelText('Loại BĐS')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Hình thức')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no results', () => {
|
||||
render(<ListingSearchClient />);
|
||||
expect(screen.getByText('Không tìm thấy tin cho thuê')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('recharts', () => ({
|
||||
Radar: () => null,
|
||||
RadarChart: ({ children }: { children: React.ReactNode }) => <div data-testid="radar-chart">{children}</div>,
|
||||
PolarGrid: () => null,
|
||||
PolarAngleAxis: () => null,
|
||||
PolarRadiusAxis: () => null,
|
||||
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
Legend: () => null,
|
||||
Tooltip: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/i18n/navigation', () => ({
|
||||
Link: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => <a {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/hooks/use-khu-cong-nghiep', () => ({
|
||||
useIndustrialCompare: () => ({ data: undefined, isLoading: false }),
|
||||
useIndustrialParksSearch: () => ({ data: { data: [] } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/khu-cong-nghiep-api', () => ({
|
||||
PARK_STATUS_COLORS: { OPERATIONAL: 'bg-green-100 text-green-800' },
|
||||
PARK_STATUS_LABELS: { OPERATIONAL: 'Hoạt động' },
|
||||
REGION_LABELS: { SOUTH: 'Miền Nam' },
|
||||
}));
|
||||
|
||||
import { ParkCompareClient } from '../park-compare-client';
|
||||
|
||||
describe('ParkCompareClient', () => {
|
||||
it('renders heading', () => {
|
||||
render(<ParkCompareClient />);
|
||||
expect(screen.getByText('So Sánh Khu Công Nghiệp')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when fewer than 2 parks selected', () => {
|
||||
render(<ParkCompareClient />);
|
||||
expect(screen.getByText('Chọn ít nhất 2 KCN để so sánh')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders add park button', () => {
|
||||
render(<ParkCompareClient />);
|
||||
expect(screen.getByText('Thêm KCN')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('mapbox-gl', () => {
|
||||
class MockMap {
|
||||
addControl = vi.fn();
|
||||
fitBounds = vi.fn();
|
||||
flyTo = vi.fn();
|
||||
setStyle = vi.fn();
|
||||
remove = vi.fn();
|
||||
on = vi.fn();
|
||||
}
|
||||
class MockNavigationControl {}
|
||||
class MockAttributionControl {}
|
||||
class MockMarker {
|
||||
setLngLat() { return this; }
|
||||
setPopup() { return this; }
|
||||
addTo() { return this; }
|
||||
remove() {}
|
||||
}
|
||||
class MockPopup {
|
||||
setHTML() { return this; }
|
||||
setLngLat() { return this; }
|
||||
addTo() { return this; }
|
||||
remove() {}
|
||||
}
|
||||
class MockLngLatBounds {
|
||||
extend() { return this; }
|
||||
isEmpty() { return false; }
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
accessToken: '',
|
||||
Map: MockMap,
|
||||
NavigationControl: MockNavigationControl,
|
||||
AttributionControl: MockAttributionControl,
|
||||
Marker: MockMarker,
|
||||
Popup: MockPopup,
|
||||
LngLatBounds: MockLngLatBounds,
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('@/lib/mapbox-style', () => ({ useMapboxStyle: () => 'mapbox://styles/mapbox/light-v11' }));
|
||||
vi.mock('@/lib/khu-cong-nghiep-api', () => ({
|
||||
PARK_STATUS_LABELS: { OPERATIONAL: 'Hoạt động' },
|
||||
PARK_STATUS_COLORS: { OPERATIONAL: 'bg-green-100 text-green-800' },
|
||||
}));
|
||||
|
||||
import { ParkMap } from '../park-map';
|
||||
|
||||
const parks = [
|
||||
{ id: 'ip1', name: 'KCN A', slug: 'kcn-a', status: 'OPERATIONAL' as const, province: 'Bình Dương', totalAreaHa: 500, occupancyRate: 80, tenantCount: 50, landRentUsdM2Year: '55', latitude: 10.8, longitude: 106.6 },
|
||||
] as never[];
|
||||
|
||||
describe('ParkMap', () => {
|
||||
it('renders fallback when no token', () => {
|
||||
delete (process.env as Record<string, string | undefined>)['NEXT_PUBLIC_MAPBOX_TOKEN'];
|
||||
render(<ParkMap parks={[]} />);
|
||||
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows park count overlay', () => {
|
||||
render(<ParkMap parks={parks} />);
|
||||
expect(screen.getByText(/1 KCN trên bản đồ/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows 0 parks when empty', () => {
|
||||
render(<ParkMap parks={[]} />);
|
||||
expect(screen.getByText(/0 KCN trên bản đồ/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user