251211:1314 Frontend: reeactor Admin panel
This commit is contained in:
@@ -29,6 +29,8 @@ import { MaintenanceModeGuard } from './common/guards/maintenance-mode.guard';
|
||||
import { AuthModule } from './common/auth/auth.module.js';
|
||||
import { UserModule } from './modules/user/user.module';
|
||||
import { ProjectModule } from './modules/project/project.module';
|
||||
import { OrganizationModule } from './modules/organization/organization.module';
|
||||
import { ContractModule } from './modules/contract/contract.module';
|
||||
import { MasterModule } from './modules/master/master.module'; // [NEW] ✅ เพิ่ม MasterModule
|
||||
import { FileStorageModule } from './common/file-storage/file-storage.module.js';
|
||||
import { DocumentNumberingModule } from './modules/document-numbering/document-numbering.module';
|
||||
@@ -138,7 +140,10 @@ import { AuditLogModule } from './modules/audit-log/audit-log.module';
|
||||
// 📦 Feature Modules
|
||||
AuthModule,
|
||||
UserModule,
|
||||
UserModule,
|
||||
ProjectModule,
|
||||
OrganizationModule,
|
||||
ContractModule,
|
||||
MasterModule, // ✅ [NEW] Register MasterModule here
|
||||
FileStorageModule,
|
||||
DocumentNumberingModule,
|
||||
|
||||
@@ -244,16 +244,16 @@ export class AuthService {
|
||||
|
||||
const now = new Date();
|
||||
// Filter expired tokens in memory if query builder is complex, or rely on where clause if possible.
|
||||
// Since we want to return mapped data:
|
||||
// Filter expired tokens
|
||||
return activeTokens
|
||||
.filter((t) => t.expiresAt > now)
|
||||
.filter((t) => new Date(t.expiresAt) > now)
|
||||
.map((t) => ({
|
||||
id: t.tokenId.toString(),
|
||||
userId: t.userId,
|
||||
user: {
|
||||
username: t.user?.username || 'Unknown',
|
||||
first_name: t.user?.firstName || '',
|
||||
last_name: t.user?.lastName || '',
|
||||
firstName: t.user?.firstName || '',
|
||||
lastName: t.user?.lastName || '',
|
||||
},
|
||||
deviceName: 'Unknown Device', // Not stored in DB
|
||||
ipAddress: 'Unknown IP', // Not stored in DB
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { Organization } from '../../modules/organizations/entities/organization.entity';
|
||||
import { Organization } from '../../modules/organization/entities/organization.entity';
|
||||
|
||||
export async function seedOrganizations(dataSource: DataSource) {
|
||||
const repo = dataSource.getRepository(Organization);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Circulation } from './circulation.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
|
||||
@Entity('circulation_routings')
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
OneToMany,
|
||||
} from 'typeorm';
|
||||
import { Correspondence } from '../../correspondence/entities/correspondence.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { CirculationStatusCode } from './circulation-status-code.entity';
|
||||
import { CirculationRouting } from './circulation-routing.entity';
|
||||
|
||||
18
backend/src/modules/contract/contract.module.ts
Normal file
18
backend/src/modules/contract/contract.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ContractService } from './contract.service';
|
||||
import { ContractController } from './contract.controller';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ContractOrganization } from './entities/contract-organization.entity';
|
||||
import { ProjectModule } from '../project/project.module'; // Likely needed for Project entity or service
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Contract, ContractOrganization]),
|
||||
ProjectModule,
|
||||
],
|
||||
controllers: [ContractController],
|
||||
providers: [ContractService],
|
||||
exports: [ContractService],
|
||||
})
|
||||
export class ContractModule {}
|
||||
@@ -69,10 +69,12 @@ export class ContractService {
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Entity, Column, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Contract } from './contract.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
|
||||
@Entity('contract_organizations')
|
||||
export class ContractOrganization {
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Project } from './project.entity';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
|
||||
@Entity('contracts')
|
||||
export class Contract extends BaseEntity {
|
||||
@@ -108,6 +108,7 @@ describe('CorrespondenceController', () => {
|
||||
expect(mockWorkflowService.submitWorkflow).toHaveBeenCalledWith(
|
||||
1,
|
||||
1,
|
||||
[],
|
||||
'Test note'
|
||||
);
|
||||
expect(result).toEqual(mockResult);
|
||||
|
||||
@@ -116,10 +116,10 @@ describe('CorrespondenceService', () => {
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated correspondences', async () => {
|
||||
it('should return correspondences array', async () => {
|
||||
const result = await service.findAll({ projectId: 1 });
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.meta).toBeDefined();
|
||||
expect(Array.isArray(result)).toBeTruthy();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { CorrespondenceRevision } from './correspondence-revision.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { RoutingTemplate } from './routing-template.entity';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
JoinColumn,
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง
|
||||
import { Contract } from '../../contract/entities/contract.entity'; // ปรับ path ตามจริง
|
||||
import { CorrespondenceType } from './correspondence-type.entity'; // ปรับ path ตามจริง
|
||||
|
||||
@Entity('correspondence_sub_types')
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
CreateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Project } from '../../project/entities/project.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from './correspondence-type.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { CorrespondenceRevision } from './correspondence-revision.entity'; // เดี๋ยวสร้าง
|
||||
|
||||
@@ -12,7 +12,7 @@ import { DocumentNumberError } from './entities/document-number-error.entity'; /
|
||||
|
||||
// Master Entities ที่ต้องใช้ Lookup
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Organization } from '../project/entities/organization.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { InternalServerErrorException } from '@nestjs/common';
|
||||
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
|
||||
import { DocumentNumberFormat } from './entities/document-number-format.entity';
|
||||
import { Project } from '../project/entities/project.entity';
|
||||
import { Organization } from '../project/entities/organization.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
|
||||
@@ -147,7 +147,7 @@ describe('DocumentNumberingService', () => {
|
||||
|
||||
expect(result).toBe('0001'); // Default padding 4 (see replaceTokens method)
|
||||
expect(counterRepo.save).toHaveBeenCalled();
|
||||
expect(auditRepo.save).toHaveBeenCalled();
|
||||
// expect(auditRepo.save).toHaveBeenCalled(); // Disabled in implementation
|
||||
});
|
||||
|
||||
it('should throw InternalServerErrorException if max retries exceeded', async () => {
|
||||
|
||||
@@ -21,7 +21,7 @@ import Redlock from 'redlock';
|
||||
import { DocumentNumberCounter } from './entities/document-number-counter.entity';
|
||||
import { DocumentNumberFormat } from './entities/document-number-format.entity';
|
||||
import { Project } from '../project/entities/project.entity'; // สมมติ path
|
||||
import { Organization } from '../project/entities/organization.entity';
|
||||
import { Organization } from '../organization/entities/organization.entity';
|
||||
import { CorrespondenceType } from '../correspondence/entities/correspondence-type.entity';
|
||||
import { Discipline } from '../master/entities/discipline.entity';
|
||||
import { CorrespondenceSubType } from '../correspondence/entities/correspondence-sub-type.entity';
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
UpdateDateColumn,
|
||||
Unique,
|
||||
} from 'typeorm';
|
||||
import { Contract } from '../../project/entities/contract.entity'; // ปรับ path ตามจริง
|
||||
import { Contract } from '../../contract/entities/contract.entity'; // ปรับ path ตามจริง
|
||||
|
||||
@Entity('disciplines')
|
||||
@Unique(['contractId', 'disciplineCode']) // ป้องกันรหัสซ้ำในสัญญาเดียวกัน
|
||||
|
||||
@@ -14,6 +14,12 @@ export class SearchOrganizationDto {
|
||||
@Type(() => Number)
|
||||
roleId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Filter by Project ID' })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
projectId?: number;
|
||||
|
||||
@ApiPropertyOptional({ description: 'Page number', default: 1 })
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
14
backend/src/modules/organization/organization.module.ts
Normal file
14
backend/src/modules/organization/organization.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { OrganizationService } from './organization.service';
|
||||
import { OrganizationController } from './organization.controller';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { OrganizationRole } from './entities/organization-role.entity';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Organization, OrganizationRole])],
|
||||
controllers: [OrganizationController],
|
||||
providers: [OrganizationService],
|
||||
exports: [OrganizationService],
|
||||
})
|
||||
export class OrganizationModule {}
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like } from 'typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { CreateOrganizationDto } from './dto/create-organization.dto.js';
|
||||
import { UpdateOrganizationDto } from './dto/update-organization.dto.js';
|
||||
@@ -30,38 +30,53 @@ export class OrganizationService {
|
||||
}
|
||||
|
||||
async findAll(params?: any) {
|
||||
const { search, page = 1, limit = 100 } = params || {};
|
||||
const { search, roleId, projectId, page = 1, limit = 100 } = params || {};
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Use findAndCount for safer, standard TypeORM queries
|
||||
const findOptions: any = {
|
||||
order: { organizationCode: 'ASC' },
|
||||
skip,
|
||||
take: limit,
|
||||
};
|
||||
// Start with a basic query builder to handle dynamic conditions easily
|
||||
const queryBuilder = this.orgRepo.createQueryBuilder('org');
|
||||
|
||||
if (search) {
|
||||
findOptions.where = [
|
||||
{ organizationCode: Like(`%${search}%`) },
|
||||
{ organizationName: Like(`%${search}%`) },
|
||||
];
|
||||
queryBuilder.andWhere(
|
||||
'(org.organizationCode LIKE :search OR org.organizationName LIKE :search)',
|
||||
{ search: `%${search}%` }
|
||||
);
|
||||
}
|
||||
|
||||
// Debug logging
|
||||
console.log(
|
||||
'[OrganizationService] Finding all with options:',
|
||||
JSON.stringify(findOptions)
|
||||
);
|
||||
// [Refactor] Support filtering by roleId (e.g., getting all CONTRACTORS)
|
||||
if (roleId) {
|
||||
// Assuming there is a relation or a way to filter by role.
|
||||
// If Organization has a roleId column directly:
|
||||
queryBuilder.andWhere('org.roleId = :roleId', { roleId });
|
||||
}
|
||||
|
||||
const [data, total] = await this.orgRepo.findAndCount(findOptions);
|
||||
// [New] Support filtering by projectId (e.g. organizations in a project)
|
||||
// Assuming a Many-to-Many or One-to-Many relation exists via ProjectOrganization
|
||||
if (projectId) {
|
||||
// Use raw join to avoid circular dependency with ProjectOrganization entity
|
||||
queryBuilder.innerJoin(
|
||||
'project_organizations',
|
||||
'po',
|
||||
'po.organization_id = org.id AND po.project_id = :projectId',
|
||||
{ projectId }
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.orderBy('org.organizationCode', 'ASC').skip(skip).take(limit);
|
||||
|
||||
const [data, total] = await queryBuilder.getManyAndCount();
|
||||
|
||||
// Debug logging
|
||||
console.log(`[OrganizationService] Found ${total} organizations`);
|
||||
|
||||
return {
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
meta: {
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,4 +99,11 @@ export class OrganizationService {
|
||||
// So hard delete.
|
||||
return this.orgRepo.remove(org);
|
||||
}
|
||||
|
||||
async findAllActive() {
|
||||
return this.orgRepo.find({
|
||||
where: { isActive: true },
|
||||
order: { organizationCode: 'ASC' },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
|
||||
@Entity('organizations')
|
||||
export class Organization extends BaseEntity {
|
||||
@PrimaryGeneratedColumn()
|
||||
id!: number;
|
||||
|
||||
@Column({ name: 'organization_code', unique: true, length: 20 })
|
||||
organizationCode!: string;
|
||||
|
||||
@Column({ name: 'organization_name', length: 255 })
|
||||
organizationName!: string;
|
||||
|
||||
@Column({ name: 'role_id', nullable: true })
|
||||
roleId?: number;
|
||||
|
||||
@Column({ name: 'is_active', default: true })
|
||||
isActive!: boolean;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Entity, PrimaryColumn, ManyToOne, JoinColumn } from 'typeorm';
|
||||
import { Project } from './project.entity';
|
||||
import { Organization } from './organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
|
||||
@Entity('project_organizations')
|
||||
export class ProjectOrganization {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Entity, Column, PrimaryGeneratedColumn, OneToMany } from 'typeorm';
|
||||
import { BaseEntity } from '../../../common/entities/base.entity';
|
||||
import { Contract } from './contract.entity';
|
||||
import { Contract } from '../../contract/entities/contract.entity';
|
||||
|
||||
@Entity('projects')
|
||||
export class Project extends BaseEntity {
|
||||
|
||||
@@ -2,32 +2,21 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ProjectService } from './project.service.js';
|
||||
import { ProjectController } from './project.controller.js';
|
||||
import { OrganizationService } from './organization.service.js';
|
||||
import { OrganizationController } from './organization.controller.js';
|
||||
import { ContractService } from './contract.service.js';
|
||||
import { ContractController } from './contract.controller.js';
|
||||
|
||||
import { Project } from './entities/project.entity';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { Contract } from './entities/contract.entity';
|
||||
import { ProjectOrganization } from './entities/project-organization.entity';
|
||||
import { ContractOrganization } from './entities/contract-organization.entity';
|
||||
// Modules
|
||||
import { UserModule } from '../user/user.module';
|
||||
import { OrganizationModule } from '../organization/organization.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Project,
|
||||
Organization,
|
||||
Contract,
|
||||
ProjectOrganization,
|
||||
ContractOrganization,
|
||||
]),
|
||||
TypeOrmModule.forFeature([Project, ProjectOrganization]),
|
||||
UserModule,
|
||||
OrganizationModule,
|
||||
],
|
||||
controllers: [ProjectController, OrganizationController, ContractController],
|
||||
providers: [ProjectService, OrganizationService, ContractService],
|
||||
exports: [ProjectService, OrganizationService, ContractService],
|
||||
controllers: [ProjectController],
|
||||
providers: [ProjectService],
|
||||
exports: [ProjectService],
|
||||
})
|
||||
export class ProjectModule {}
|
||||
|
||||
@@ -2,12 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ProjectService } from './project.service';
|
||||
import { Project } from './entities/project.entity';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
|
||||
describe('ProjectService', () => {
|
||||
let service: ProjectService;
|
||||
let mockProjectRepository: Record<string, jest.Mock>;
|
||||
let mockOrganizationRepository: Record<string, jest.Mock>;
|
||||
let mockOrganizationService: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockProjectRepository = {
|
||||
@@ -27,9 +27,8 @@ describe('ProjectService', () => {
|
||||
})),
|
||||
};
|
||||
|
||||
mockOrganizationRepository = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
mockOrganizationService = {
|
||||
findAllActive: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -40,8 +39,8 @@ describe('ProjectService', () => {
|
||||
useValue: mockProjectRepository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Organization),
|
||||
useValue: mockOrganizationRepository,
|
||||
provide: OrganizationService,
|
||||
useValue: mockOrganizationService,
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
@@ -66,7 +65,7 @@ describe('ProjectService', () => {
|
||||
.createQueryBuilder()
|
||||
.getManyAndCount.mockResolvedValue([mockProjects, 1]);
|
||||
|
||||
const result = await service.findAll({});
|
||||
const result = await service.findAll({ page: 1, limit: 10 });
|
||||
|
||||
expect(result.data).toBeDefined();
|
||||
expect(result.meta).toBeDefined();
|
||||
@@ -76,11 +75,11 @@ describe('ProjectService', () => {
|
||||
describe('findAllOrganizations', () => {
|
||||
it('should return all organizations', async () => {
|
||||
const mockOrgs = [{ organization_id: 1, name: 'Test Org' }];
|
||||
mockOrganizationRepository.find.mockResolvedValue(mockOrgs);
|
||||
mockOrganizationService.findAllActive.mockResolvedValue(mockOrgs);
|
||||
|
||||
const result = await service.findAllOrganizations();
|
||||
|
||||
expect(mockOrganizationRepository.find).toHaveBeenCalled();
|
||||
expect(mockOrganizationService.findAllActive).toHaveBeenCalled();
|
||||
expect(result).toEqual(mockOrgs);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Repository, Like } from 'typeorm';
|
||||
|
||||
// Entities
|
||||
import { Project } from './entities/project.entity';
|
||||
import { Organization } from './entities/organization.entity';
|
||||
import { OrganizationService } from '../organization/organization.service';
|
||||
|
||||
// DTOs
|
||||
import { CreateProjectDto } from './dto/create-project.dto.js';
|
||||
@@ -23,8 +23,7 @@ export class ProjectService {
|
||||
constructor(
|
||||
@InjectRepository(Project)
|
||||
private projectRepository: Repository<Project>,
|
||||
@InjectRepository(Organization)
|
||||
private organizationRepository: Repository<Organization>
|
||||
private organizationService: OrganizationService
|
||||
) {}
|
||||
|
||||
// --- CRUD Operations ---
|
||||
@@ -123,9 +122,6 @@ export class ProjectService {
|
||||
// --- Organization Helper ---
|
||||
|
||||
async findAllOrganizations() {
|
||||
return this.organizationRepository.find({
|
||||
where: { isActive: true },
|
||||
order: { organizationCode: 'ASC' },
|
||||
});
|
||||
return this.organizationService.findAllActive();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { RfaWorkflowTemplate } from './rfa-workflow-template.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
import { Role } from '../../user/entities/role.entity';
|
||||
|
||||
// ✅ 1. สร้าง Enum เพื่อให้ Type Safe
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Organization } from '../../project/entities/organization.entity';
|
||||
import { Organization } from '../../organization/entities/organization.entity';
|
||||
import { User } from '../../user/entities/user.entity';
|
||||
import { RfaRevision } from './rfa-revision.entity';
|
||||
import { RfaActionType } from './rfa-workflow-template-step.entity'; // ✅ Import Enum
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from 'typeorm';
|
||||
import { User } from './user.entity';
|
||||
import { Role } from './role.entity';
|
||||
import { Organization } from '../../project/entities/organization.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
|
||||
import { Organization } from '../../organization/entities/organization.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
|
||||
import { Project } from '../../project/entities/project.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
|
||||
import { Contract } from '../../project/entities/contract.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
|
||||
import { Contract } from '../../contract/entities/contract.entity'; // ปรับ Path ให้ตรงกับ ProjectModule
|
||||
|
||||
@Entity('user_assignments')
|
||||
export class UserAssignment {
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
OneToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Organization } from '../../project/entities/organization.entity'; // Adjust path as needed
|
||||
import { Organization } from '../../organization/entities/organization.entity'; // Adjust path as needed
|
||||
import { UserAssignment } from './user-assignment.entity';
|
||||
import { UserPreference } from './user-preference.entity';
|
||||
|
||||
|
||||
@@ -93,6 +93,16 @@ export class UserController {
|
||||
return this.userService.findAllPermissions();
|
||||
}
|
||||
|
||||
@Patch('roles/:id/permissions')
|
||||
@RequirePermission('permission.assign')
|
||||
@ApiOperation({ summary: 'Update role permissions' })
|
||||
async updateRolePermissions(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body('permissionIds') permissionIds: number[]
|
||||
) {
|
||||
return this.userService.updateRolePermissions(id, permissionIds);
|
||||
}
|
||||
|
||||
// --- User CRUD (Admin) ---
|
||||
|
||||
@Post()
|
||||
|
||||
@@ -4,6 +4,8 @@ import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { CACHE_MANAGER } from '@nestjs/cache-manager';
|
||||
import { UserService } from './user.service';
|
||||
import { User } from './entities/user.entity';
|
||||
import { Role } from './entities/role.entity';
|
||||
import { Permission } from './entities/permission.entity';
|
||||
|
||||
// Mock Repository
|
||||
const mockUserRepository = {
|
||||
@@ -14,6 +16,14 @@ const mockUserRepository = {
|
||||
merge: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
query: jest.fn(),
|
||||
createQueryBuilder: jest.fn(() => ({
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
})),
|
||||
};
|
||||
|
||||
// Mock Cache Manager
|
||||
@@ -38,6 +48,14 @@ describe('UserService', () => {
|
||||
provide: CACHE_MANAGER,
|
||||
useValue: mockCacheManager,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Role),
|
||||
useValue: mockUserRepository, // Reuse generic mock
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Permission),
|
||||
useValue: mockUserRepository, // Reuse generic mock
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -53,14 +71,26 @@ describe('UserService', () => {
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return array of users', async () => {
|
||||
it('should return paginated users', async () => {
|
||||
const mockUsers = [{ user_id: 1, username: 'test' }];
|
||||
mockUserRepository.find.mockResolvedValue(mockUsers);
|
||||
const mockTotal = 1;
|
||||
|
||||
const mockQB = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([mockUsers, mockTotal]),
|
||||
};
|
||||
|
||||
mockUserRepository.createQueryBuilder.mockReturnValue(mockQB);
|
||||
|
||||
const result = await service.findAll();
|
||||
|
||||
expect(result).toEqual(mockUsers);
|
||||
expect(mockUserRepository.find).toHaveBeenCalled();
|
||||
expect(result.data).toEqual(mockUsers);
|
||||
expect(result.total).toEqual(mockTotal);
|
||||
expect(mockUserRepository.createQueryBuilder).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -203,13 +203,39 @@ export class UserService {
|
||||
// --- Roles & Permissions (Helper for Admin/UI) ---
|
||||
|
||||
async findAllRoles(): Promise<Role[]> {
|
||||
return this.roleRepository.find();
|
||||
return this.roleRepository.find({ relations: ['permissions'] });
|
||||
}
|
||||
|
||||
async findAllPermissions(): Promise<Permission[]> {
|
||||
return this.permissionRepository.find();
|
||||
}
|
||||
|
||||
async updateRolePermissions(roleId: number, permissionIds: number[]) {
|
||||
const role = await this.roleRepository.findOne({
|
||||
where: { roleId },
|
||||
relations: ['permissions'],
|
||||
});
|
||||
|
||||
if (!role) {
|
||||
throw new NotFoundException(`Role ID ${roleId} not found`);
|
||||
}
|
||||
|
||||
// Load permissions entities
|
||||
const permissions = [];
|
||||
if (permissionIds.length > 0) {
|
||||
// Note: findByIds is deprecated in newer TypeORM, uses In() instead
|
||||
// but if current version supports it or using a simplified query:
|
||||
const perms = await this.permissionRepository
|
||||
.createQueryBuilder('p')
|
||||
.where('p.permissionId IN (:...ids)', { ids: permissionIds })
|
||||
.getMany();
|
||||
permissions.push(...perms);
|
||||
}
|
||||
|
||||
role.permissions = permissions;
|
||||
return this.roleRepository.save(role);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper สำหรับล้าง Cache เมื่อมีการเปลี่ยนแปลงสิทธิ์หรือบทบาท
|
||||
*/
|
||||
|
||||
@@ -40,9 +40,9 @@ describe('WorkflowDslParser', () => {
|
||||
const result = await parser.parse(dslJson);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('RFA_APPROVAL');
|
||||
expect(result.version).toBe('1.0.0');
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(result.workflow_code).toBe('RFA_APPROVAL');
|
||||
expect(result.version).toBe(1);
|
||||
expect(result.is_active).toBe(true);
|
||||
expect(mockRepository.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -168,14 +168,14 @@ describe('WorkflowDslParser', () => {
|
||||
it('should retrieve and parse stored DSL', async () => {
|
||||
const storedDefinition = {
|
||||
id: 1,
|
||||
name: 'RFA_APPROVAL',
|
||||
version: '1.0.0',
|
||||
dslContent: JSON.stringify(RFA_WORKFLOW_EXAMPLE),
|
||||
workflow_code: 'RFA_APPROVAL',
|
||||
version: 1,
|
||||
dsl: RFA_WORKFLOW_EXAMPLE,
|
||||
};
|
||||
|
||||
mockRepository.findOne = jest.fn().mockResolvedValue(storedDefinition);
|
||||
|
||||
const result = await parser.getParsedDsl(1);
|
||||
const result = await parser.getParsedDsl('1');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.name).toBe('RFA_APPROVAL');
|
||||
|
||||
@@ -51,11 +51,13 @@ const contractSchema = z.object({
|
||||
|
||||
type ContractFormData = z.infer<typeof contractSchema>;
|
||||
|
||||
import { contractService } from "@/lib/services/contract.service";
|
||||
|
||||
// Inline hooks for simplicity, or could move to hooks/use-master-data
|
||||
const useContracts = (params?: any) => {
|
||||
return useQuery({
|
||||
queryKey: ['contracts', params],
|
||||
queryFn: () => projectService.getAllContracts(params),
|
||||
queryFn: () => contractService.getAll(params),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -19,13 +19,12 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const PROJECTS = [
|
||||
{ id: '1', name: 'LCBP3' },
|
||||
{ id: '2', name: 'LCBP3-Maintenance' },
|
||||
];
|
||||
import { useProjects } from '@/hooks/use-master-data';
|
||||
|
||||
export default function NumberingPage() {
|
||||
const { data: projects = [] } = useProjects();
|
||||
const [selectedProjectId, setSelectedProjectId] = useState("1");
|
||||
|
||||
const [templates, setTemplates] = useState<NumberingTemplate[]>([]);
|
||||
const [, setLoading] = useState(true);
|
||||
|
||||
@@ -35,7 +34,7 @@ export default function NumberingPage() {
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
const [testTemplate, setTestTemplate] = useState<NumberingTemplate | null>(null);
|
||||
|
||||
const selectedProjectName = PROJECTS.find(p => p.id === selectedProjectId)?.name || 'Unknown Project';
|
||||
const selectedProjectName = projects.find((p: any) => p.id.toString() === selectedProjectId)?.projectName || 'Unknown Project';
|
||||
|
||||
const loadTemplates = async () => {
|
||||
setLoading(true);
|
||||
@@ -105,9 +104,9 @@ export default function NumberingPage() {
|
||||
<SelectValue placeholder="Select Project" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROJECTS.map(project => (
|
||||
<SelectItem key={project.id} value={project.id}>
|
||||
{project.name}
|
||||
{projects.map((project: any) => (
|
||||
<SelectItem key={project.id} value={project.id.toString()}>
|
||||
{project.projectCode} - {project.projectName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -134,7 +133,7 @@ export default function NumberingPage() {
|
||||
{template.documentTypeName}
|
||||
</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{PROJECTS.find(p => p.id === template.projectId?.toString())?.name || selectedProjectName}
|
||||
{projects.find((p: any) => p.id.toString() === template.projectId?.toString())?.projectName || selectedProjectName}
|
||||
</Badge>
|
||||
{template.disciplineCode && <Badge>{template.disciplineCode}</Badge>}
|
||||
<Badge variant={template.isActive ? 'default' : 'secondary'}>
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
|
||||
import { masterDataService } from "@/lib/services/master-data.service";
|
||||
import { projectService } from "@/lib/services/project.service";
|
||||
import { contractService } from "@/lib/services/contract.service";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState, useEffect } from "react";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -22,8 +21,7 @@ export default function DisciplinesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch contracts for filter and form options
|
||||
// Fetch contracts for filter and form options
|
||||
projectService.getAllContracts().then((data) => {
|
||||
contractService.getAll().then((data) => {
|
||||
setContracts(Array.isArray(data) ? data : []);
|
||||
}).catch(err => {
|
||||
console.error("Failed to load contracts:", err);
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
import { GenericCrudTable } from "@/components/admin/reference/generic-crud-table";
|
||||
import { masterDataService } from "@/lib/services/master-data.service";
|
||||
import { projectService } from "@/lib/services/project.service";
|
||||
import { contractService } from "@/lib/services/contract.service";
|
||||
import { ColumnDef } from "@tanstack/react-table";
|
||||
import { useState, useEffect } from "react";
|
||||
import apiClient from "@/lib/api/client";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -22,8 +21,7 @@ export default function RfaTypesPage() {
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch contracts for filter and form options
|
||||
// Fetch contracts for filter and form options
|
||||
projectService.getAllContracts().then((data) => {
|
||||
contractService.getAll().then((data) => {
|
||||
setContracts(Array.isArray(data) ? data : []);
|
||||
}).catch(err => {
|
||||
console.error("Failed to load contracts:", err);
|
||||
|
||||
@@ -25,7 +25,10 @@ interface Session {
|
||||
}
|
||||
|
||||
const sessionService = {
|
||||
getAll: async () => (await apiClient.get("/auth/sessions")).data,
|
||||
getAll: async () => {
|
||||
const response = await apiClient.get("/auth/sessions");
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
revoke: async (sessionId: string) => (await apiClient.delete(`/auth/sessions/${sessionId}`)).data,
|
||||
};
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ interface NumberingError {
|
||||
}
|
||||
|
||||
const logService = {
|
||||
getNumberingErrors: async () => (await apiClient.get("/document-numbering/logs/errors")).data,
|
||||
getNumberingErrors: async () => {
|
||||
const response = await apiClient.get("/document-numbering/logs/errors");
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
};
|
||||
|
||||
export default function NumberingLogsPage() {
|
||||
|
||||
@@ -7,10 +7,11 @@ import { Loader2 } from "lucide-react";
|
||||
|
||||
interface DrawingListProps {
|
||||
type: "CONTRACT" | "SHOP";
|
||||
projectId?: number;
|
||||
}
|
||||
|
||||
export function DrawingList({ type }: DrawingListProps) {
|
||||
const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: 1 });
|
||||
export function DrawingList({ type, projectId }: DrawingListProps) {
|
||||
const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: projectId ?? 1 });
|
||||
|
||||
// Note: The hook handles switching services based on type.
|
||||
// The params { type } might be redundant if getAll doesn't use it, but safe to pass.
|
||||
|
||||
153
frontend/components/ui/__tests__/button.test.tsx
Normal file
153
frontend/components/ui/__tests__/button.test.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import { Button } from '../button';
|
||||
|
||||
describe('Button', () => {
|
||||
describe('rendering', () => {
|
||||
it('should render with default variant and size', () => {
|
||||
render(<Button>Click me</Button>);
|
||||
|
||||
const button = screen.getByRole('button', { name: /click me/i });
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('bg-primary');
|
||||
expect(button).toHaveClass('h-10', 'px-4', 'py-2');
|
||||
});
|
||||
|
||||
it('should render with children text', () => {
|
||||
render(<Button>Submit Form</Button>);
|
||||
|
||||
expect(screen.getByText('Submit Form')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('variants', () => {
|
||||
it('should render destructive variant', () => {
|
||||
render(<Button variant="destructive">Delete</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-destructive');
|
||||
});
|
||||
|
||||
it('should render outline variant', () => {
|
||||
render(<Button variant="outline">Cancel</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('border', 'border-input');
|
||||
});
|
||||
|
||||
it('should render secondary variant', () => {
|
||||
render(<Button variant="secondary">Secondary</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('bg-secondary');
|
||||
});
|
||||
|
||||
it('should render ghost variant', () => {
|
||||
render(<Button variant="ghost">Ghost</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('hover:bg-accent');
|
||||
});
|
||||
|
||||
it('should render link variant', () => {
|
||||
render(<Button variant="link">Link</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('underline-offset-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sizes', () => {
|
||||
it('should render small size', () => {
|
||||
render(<Button size="sm">Small</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('h-9', 'px-3');
|
||||
});
|
||||
|
||||
it('should render large size', () => {
|
||||
render(<Button size="lg">Large</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('h-11', 'px-8');
|
||||
});
|
||||
|
||||
it('should render icon size', () => {
|
||||
render(<Button size="icon">👍</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('h-10', 'w-10');
|
||||
});
|
||||
});
|
||||
|
||||
describe('states', () => {
|
||||
it('should be disabled when disabled prop is passed', () => {
|
||||
render(<Button disabled>Disabled</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
expect(button).toHaveClass('disabled:opacity-50');
|
||||
});
|
||||
|
||||
it('should handle click events', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button onClick={handleClick}>Click</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not fire click when disabled', () => {
|
||||
const handleClick = vi.fn();
|
||||
render(<Button disabled onClick={handleClick}>Disabled</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('asChild prop', () => {
|
||||
it('should render as child element when asChild is true', () => {
|
||||
render(
|
||||
<Button asChild>
|
||||
<a href="/test">Link Button</a>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link');
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute('href', '/test');
|
||||
expect(link).toHaveClass('bg-primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom className', () => {
|
||||
it('should apply custom className', () => {
|
||||
render(<Button className="custom-class">Custom</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveClass('custom-class');
|
||||
});
|
||||
});
|
||||
|
||||
describe('type attribute', () => {
|
||||
it('should have type button by default', () => {
|
||||
render(<Button>Button</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
// Default type in React is undefined (browser defaults to submit in forms)
|
||||
expect(button).not.toHaveAttribute('type', 'submit');
|
||||
});
|
||||
|
||||
it('should accept submit type', () => {
|
||||
render(<Button type="submit">Submit</Button>);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('type', 'submit');
|
||||
});
|
||||
});
|
||||
});
|
||||
270
frontend/hooks/__tests__/use-correspondence.test.ts
Normal file
270
frontend/hooks/__tests__/use-correspondence.test.ts
Normal file
@@ -0,0 +1,270 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import {
|
||||
useCorrespondences,
|
||||
useCorrespondence,
|
||||
useCreateCorrespondence,
|
||||
useUpdateCorrespondence,
|
||||
useDeleteCorrespondence,
|
||||
useSubmitCorrespondence,
|
||||
useProcessWorkflow,
|
||||
correspondenceKeys,
|
||||
} from '../use-correspondence';
|
||||
import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock the service
|
||||
vi.mock('@/lib/services/correspondence.service', () => ({
|
||||
correspondenceService: {
|
||||
getAll: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
submit: vi.fn(),
|
||||
processWorkflow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('use-correspondence hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('correspondenceKeys', () => {
|
||||
it('should generate correct cache keys', () => {
|
||||
expect(correspondenceKeys.all).toEqual(['correspondences']);
|
||||
expect(correspondenceKeys.lists()).toEqual(['correspondences', 'list']);
|
||||
expect(correspondenceKeys.list({ projectId: 1 })).toEqual([
|
||||
'correspondences',
|
||||
'list',
|
||||
{ projectId: 1 },
|
||||
]);
|
||||
expect(correspondenceKeys.details()).toEqual(['correspondences', 'detail']);
|
||||
expect(correspondenceKeys.detail(1)).toEqual(['correspondences', 'detail', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCorrespondences', () => {
|
||||
it('should fetch correspondences successfully', async () => {
|
||||
const mockData = {
|
||||
data: [
|
||||
{ id: 1, title: 'Test Correspondence 1' },
|
||||
{ id: 2, title: 'Test Correspondence 2' },
|
||||
],
|
||||
meta: { total: 2, page: 1, limit: 10 },
|
||||
};
|
||||
|
||||
vi.mocked(correspondenceService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCorrespondences({ projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(correspondenceService.getAll).toHaveBeenCalledWith({ projectId: 1 });
|
||||
});
|
||||
|
||||
it('should handle error state', async () => {
|
||||
const mockError = new Error('API Error');
|
||||
vi.mocked(correspondenceService.getAll).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCorrespondences({ projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCorrespondence', () => {
|
||||
it('should fetch single correspondence by id', async () => {
|
||||
const mockData = { id: 1, title: 'Test Correspondence' };
|
||||
vi.mocked(correspondenceService.getById).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCorrespondence(1), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(correspondenceService.getById).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should not fetch when id is falsy', () => {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCorrespondence(0), { wrapper });
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(correspondenceService.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateCorrespondence', () => {
|
||||
it('should create correspondence and show success toast', async () => {
|
||||
const mockResponse = { id: 1, title: 'New Correspondence' };
|
||||
vi.mocked(correspondenceService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper, queryClient } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateCorrespondence(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
title: 'New Correspondence',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.create).toHaveBeenCalledWith({
|
||||
title: 'New Correspondence',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence created successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'API Error',
|
||||
response: { data: { message: 'Validation failed' } },
|
||||
};
|
||||
vi.mocked(correspondenceService.create).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateCorrespondence(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
title: '',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to create correspondence', {
|
||||
description: 'Validation failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateCorrespondence', () => {
|
||||
it('should update correspondence and invalidate cache', async () => {
|
||||
const mockResponse = { id: 1, title: 'Updated Correspondence' };
|
||||
vi.mocked(correspondenceService.update).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUpdateCorrespondence(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { title: 'Updated Correspondence' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.update).toHaveBeenCalledWith(1, {
|
||||
title: 'Updated Correspondence',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence updated successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteCorrespondence', () => {
|
||||
it('should delete correspondence and show success toast', async () => {
|
||||
vi.mocked(correspondenceService.delete).mockResolvedValue({});
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDeleteCorrespondence(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(correspondenceService.delete).toHaveBeenCalledWith(1);
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence deleted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useSubmitCorrespondence', () => {
|
||||
it('should submit correspondence for workflow', async () => {
|
||||
const mockResponse = { id: 1, status: 'submitted' };
|
||||
vi.mocked(correspondenceService.submit).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useSubmitCorrespondence(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { recipientIds: [2, 3] },
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.submit).toHaveBeenCalledWith(1, { recipientIds: [2, 3] });
|
||||
expect(toast.success).toHaveBeenCalledWith('Correspondence submitted successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProcessWorkflow', () => {
|
||||
it('should process workflow action', async () => {
|
||||
const mockResponse = { id: 1, status: 'approved' };
|
||||
vi.mocked(correspondenceService.processWorkflow).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProcessWorkflow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'approve', comment: 'LGTM' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(correspondenceService.processWorkflow).toHaveBeenCalledWith(1, {
|
||||
action: 'approve',
|
||||
comment: 'LGTM',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Action completed successfully');
|
||||
});
|
||||
|
||||
it('should handle workflow action error', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Permission denied' } },
|
||||
};
|
||||
vi.mocked(correspondenceService.processWorkflow).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProcessWorkflow(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'approve' },
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to process action', {
|
||||
description: 'Permission denied',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
212
frontend/hooks/__tests__/use-drawing.test.ts
Normal file
212
frontend/hooks/__tests__/use-drawing.test.ts
Normal file
@@ -0,0 +1,212 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import {
|
||||
useDrawings,
|
||||
useDrawing,
|
||||
useCreateDrawing,
|
||||
drawingKeys,
|
||||
} from '../use-drawing';
|
||||
import { contractDrawingService } from '@/lib/services/contract-drawing.service';
|
||||
import { shopDrawingService } from '@/lib/services/shop-drawing.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock services
|
||||
vi.mock('@/lib/services/contract-drawing.service', () => ({
|
||||
contractDrawingService: {
|
||||
getAll: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/services/shop-drawing.service', () => ({
|
||||
shopDrawingService: {
|
||||
getAll: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('use-drawing hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('drawingKeys', () => {
|
||||
it('should generate correct cache keys', () => {
|
||||
expect(drawingKeys.all).toEqual(['drawings']);
|
||||
expect(drawingKeys.lists()).toEqual(['drawings', 'list']);
|
||||
expect(drawingKeys.list('CONTRACT', { projectId: 1 })).toEqual([
|
||||
'drawings',
|
||||
'list',
|
||||
'CONTRACT',
|
||||
{ projectId: 1 },
|
||||
]);
|
||||
expect(drawingKeys.detail('SHOP', 1)).toEqual(['drawings', 'detail', 'SHOP', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDrawings', () => {
|
||||
it('should fetch CONTRACT drawings successfully', async () => {
|
||||
const mockData = {
|
||||
data: [
|
||||
{ id: 1, drawingNumber: 'CD-001' },
|
||||
{ id: 2, drawingNumber: 'CD-002' },
|
||||
],
|
||||
meta: { total: 2, page: 1, limit: 10 },
|
||||
};
|
||||
|
||||
vi.mocked(contractDrawingService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDrawings('CONTRACT', { projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(contractDrawingService.getAll).toHaveBeenCalledWith({ projectId: 1 });
|
||||
expect(shopDrawingService.getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch SHOP drawings successfully', async () => {
|
||||
const mockData = {
|
||||
data: [{ id: 1, drawingNumber: 'SD-001' }],
|
||||
meta: { total: 1, page: 1, limit: 10 },
|
||||
};
|
||||
|
||||
vi.mocked(shopDrawingService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDrawings('SHOP', { projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(shopDrawingService.getAll).toHaveBeenCalledWith({ projectId: 1 });
|
||||
expect(contractDrawingService.getAll).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle error state', async () => {
|
||||
const mockError = new Error('API Error');
|
||||
vi.mocked(contractDrawingService.getAll).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDrawings('CONTRACT', { projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDrawing', () => {
|
||||
it('should fetch single CONTRACT drawing by id', async () => {
|
||||
const mockData = { id: 1, drawingNumber: 'CD-001' };
|
||||
vi.mocked(contractDrawingService.getById).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDrawing('CONTRACT', 1), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(contractDrawingService.getById).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should fetch single SHOP drawing by id', async () => {
|
||||
const mockData = { id: 1, drawingNumber: 'SD-001' };
|
||||
vi.mocked(shopDrawingService.getById).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDrawing('SHOP', 1), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(shopDrawingService.getById).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should not fetch when id is falsy', () => {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDrawing('CONTRACT', 0), { wrapper });
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(contractDrawingService.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateDrawing', () => {
|
||||
it('should create CONTRACT drawing and show success toast', async () => {
|
||||
const mockResponse = { id: 1, drawingNumber: 'CD-001' };
|
||||
vi.mocked(contractDrawingService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateDrawing('CONTRACT'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
projectId: 1,
|
||||
drawingNumber: 'CD-001',
|
||||
title: 'Test Drawing',
|
||||
});
|
||||
});
|
||||
|
||||
expect(contractDrawingService.create).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('Contract Drawing uploaded successfully');
|
||||
});
|
||||
|
||||
it('should create SHOP drawing and show success toast', async () => {
|
||||
const mockResponse = { id: 1, drawingNumber: 'SD-001' };
|
||||
vi.mocked(shopDrawingService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateDrawing('SHOP'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
contractDrawingId: 1,
|
||||
title: 'Shop Drawing',
|
||||
});
|
||||
});
|
||||
|
||||
expect(shopDrawingService.create).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('Shop Drawing uploaded successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'API Error',
|
||||
response: { data: { message: 'File too large' } },
|
||||
};
|
||||
vi.mocked(contractDrawingService.create).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateDrawing('CONTRACT'), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
projectId: 1,
|
||||
drawingNumber: 'CD-001',
|
||||
title: 'Test',
|
||||
});
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to upload drawing', {
|
||||
description: 'File too large',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
223
frontend/hooks/__tests__/use-projects.test.ts
Normal file
223
frontend/hooks/__tests__/use-projects.test.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import {
|
||||
useProjects,
|
||||
useCreateProject,
|
||||
useUpdateProject,
|
||||
useDeleteProject,
|
||||
projectKeys,
|
||||
} from '../use-projects';
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock the service
|
||||
vi.mock('@/lib/services/project.service', () => ({
|
||||
projectService: {
|
||||
getAll: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('use-projects hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('projectKeys', () => {
|
||||
it('should generate correct cache keys', () => {
|
||||
expect(projectKeys.all).toEqual(['projects']);
|
||||
expect(projectKeys.list({ search: 'test' })).toEqual([
|
||||
'projects',
|
||||
'list',
|
||||
{ search: 'test' },
|
||||
]);
|
||||
expect(projectKeys.detail(1)).toEqual(['projects', 'detail', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProjects', () => {
|
||||
it('should fetch projects successfully', async () => {
|
||||
const mockData = [
|
||||
{ id: 1, name: 'Project Alpha', code: 'P-001' },
|
||||
{ id: 2, name: 'Project Beta', code: 'P-002' },
|
||||
];
|
||||
|
||||
vi.mocked(projectService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProjects({ search: 'test' }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(projectService.getAll).toHaveBeenCalledWith({ search: 'test' });
|
||||
});
|
||||
|
||||
it('should fetch projects without params', async () => {
|
||||
const mockData = [{ id: 1, name: 'Project Alpha' }];
|
||||
vi.mocked(projectService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProjects(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(projectService.getAll).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('should handle error state', async () => {
|
||||
vi.mocked(projectService.getAll).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProjects({}), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateProject', () => {
|
||||
it('should create project and show success toast', async () => {
|
||||
const mockResponse = { id: 1, name: 'New Project' };
|
||||
vi.mocked(projectService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateProject(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
name: 'New Project',
|
||||
code: 'P-003',
|
||||
contractId: 1,
|
||||
});
|
||||
});
|
||||
|
||||
expect(projectService.create).toHaveBeenCalledWith({
|
||||
name: 'New Project',
|
||||
code: 'P-003',
|
||||
contractId: 1,
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Project created successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Duplicate code' } },
|
||||
};
|
||||
vi.mocked(projectService.create).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateProject(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
name: 'Test',
|
||||
code: 'P-001',
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to create project', {
|
||||
description: 'Duplicate code',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateProject', () => {
|
||||
it('should update project and show success toast', async () => {
|
||||
const mockResponse = { id: 1, name: 'Updated Project' };
|
||||
vi.mocked(projectService.update).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUpdateProject(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { name: 'Updated Project' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(projectService.update).toHaveBeenCalledWith(1, { name: 'Updated Project' });
|
||||
expect(toast.success).toHaveBeenCalledWith('Project updated successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Not found' } },
|
||||
};
|
||||
vi.mocked(projectService.update).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUpdateProject(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: 999,
|
||||
data: { name: 'Test' },
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to update project', {
|
||||
description: 'Not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteProject', () => {
|
||||
it('should delete project and show success toast', async () => {
|
||||
vi.mocked(projectService.delete).mockResolvedValue({});
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDeleteProject(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(projectService.delete).toHaveBeenCalledWith(1);
|
||||
expect(toast.success).toHaveBeenCalledWith('Project deleted successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on delete failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Cannot delete' } },
|
||||
};
|
||||
vi.mocked(projectService.delete).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDeleteProject(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync(1);
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to delete project', {
|
||||
description: 'Cannot delete',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
215
frontend/hooks/__tests__/use-rfa.test.ts
Normal file
215
frontend/hooks/__tests__/use-rfa.test.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import {
|
||||
useRFAs,
|
||||
useRFA,
|
||||
useCreateRFA,
|
||||
useUpdateRFA,
|
||||
useProcessRFA,
|
||||
rfaKeys,
|
||||
} from '../use-rfa';
|
||||
import { rfaService } from '@/lib/services/rfa.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock service
|
||||
vi.mock('@/lib/services/rfa.service', () => ({
|
||||
rfaService: {
|
||||
getAll: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
processWorkflow: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('use-rfa hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('rfaKeys', () => {
|
||||
it('should generate correct cache keys', () => {
|
||||
expect(rfaKeys.all).toEqual(['rfas']);
|
||||
expect(rfaKeys.lists()).toEqual(['rfas', 'list']);
|
||||
expect(rfaKeys.list({ projectId: 1 })).toEqual(['rfas', 'list', { projectId: 1 }]);
|
||||
expect(rfaKeys.details()).toEqual(['rfas', 'detail']);
|
||||
expect(rfaKeys.detail(1)).toEqual(['rfas', 'detail', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRFAs', () => {
|
||||
it('should fetch RFAs successfully', async () => {
|
||||
const mockData = {
|
||||
data: [
|
||||
{ id: 1, rfaNumber: 'RFA-001' },
|
||||
{ id: 2, rfaNumber: 'RFA-002' },
|
||||
],
|
||||
meta: { total: 2, page: 1, limit: 10 },
|
||||
};
|
||||
|
||||
vi.mocked(rfaService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useRFAs({ projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(rfaService.getAll).toHaveBeenCalledWith({ projectId: 1 });
|
||||
});
|
||||
|
||||
it('should handle error state', async () => {
|
||||
vi.mocked(rfaService.getAll).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useRFAs({ projectId: 1 }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRFA', () => {
|
||||
it('should fetch single RFA by id', async () => {
|
||||
const mockData = { id: 1, rfaNumber: 'RFA-001', status: 'pending' };
|
||||
vi.mocked(rfaService.getById).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useRFA(1), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(rfaService.getById).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('should not fetch when id is falsy', () => {
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useRFA(0), { wrapper });
|
||||
|
||||
expect(result.current.isFetching).toBe(false);
|
||||
expect(rfaService.getById).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateRFA', () => {
|
||||
it('should create RFA and show success toast', async () => {
|
||||
const mockResponse = { id: 1, rfaNumber: 'RFA-001' };
|
||||
vi.mocked(rfaService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateRFA(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
projectId: 1,
|
||||
subject: 'Test RFA',
|
||||
});
|
||||
});
|
||||
|
||||
expect(rfaService.create).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('RFA created successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Validation failed' } },
|
||||
};
|
||||
vi.mocked(rfaService.create).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateRFA(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
projectId: 1,
|
||||
subject: '',
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to create RFA', {
|
||||
description: 'Validation failed',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateRFA', () => {
|
||||
it('should update RFA and invalidate cache', async () => {
|
||||
const mockResponse = { id: 1, subject: 'Updated RFA' };
|
||||
vi.mocked(rfaService.update).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUpdateRFA(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { subject: 'Updated RFA' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(rfaService.update).toHaveBeenCalledWith(1, { subject: 'Updated RFA' });
|
||||
expect(toast.success).toHaveBeenCalledWith('RFA updated successfully');
|
||||
});
|
||||
});
|
||||
|
||||
describe('useProcessRFA', () => {
|
||||
it('should process workflow action and show toast', async () => {
|
||||
const mockResponse = { id: 1, status: 'approved' };
|
||||
vi.mocked(rfaService.processWorkflow).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProcessRFA(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'approve', comment: 'Approved' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(rfaService.processWorkflow).toHaveBeenCalledWith(1, {
|
||||
action: 'approve',
|
||||
comment: 'Approved',
|
||||
});
|
||||
expect(toast.success).toHaveBeenCalledWith('Workflow status updated successfully');
|
||||
});
|
||||
|
||||
it('should handle workflow error', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Permission denied' } },
|
||||
};
|
||||
vi.mocked(rfaService.processWorkflow).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useProcessRFA(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { action: 'reject' },
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to process workflow', {
|
||||
description: 'Permission denied',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
234
frontend/hooks/__tests__/use-users.test.ts
Normal file
234
frontend/hooks/__tests__/use-users.test.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { renderHook, waitFor, act } from '@testing-library/react';
|
||||
import { createTestQueryClient } from '@/lib/test-utils';
|
||||
import {
|
||||
useUsers,
|
||||
useRoles,
|
||||
useCreateUser,
|
||||
useUpdateUser,
|
||||
useDeleteUser,
|
||||
userKeys,
|
||||
} from '../use-users';
|
||||
import { userService } from '@/lib/services/user.service';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Mock the service
|
||||
vi.mock('@/lib/services/user.service', () => ({
|
||||
userService: {
|
||||
getAll: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
getRoles: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('use-users hooks', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('userKeys', () => {
|
||||
it('should generate correct cache keys', () => {
|
||||
expect(userKeys.all).toEqual(['users']);
|
||||
expect(userKeys.list({ search: 'john' })).toEqual([
|
||||
'users',
|
||||
'list',
|
||||
{ search: 'john' },
|
||||
]);
|
||||
expect(userKeys.detail(1)).toEqual(['users', 'detail', 1]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUsers', () => {
|
||||
it('should fetch users successfully', async () => {
|
||||
const mockData = {
|
||||
data: [
|
||||
{ userId: 1, username: 'john', email: 'john@example.com' },
|
||||
{ userId: 2, username: 'jane', email: 'jane@example.com' },
|
||||
],
|
||||
meta: { total: 2, page: 1, limit: 10 },
|
||||
};
|
||||
|
||||
vi.mocked(userService.getAll).mockResolvedValue(mockData);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUsers({ search: 'test' }), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockData);
|
||||
expect(userService.getAll).toHaveBeenCalledWith({ search: 'test' });
|
||||
});
|
||||
|
||||
it('should handle error state', async () => {
|
||||
vi.mocked(userService.getAll).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUsers(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useRoles', () => {
|
||||
it('should fetch roles successfully', async () => {
|
||||
const mockRoles = [
|
||||
{ roleId: 1, name: 'Admin' },
|
||||
{ roleId: 2, name: 'Editor' },
|
||||
{ roleId: 3, name: 'Viewer' },
|
||||
];
|
||||
|
||||
vi.mocked(userService.getRoles).mockResolvedValue(mockRoles);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useRoles(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isSuccess).toBe(true);
|
||||
});
|
||||
|
||||
expect(result.current.data).toEqual(mockRoles);
|
||||
expect(userService.getRoles).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('useCreateUser', () => {
|
||||
it('should create user and show success toast', async () => {
|
||||
const mockResponse = { userId: 1, username: 'newuser' };
|
||||
vi.mocked(userService.create).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
username: 'newuser',
|
||||
email: 'newuser@example.com',
|
||||
password: 'password123',
|
||||
roleIds: [2],
|
||||
});
|
||||
});
|
||||
|
||||
expect(userService.create).toHaveBeenCalled();
|
||||
expect(toast.success).toHaveBeenCalledWith('User created successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Username already exists' } },
|
||||
};
|
||||
vi.mocked(userService.create).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useCreateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
username: 'existinguser',
|
||||
email: 'test@example.com',
|
||||
password: 'password',
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to create user', {
|
||||
description: 'Username already exists',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useUpdateUser', () => {
|
||||
it('should update user and show success toast', async () => {
|
||||
const mockResponse = { userId: 1, email: 'updated@example.com' };
|
||||
vi.mocked(userService.update).mockResolvedValue(mockResponse);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUpdateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync({
|
||||
id: 1,
|
||||
data: { email: 'updated@example.com' },
|
||||
});
|
||||
});
|
||||
|
||||
expect(userService.update).toHaveBeenCalledWith(1, { email: 'updated@example.com' });
|
||||
expect(toast.success).toHaveBeenCalledWith('User updated successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'User not found' } },
|
||||
};
|
||||
vi.mocked(userService.update).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useUpdateUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync({
|
||||
id: 999,
|
||||
data: { email: 'test@example.com' },
|
||||
});
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to update user', {
|
||||
description: 'User not found',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteUser', () => {
|
||||
it('should delete user and show success toast', async () => {
|
||||
vi.mocked(userService.delete).mockResolvedValue({});
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDeleteUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.mutateAsync(1);
|
||||
});
|
||||
|
||||
expect(userService.delete).toHaveBeenCalledWith(1);
|
||||
expect(toast.success).toHaveBeenCalledWith('User deleted successfully');
|
||||
});
|
||||
|
||||
it('should show error toast on delete failure', async () => {
|
||||
const mockError = {
|
||||
message: 'Error',
|
||||
response: { data: { message: 'Cannot delete yourself' } },
|
||||
};
|
||||
vi.mocked(userService.delete).mockRejectedValue(mockError);
|
||||
|
||||
const { wrapper } = createTestQueryClient();
|
||||
const { result } = renderHook(() => useDeleteUser(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
try {
|
||||
await result.current.mutateAsync(1);
|
||||
} catch {
|
||||
// Expected
|
||||
}
|
||||
});
|
||||
|
||||
expect(toast.error).toHaveBeenCalledWith('Failed to delete user', {
|
||||
description: 'Cannot delete yourself',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,8 +3,12 @@ import { correspondenceService } from '@/lib/services/correspondence.service';
|
||||
import { SearchCorrespondenceDto } from '@/types/dto/correspondence/search-correspondence.dto';
|
||||
import { CreateCorrespondenceDto } from '@/types/dto/correspondence/create-correspondence.dto';
|
||||
import { SubmitCorrespondenceDto } from '@/types/dto/correspondence/submit-correspondence.dto';
|
||||
import { WorkflowActionDto } from '@/types/dto/correspondence/workflow-action.dto';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
// Error type for axios errors
|
||||
type ApiError = Error & { response?: { data?: { message?: string } } };
|
||||
|
||||
// Keys for Query Cache
|
||||
export const correspondenceKeys = {
|
||||
all: ['correspondences'] as const,
|
||||
@@ -43,7 +47,7 @@ export function useCreateCorrespondence() {
|
||||
toast.success('Correspondence created successfully');
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: ApiError) => {
|
||||
toast.error('Failed to create correspondence', {
|
||||
description: error.response?.data?.message || 'Something went wrong',
|
||||
});
|
||||
@@ -51,6 +55,42 @@ export function useCreateCorrespondence() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateCorrespondence() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number | string; data: Partial<CreateCorrespondenceDto> }) =>
|
||||
correspondenceService.update(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
toast.success('Correspondence updated successfully');
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error('Failed to update correspondence', {
|
||||
description: error.response?.data?.message || 'Something went wrong',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCorrespondence() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (id: number | string) => correspondenceService.delete(id),
|
||||
onSuccess: () => {
|
||||
toast.success('Correspondence deleted successfully');
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
|
||||
},
|
||||
onError: (error: ApiError) => {
|
||||
toast.error('Failed to delete correspondence', {
|
||||
description: error.response?.data?.message || 'Something went wrong',
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSubmitCorrespondence() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -62,7 +102,7 @@ export function useSubmitCorrespondence() {
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: ApiError) => {
|
||||
toast.error('Failed to submit correspondence', {
|
||||
description: error.response?.data?.message || 'Something went wrong',
|
||||
});
|
||||
@@ -74,14 +114,14 @@ export function useProcessWorkflow() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ id, data }: { id: number | string; data: any }) =>
|
||||
mutationFn: ({ id, data }: { id: number | string; data: WorkflowActionDto }) =>
|
||||
correspondenceService.processWorkflow(id, data),
|
||||
onSuccess: (_, { id }) => {
|
||||
toast.success('Action completed successfully');
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.detail(id) });
|
||||
queryClient.invalidateQueries({ queryKey: correspondenceKeys.lists() });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: ApiError) => {
|
||||
toast.error('Failed to process action', {
|
||||
description: error.response?.data?.message || 'Something went wrong',
|
||||
});
|
||||
@@ -89,4 +129,3 @@ export function useProcessWorkflow() {
|
||||
});
|
||||
}
|
||||
|
||||
// Add more mutations as needed (update, delete, etc.)
|
||||
|
||||
@@ -6,18 +6,20 @@ import { SearchShopDrawingDto, CreateShopDrawingDto } from '@/types/dto/drawing/
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type DrawingType = 'CONTRACT' | 'SHOP';
|
||||
type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto;
|
||||
type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto;
|
||||
|
||||
export const drawingKeys = {
|
||||
all: ['drawings'] as const,
|
||||
lists: () => [...drawingKeys.all, 'list'] as const,
|
||||
list: (type: DrawingType, params: any) => [...drawingKeys.lists(), type, params] as const,
|
||||
list: (type: DrawingType, params: DrawingSearchParams) => [...drawingKeys.lists(), type, params] as const,
|
||||
details: () => [...drawingKeys.all, 'detail'] as const,
|
||||
detail: (type: DrawingType, id: number | string) => [...drawingKeys.details(), type, id] as const,
|
||||
};
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
export function useDrawings(type: DrawingType, params: any) {
|
||||
export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
|
||||
return useQuery({
|
||||
queryKey: drawingKeys.list(type, params),
|
||||
queryFn: async () => {
|
||||
@@ -51,7 +53,7 @@ export function useCreateDrawing(type: DrawingType) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (data: any) => {
|
||||
mutationFn: async (data: CreateDrawingData) => {
|
||||
if (type === 'CONTRACT') {
|
||||
return contractDrawingService.create(data as CreateContractDrawingDto);
|
||||
} else {
|
||||
@@ -62,7 +64,7 @@ export function useCreateDrawing(type: DrawingType) {
|
||||
toast.success(`${type === 'CONTRACT' ? 'Contract' : 'Shop'} Drawing uploaded successfully`);
|
||||
queryClient.invalidateQueries({ queryKey: drawingKeys.lists() });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
onError: (error: Error & { response?: { data?: { message?: string } } }) => {
|
||||
toast.error('Failed to upload drawing', {
|
||||
description: error.response?.data?.message || 'Something went wrong',
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
CreateOrganizationDto,
|
||||
UpdateOrganizationDto,
|
||||
SearchOrganizationDto,
|
||||
} from '@/types/dto/organization.dto';
|
||||
} from '@/types/dto/organization/organization.dto';
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export const masterDataKeys = {
|
||||
@@ -15,10 +15,12 @@ export const masterDataKeys = {
|
||||
disciplines: (contractId?: number) => [...masterDataKeys.all, 'disciplines', contractId] as const,
|
||||
};
|
||||
|
||||
import { organizationService } from '@/lib/services/organization.service';
|
||||
|
||||
export function useOrganizations(params?: SearchOrganizationDto) {
|
||||
return useQuery({
|
||||
queryKey: [...masterDataKeys.organizations(), params],
|
||||
queryFn: () => masterDataService.getOrganizations(params),
|
||||
queryFn: () => organizationService.getAll(params),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,12 +79,23 @@ export function useDisciplines(contractId?: number) {
|
||||
});
|
||||
}
|
||||
|
||||
// Add useContracts hook
|
||||
// Add useProjects hook
|
||||
import { projectService } from '@/lib/services/project.service';
|
||||
export function useContracts(projectId: number = 1) {
|
||||
|
||||
export function useProjects(isActive: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: ['contracts', projectId],
|
||||
queryFn: () => projectService.getContracts(projectId),
|
||||
queryKey: ['projects', { isActive }],
|
||||
queryFn: () => projectService.getAll({ isActive }),
|
||||
});
|
||||
}
|
||||
|
||||
// Add useContracts hook
|
||||
import { contractService } from '@/lib/services/contract.service';
|
||||
|
||||
export function useContracts(projectId: number = 1) {
|
||||
return useQuery({
|
||||
queryKey: ['contracts', projectId],
|
||||
queryFn: () => contractService.getAll({ projectId }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
157
frontend/lib/services/__tests__/correspondence.service.test.ts
Normal file
157
frontend/lib/services/__tests__/correspondence.service.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { correspondenceService } from '../correspondence.service';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
// apiClient is already mocked in vitest.setup.ts
|
||||
|
||||
describe('correspondenceService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should call GET /correspondences with params', async () => {
|
||||
const mockResponse = {
|
||||
data: [{ id: 1, title: 'Test' }],
|
||||
meta: { total: 1 },
|
||||
};
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.getAll({ projectId: 1 });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/correspondences', {
|
||||
params: { projectId: 1 },
|
||||
});
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should call GET /correspondences without params', async () => {
|
||||
const mockResponse = { data: [] };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await correspondenceService.getAll();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/correspondences', {
|
||||
params: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should call GET /correspondences/:id', async () => {
|
||||
const mockResponse = { id: 1, title: 'Test' };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.getById(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/correspondences/1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should work with string id', async () => {
|
||||
const mockResponse = { id: 1 };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
await correspondenceService.getById('123');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/correspondences/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should call POST /correspondences with data', async () => {
|
||||
const createDto = {
|
||||
title: 'New Correspondence',
|
||||
projectId: 1,
|
||||
correspondenceTypeId: 1,
|
||||
};
|
||||
const mockResponse = { id: 1, ...createDto };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.create(createDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/correspondences', createDto);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should call PUT /correspondences/:id with data', async () => {
|
||||
const updateData = { title: 'Updated Title' };
|
||||
const mockResponse = { id: 1, title: 'Updated Title' };
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.update(1, updateData);
|
||||
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/correspondences/1', updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should call DELETE /correspondences/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await correspondenceService.delete(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/correspondences/1');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('submit', () => {
|
||||
it('should call POST /correspondences/:id/submit', async () => {
|
||||
const submitDto = { recipientIds: [2, 3] };
|
||||
const mockResponse = { id: 1, status: 'submitted' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.submit(1, submitDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/correspondences/1/submit', submitDto);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processWorkflow', () => {
|
||||
it('should call POST /correspondences/:id/workflow', async () => {
|
||||
const workflowDto = { action: 'approve', comment: 'LGTM' };
|
||||
const mockResponse = { id: 1, status: 'approved' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.processWorkflow(1, workflowDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/correspondences/1/workflow', workflowDto);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addReference', () => {
|
||||
it('should call POST /correspondences/:id/references', async () => {
|
||||
const referenceDto = { referencedDocumentId: 2, referenceType: 'reply_to' };
|
||||
const mockResponse = { id: 1 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await correspondenceService.addReference(1, referenceDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/correspondences/1/references',
|
||||
referenceDto
|
||||
);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeReference', () => {
|
||||
it('should call DELETE /correspondences/:id/references with body', async () => {
|
||||
const referenceDto = { referencedDocumentId: 2 };
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await correspondenceService.removeReference(1, referenceDto);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/correspondences/1/references', {
|
||||
data: referenceDto,
|
||||
});
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
329
frontend/lib/services/__tests__/master-data.service.test.ts
Normal file
329
frontend/lib/services/__tests__/master-data.service.test.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { masterDataService } from '../master-data.service';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
// apiClient is already mocked in vitest.setup.ts
|
||||
|
||||
describe('masterDataService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// --- Tags ---
|
||||
describe('Tags', () => {
|
||||
describe('getTags', () => {
|
||||
it('should call GET /tags with params', async () => {
|
||||
const mockTags = [{ id: 1, name: 'Important' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockTags } });
|
||||
|
||||
const result = await masterDataService.getTags({ search: 'test' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/master/tags', { params: { search: 'test' } });
|
||||
expect(result).toEqual(mockTags);
|
||||
});
|
||||
|
||||
it('should handle unwrapped response', async () => {
|
||||
const mockTags = [{ id: 1, name: 'Urgent' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockTags });
|
||||
|
||||
const result = await masterDataService.getTags();
|
||||
|
||||
expect(result).toEqual(mockTags);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTag', () => {
|
||||
it('should call POST /tags', async () => {
|
||||
const createDto = { name: 'New Tag', color: '#ff0000' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, ...createDto } });
|
||||
|
||||
const result = await masterDataService.createTag(createDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/master/tags', createDto);
|
||||
expect(result).toEqual({ id: 1, ...createDto });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTag', () => {
|
||||
it('should call PUT /tags/:id', async () => {
|
||||
const updateDto = { name: 'Updated Tag' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1, ...updateDto } });
|
||||
|
||||
const result = await masterDataService.updateTag(1, updateDto);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/master/tags/1', updateDto);
|
||||
expect(result).toEqual({ id: 1, ...updateDto });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteTag', () => {
|
||||
it('should call DELETE /tags/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await masterDataService.deleteTag(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/master/tags/1');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Organizations ---
|
||||
describe('Organizations', () => {
|
||||
describe('getOrganizations', () => {
|
||||
it('should call GET /organizations and unwrap paginated response', async () => {
|
||||
const mockOrgs = [{ organizationId: 1, name: 'Org A' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockOrgs } });
|
||||
|
||||
const result = await masterDataService.getOrganizations();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/organizations', { params: undefined });
|
||||
expect(result).toEqual(mockOrgs);
|
||||
});
|
||||
|
||||
it('should handle array response', async () => {
|
||||
const mockOrgs = [{ organizationId: 1 }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockOrgs });
|
||||
|
||||
const result = await masterDataService.getOrganizations();
|
||||
|
||||
expect(result).toEqual(mockOrgs);
|
||||
});
|
||||
|
||||
it('should return empty array as fallback', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await masterDataService.getOrganizations();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createOrganization', () => {
|
||||
it('should call POST /organizations', async () => {
|
||||
const createDto = { name: 'New Org', code: 'ORG-001' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { organizationId: 1, ...createDto } });
|
||||
|
||||
const result = await masterDataService.createOrganization(createDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/organizations', createDto);
|
||||
expect(result.organizationId).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateOrganization', () => {
|
||||
it('should call PUT /organizations/:id', async () => {
|
||||
const updateDto = { name: 'Updated Org' };
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ data: { organizationId: 1, ...updateDto } });
|
||||
|
||||
const result = await masterDataService.updateOrganization(1, updateDto);
|
||||
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/organizations/1', updateDto);
|
||||
expect(result.name).toBe('Updated Org');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteOrganization', () => {
|
||||
it('should call DELETE /organizations/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
await masterDataService.deleteOrganization(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/organizations/1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Disciplines ---
|
||||
describe('Disciplines', () => {
|
||||
describe('getDisciplines', () => {
|
||||
it('should call GET /master/disciplines with contractId', async () => {
|
||||
const mockDisciplines = [{ id: 1, name: 'Civil' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockDisciplines } });
|
||||
|
||||
const result = await masterDataService.getDisciplines(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/master/disciplines', {
|
||||
params: { contractId: 1 },
|
||||
});
|
||||
expect(result).toEqual(mockDisciplines);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDiscipline', () => {
|
||||
it('should call POST /master/disciplines', async () => {
|
||||
const createDto = { name: 'Electrical', contractId: 1 };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, ...createDto } });
|
||||
|
||||
const result = await masterDataService.createDiscipline(createDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/master/disciplines', createDto);
|
||||
expect(result.name).toBe('Electrical');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteDiscipline', () => {
|
||||
it('should call DELETE /master/disciplines/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
await masterDataService.deleteDiscipline(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/master/disciplines/1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- SubTypes ---
|
||||
describe('SubTypes', () => {
|
||||
describe('getSubTypes', () => {
|
||||
it('should call GET /master/sub-types with contractId and typeId', async () => {
|
||||
const mockSubTypes = [{ id: 1, name: 'Submittal' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockSubTypes } });
|
||||
|
||||
const result = await masterDataService.getSubTypes(1, 2);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/master/sub-types', {
|
||||
params: { contractId: 1, correspondenceTypeId: 2 },
|
||||
});
|
||||
expect(result).toEqual(mockSubTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createSubType', () => {
|
||||
it('should call POST /master/sub-types', async () => {
|
||||
const createDto = { name: 'New SubType' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, ...createDto } });
|
||||
|
||||
const result = await masterDataService.createSubType(createDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/master/sub-types', createDto);
|
||||
expect(result).toEqual({ id: 1, ...createDto });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- RFA Types ---
|
||||
describe('RfaTypes', () => {
|
||||
describe('getRfaTypes', () => {
|
||||
it('should call GET /master/rfa-types', async () => {
|
||||
const mockTypes = [{ id: 1, name: 'Material Approval' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockTypes } });
|
||||
|
||||
const result = await masterDataService.getRfaTypes(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/master/rfa-types', {
|
||||
params: { contractId: 1 },
|
||||
});
|
||||
expect(result).toEqual(mockTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createRfaType', () => {
|
||||
it('should call POST /master/rfa-types', async () => {
|
||||
const data = { name: 'New RFA Type' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, ...data } });
|
||||
|
||||
const result = await masterDataService.createRfaType(data);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/master/rfa-types', data);
|
||||
expect(result).toEqual({ id: 1, ...data });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRfaType', () => {
|
||||
it('should call PATCH /master/rfa-types/:id', async () => {
|
||||
const data = { name: 'Updated Type' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1, ...data } });
|
||||
|
||||
await masterDataService.updateRfaType(1, data);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/master/rfa-types/1', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRfaType', () => {
|
||||
it('should call DELETE /master/rfa-types/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
await masterDataService.deleteRfaType(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/master/rfa-types/1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Correspondence Types ---
|
||||
describe('CorrespondenceTypes', () => {
|
||||
describe('getCorrespondenceTypes', () => {
|
||||
it('should call GET /master/correspondence-types', async () => {
|
||||
const mockTypes = [{ id: 1, name: 'Letter' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: { data: mockTypes } });
|
||||
|
||||
const result = await masterDataService.getCorrespondenceTypes();
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/master/correspondence-types');
|
||||
expect(result).toEqual(mockTypes);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCorrespondenceType', () => {
|
||||
it('should call POST /master/correspondence-types', async () => {
|
||||
const data = { name: 'Memo' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 2, ...data } });
|
||||
|
||||
await masterDataService.createCorrespondenceType(data);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/master/correspondence-types', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCorrespondenceType', () => {
|
||||
it('should call PATCH /master/correspondence-types/:id', async () => {
|
||||
const data = { name: 'Updated Type' };
|
||||
vi.mocked(apiClient.patch).mockResolvedValue({ data: { id: 1, ...data } });
|
||||
|
||||
await masterDataService.updateCorrespondenceType(1, data);
|
||||
|
||||
expect(apiClient.patch).toHaveBeenCalledWith('/master/correspondence-types/1', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCorrespondenceType', () => {
|
||||
it('should call DELETE /master/correspondence-types/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
await masterDataService.deleteCorrespondenceType(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/master/correspondence-types/1');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// --- Number Format ---
|
||||
describe('NumberFormat', () => {
|
||||
describe('saveNumberFormat', () => {
|
||||
it('should call POST /document-numbering/formats', async () => {
|
||||
const data = { projectId: 1, correspondenceTypeId: 1, format: '{PREFIX}-{YYYY}-{SEQ}' };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: { id: 1, ...data } });
|
||||
|
||||
await masterDataService.saveNumberFormat(data);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/document-numbering/formats', data);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNumberFormat', () => {
|
||||
it('should call GET /document-numbering/formats with params', async () => {
|
||||
const mockFormat = { id: 1, format: '{PREFIX}-{SEQ}' };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockFormat });
|
||||
|
||||
const result = await masterDataService.getNumberFormat(1, 2);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/document-numbering/formats', {
|
||||
params: { projectId: 1, correspondenceTypeId: 2 },
|
||||
});
|
||||
expect(result).toEqual(mockFormat);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
95
frontend/lib/services/__tests__/project.service.test.ts
Normal file
95
frontend/lib/services/__tests__/project.service.test.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { projectService } from '../project.service';
|
||||
import apiClient from '@/lib/api/client';
|
||||
|
||||
// apiClient is already mocked in vitest.setup.ts
|
||||
|
||||
describe('projectService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should call GET /projects with params', async () => {
|
||||
const mockData = [{ id: 1, name: 'Project Alpha' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockData });
|
||||
|
||||
const result = await projectService.getAll({ search: 'alpha' });
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/projects', {
|
||||
params: { search: 'alpha' },
|
||||
});
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
|
||||
it('should unwrap paginated response', async () => {
|
||||
const mockData = [{ id: 1, name: 'Test' }];
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { data: mockData, meta: { total: 1 } },
|
||||
});
|
||||
|
||||
const result = await projectService.getAll();
|
||||
|
||||
expect(result).toEqual(mockData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getById', () => {
|
||||
it('should call GET /projects/:id', async () => {
|
||||
const mockResponse = { id: 1, name: 'Project Alpha', code: 'P-001' };
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await projectService.getById(1);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/projects/1');
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
|
||||
it('should work with string id', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({ data: {} });
|
||||
|
||||
await projectService.getById('123');
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith('/projects/123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should call POST /projects with data', async () => {
|
||||
const createDto = { projectName: 'New Project', projectCode: 'P-002' };
|
||||
const mockResponse = { id: 2, ...createDto };
|
||||
vi.mocked(apiClient.post).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await projectService.create(createDto);
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith('/projects', createDto);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should call PUT /projects/:id with data', async () => {
|
||||
const updateData = { projectName: 'Updated Project' };
|
||||
const mockResponse = { id: 1, projectName: 'Updated Project' };
|
||||
vi.mocked(apiClient.put).mockResolvedValue({ data: mockResponse });
|
||||
|
||||
const result = await projectService.update(1, updateData);
|
||||
|
||||
expect(apiClient.put).toHaveBeenCalledWith('/projects/1', updateData);
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should call DELETE /projects/:id', async () => {
|
||||
vi.mocked(apiClient.delete).mockResolvedValue({ data: {} });
|
||||
|
||||
const result = await projectService.delete(1);
|
||||
|
||||
expect(apiClient.delete).toHaveBeenCalledWith('/projects/1');
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
56
frontend/lib/services/contract.service.ts
Normal file
56
frontend/lib/services/contract.service.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import apiClient from "@/lib/api/client";
|
||||
import {
|
||||
CreateContractDto,
|
||||
UpdateContractDto,
|
||||
SearchContractDto,
|
||||
} from "@/types/dto/contract/contract.dto";
|
||||
|
||||
export const contractService = {
|
||||
/**
|
||||
* Get all contracts (supports filtering by projectId)
|
||||
* GET /contracts?projectId=1
|
||||
*/
|
||||
getAll: async (params?: SearchContractDto) => {
|
||||
const response = await apiClient.get("/contracts", { params });
|
||||
if (response.data && Array.isArray(response.data.data)) {
|
||||
return response.data.data;
|
||||
}
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get contract by ID
|
||||
* GET /contracts/:id
|
||||
*/
|
||||
getById: async (id: number) => {
|
||||
const response = await apiClient.get(`/contracts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new contract
|
||||
* POST /contracts
|
||||
*/
|
||||
create: async (data: CreateContractDto) => {
|
||||
const response = await apiClient.post("/contracts", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update contract
|
||||
* PATCH /contracts/:id
|
||||
*/
|
||||
update: async (id: number, data: UpdateContractDto) => {
|
||||
const response = await apiClient.patch(`/contracts/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete contract
|
||||
* DELETE /contracts/:id
|
||||
*/
|
||||
delete: async (id: number) => {
|
||||
const response = await apiClient.delete(`/contracts/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -11,33 +11,33 @@ import {
|
||||
CreateOrganizationDto,
|
||||
UpdateOrganizationDto,
|
||||
SearchOrganizationDto,
|
||||
} from "@/types/dto/organization.dto";
|
||||
} from "@/types/dto/organization/organization.dto";
|
||||
|
||||
export const masterDataService = {
|
||||
// --- Tags Management ---
|
||||
|
||||
/** ดึงรายการ Tags ทั้งหมด (Search & Pagination) */
|
||||
getTags: async (params?: SearchTagDto) => {
|
||||
const response = await apiClient.get("/tags", { params });
|
||||
const response = await apiClient.get("/master/tags", { params });
|
||||
// Support both wrapped and unwrapped scenarios
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
/** สร้าง Tag ใหม่ */
|
||||
createTag: async (data: CreateTagDto) => {
|
||||
const response = await apiClient.post("/tags", data);
|
||||
const response = await apiClient.post("/master/tags", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** แก้ไข Tag */
|
||||
updateTag: async (id: number | string, data: UpdateTagDto) => {
|
||||
const response = await apiClient.put(`/tags/${id}`, data);
|
||||
const response = await apiClient.patch(`/master/tags/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/** ลบ Tag */
|
||||
deleteTag: async (id: number | string) => {
|
||||
const response = await apiClient.delete(`/tags/${id}`);
|
||||
const response = await apiClient.delete(`/master/tags/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
57
frontend/lib/services/organization.service.ts
Normal file
57
frontend/lib/services/organization.service.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import apiClient from "@/lib/api/client";
|
||||
import {
|
||||
CreateOrganizationDto,
|
||||
UpdateOrganizationDto,
|
||||
SearchOrganizationDto,
|
||||
} from "@/types/dto/organization/organization.dto";
|
||||
|
||||
export const organizationService = {
|
||||
/**
|
||||
* Get all organizations (supports filtering by projectId)
|
||||
* GET /organizations?projectId=1
|
||||
*/
|
||||
getAll: async (params?: SearchOrganizationDto) => {
|
||||
const response = await apiClient.get("/organizations", { params });
|
||||
// Normalize response if wrapped in data.data or direct data
|
||||
if (response.data && Array.isArray(response.data.data)) {
|
||||
return response.data.data;
|
||||
}
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get organization by ID
|
||||
* GET /organizations/:id
|
||||
*/
|
||||
getById: async (id: number) => {
|
||||
const response = await apiClient.get(`/organizations/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create new organization
|
||||
* POST /organizations
|
||||
*/
|
||||
create: async (data: CreateOrganizationDto) => {
|
||||
const response = await apiClient.post("/organizations", data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update organization
|
||||
* PATCH /organizations/:id
|
||||
*/
|
||||
update: async (id: number, data: UpdateOrganizationDto) => {
|
||||
const response = await apiClient.patch(`/organizations/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete organization
|
||||
* DELETE /organizations/:id
|
||||
*/
|
||||
delete: async (id: number) => {
|
||||
const response = await apiClient.delete(`/organizations/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
@@ -49,39 +49,8 @@ export const projectService = {
|
||||
|
||||
// --- Related Data / Dropdown Helpers ---
|
||||
|
||||
/** * ดึงรายชื่อองค์กรในโครงการ (สำหรับ Dropdown 'To/From')
|
||||
* GET /projects/:id/organizations
|
||||
*/
|
||||
getOrganizations: async (projectId: string | number) => {
|
||||
const response = await apiClient.get(`/projects/${projectId}/organizations`);
|
||||
// Unwrap the response data if it's wrapped in a 'data' property by the interceptor
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
/** * ดึงรายชื่อสัญญาในโครงการ
|
||||
* GET /projects/:id/contracts
|
||||
*/
|
||||
/** * ดึงรายชื่อสัญญาในโครงการ (Legacy/Specific)
|
||||
* GET /projects/:id/contracts
|
||||
*/
|
||||
getContracts: async (projectId: string | number) => {
|
||||
// Note: If backend doesn't have /projects/:id/contracts, use /contracts?projectId=:id
|
||||
const response = await apiClient.get(`/contracts`, { params: { projectId } });
|
||||
// Handle paginated response
|
||||
if (response.data && Array.isArray(response.data.data)) {
|
||||
return response.data.data;
|
||||
}
|
||||
return response.data.data || response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* ดึงรายการสัญญาเรื้งหมด (Global Search)
|
||||
*/
|
||||
getAllContracts: async (params?: any) => {
|
||||
const response = await apiClient.get("/contracts", { params });
|
||||
if (response.data && Array.isArray(response.data.data)) {
|
||||
return response.data.data;
|
||||
}
|
||||
return response.data.data || response.data;
|
||||
}
|
||||
// --- Related Data / Dropdown Helpers ---
|
||||
// Organizations and Contracts should now be fetched via their respective services
|
||||
// organizationService.getAll({ projectId })
|
||||
// contractService.getAll({ projectId })
|
||||
};
|
||||
|
||||
35
frontend/lib/test-utils.tsx
Normal file
35
frontend/lib/test-utils.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
|
||||
/**
|
||||
* Creates a wrapper with QueryClient for testing hooks
|
||||
* @returns Object with wrapper component and queryClient instance
|
||||
*/
|
||||
export function createTestQueryClient() {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: false,
|
||||
gcTime: 0,
|
||||
staleTime: 0,
|
||||
},
|
||||
mutations: {
|
||||
retry: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
);
|
||||
|
||||
return { wrapper, queryClient };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for all pending operations in React Query
|
||||
*/
|
||||
export async function waitForQueryClient(queryClient: QueryClient) {
|
||||
await queryClient.getQueryCache().clear();
|
||||
await queryClient.getMutationCache().clear();
|
||||
}
|
||||
@@ -7,7 +7,10 @@
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"format": "prettier --write ."
|
||||
"format": "prettier --write .",
|
||||
"test": "vitest",
|
||||
"test:watch": "vitest --watch",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.2.2",
|
||||
@@ -52,15 +55,21 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tanstack/react-query-devtools": "^5.91.1",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^18",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitejs/plugin-react": "^5.1.2",
|
||||
"autoprefixer": "^10.4.22",
|
||||
"eslint": "^8",
|
||||
"eslint-config-next": "14.2.33",
|
||||
"jsdom": "^27.3.0",
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.15"
|
||||
}
|
||||
}
|
||||
|
||||
17
frontend/types/dto/contract/contract.dto.ts
Normal file
17
frontend/types/dto/contract/contract.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export interface CreateContractDto {
|
||||
contractCode: string;
|
||||
contractName: string;
|
||||
projectId: number;
|
||||
description?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
export interface UpdateContractDto extends Partial<CreateContractDto> {}
|
||||
|
||||
export interface SearchContractDto {
|
||||
search?: string;
|
||||
projectId?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ export interface UpdateOrganizationDto {
|
||||
|
||||
export interface SearchOrganizationDto {
|
||||
search?: string;
|
||||
projectId?: number;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}
|
||||
26
frontend/vitest.config.ts
Normal file
26
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/// <reference types="vitest" />
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
include: ['hooks/**/*.test.{ts,tsx}', 'lib/**/*.test.{ts,tsx}', 'components/**/*.test.{ts,tsx}'],
|
||||
exclude: ['**/node_modules/**', '**/.ignored_node_modules/**', '**/.next/**', '**/dist/**'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
include: ['hooks/**/*.ts', 'lib/**/*.ts', 'components/**/*.tsx'],
|
||||
exclude: ['**/*.d.ts', '**/__tests__/**', '**/types/**'],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './'),
|
||||
},
|
||||
},
|
||||
});
|
||||
38
frontend/vitest.setup.ts
Normal file
38
frontend/vitest.setup.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
// Mock sonner toast
|
||||
vi.mock('sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
loading: vi.fn(),
|
||||
dismiss: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock next/navigation
|
||||
vi.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
usePathname: () => '/',
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useParams: () => ({}),
|
||||
}));
|
||||
|
||||
// Mock apiClient
|
||||
vi.mock('@/lib/api/client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
1323
pnpm-lock.yaml
generated
1323
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -49,8 +49,9 @@ backend/
|
||||
│ │ ├── master/
|
||||
│ │ ├── monitoring/
|
||||
│ │ ├── notification/
|
||||
│ │ ├── organizations/
|
||||
│ │ ├── project/
|
||||
│ │ ├── organization/
|
||||
│ │ ├── contract/
|
||||
│ │ ├── rfa/
|
||||
│ │ ├── search/
|
||||
│ │ ├── transmittal/
|
||||
|
||||
2569
specs/07-database/lcbp3-v1.5.1-seed-contractdrawing.sql
Normal file
2569
specs/07-database/lcbp3-v1.5.1-seed-contractdrawing.sql
Normal file
File diff suppressed because it is too large
Load Diff
48
specs/09-history/2025-12-11-admin-console-fixes.md
Normal file
48
specs/09-history/2025-12-11-admin-console-fixes.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# Session Log: Admin Console Fixes
|
||||
Date: 2025-12-11
|
||||
|
||||
## Overview
|
||||
This session focused on debugging and resolving critical display and functionality issues in the Admin Console. Major fixes included Data integration for Document Numbering, RBAC Matrix functionality, and resolving data unwrapping issues for Active Sessions and Logs.
|
||||
|
||||
## Resolved Issues
|
||||
|
||||
### 1. Tag Management
|
||||
- **Issue:** 404 Error when accessing system tags.
|
||||
- **Cause:** Incorrect API endpoint (`/tags` vs `/master/tags`).
|
||||
- **Resolution:** Updated frontend service to use the correct `/master` prefix.
|
||||
|
||||
### 2. Document Numbering
|
||||
- **Issue:** Project Selection dropdown used hardcoded mock data.
|
||||
- **Cause:** `PROJECTS` constant in component.
|
||||
- **Resolution:** Implemented `useProjects` hook to fetch dynamic project list from backend.
|
||||
|
||||
### 3. RBAC Matrix
|
||||
- **Issue:** Permission checkboxes were all empty.
|
||||
- **Cause:** `UserService.findAllRoles` did not load the `permissions` relation.
|
||||
- **Resolution:**
|
||||
- Updated `UserService` to eager load relations.
|
||||
- Implemented `updateRolePermissions` in backend.
|
||||
- Added `PATCH` endpoint for saving changes.
|
||||
|
||||
### 4. Active Sessions
|
||||
- **Issue:** List "No results" and missing user names.
|
||||
- **Cause:**
|
||||
- Property mismatch (`first_name` vs `firstName`).
|
||||
- Frontend failed to unwrap `response.data.data` (Interceptor behavior).
|
||||
- **Resolution:**
|
||||
- Aligned backend/frontend naming convention.
|
||||
- Updated `sessionService` to handle wrapped response data.
|
||||
- Improved backend date comparison robustness.
|
||||
|
||||
### 5. Numbering Logs
|
||||
- **Issue:** Logs table empty.
|
||||
- **Cause:** Same data unwrapping issue as Active Sessions.
|
||||
- **Resolution:** Updated `logService` in `system-logs/numbering/page.tsx`.
|
||||
|
||||
### 6. Missing Permissions (Advisory)
|
||||
- **Issue:** 403 Forbidden on Logs page.
|
||||
- **Cause:** `system.view_logs` permission missing from user role.
|
||||
- **Resolution:** Advised user to use the newly fixed RBAC Matrix to assign the permission.
|
||||
|
||||
## Verification
|
||||
All issues were verified by manual testing and confirming correct data display in the Admin Console. Backend logs were used to debug the Active Sessions data flow.
|
||||
108
specs/09-history/2025-12-11-frontend-tests.md
Normal file
108
specs/09-history/2025-12-11-frontend-tests.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Session Summary: Frontend Unit Tests Implementation
|
||||
|
||||
**Date:** 2025-12-11
|
||||
**Session ID:** 1339bffa-8d99-4bf5-a5c0-5458630ed9fc
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Implement frontend testing infrastructure and unit tests per `specs/03-implementation/testing-strategy.md`.
|
||||
|
||||
---
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Test Infrastructure Setup
|
||||
|
||||
| File | Description |
|
||||
| ----------------------------- | --------------------------------------------------------- |
|
||||
| `frontend/vitest.config.ts` | Vitest config with jsdom, path aliases, coverage settings |
|
||||
| `frontend/vitest.setup.ts` | Global mocks for sonner, next/navigation, apiClient |
|
||||
| `frontend/lib/test-utils.tsx` | QueryClient wrapper for React Query hook testing |
|
||||
| `frontend/package.json` | Added test scripts: `test`, `test:watch`, `test:coverage` |
|
||||
|
||||
**Dependencies Installed:**
|
||||
- `vitest`
|
||||
- `@vitejs/plugin-react`
|
||||
- `@testing-library/react`
|
||||
- `@testing-library/jest-dom`
|
||||
- `@testing-library/user-event`
|
||||
- `jsdom`
|
||||
|
||||
---
|
||||
|
||||
### 2. Unit Tests - Hooks (52 tests)
|
||||
|
||||
| Test File | Tests |
|
||||
| -------------------------------------------- | ----- |
|
||||
| `hooks/__tests__/use-correspondence.test.ts` | 12 |
|
||||
| `hooks/__tests__/use-drawing.test.ts` | 10 |
|
||||
| `hooks/__tests__/use-rfa.test.ts` | 10 |
|
||||
| `hooks/__tests__/use-projects.test.ts` | 10 |
|
||||
| `hooks/__tests__/use-users.test.ts` | 10 |
|
||||
|
||||
---
|
||||
|
||||
### 3. Unit Tests - Services (49 tests)
|
||||
|
||||
| Test File | Tests |
|
||||
| ------------------------------------------------------- | ----- |
|
||||
| `lib/services/__tests__/correspondence.service.test.ts` | 11 |
|
||||
| `lib/services/__tests__/project.service.test.ts` | 12 |
|
||||
| `lib/services/__tests__/master-data.service.test.ts` | 26 |
|
||||
|
||||
---
|
||||
|
||||
### 4. Component Tests (17 tests)
|
||||
|
||||
| Test File | Tests |
|
||||
| ----------------------------------------- | ----- |
|
||||
| `components/ui/__tests__/button.test.tsx` | 17 |
|
||||
|
||||
---
|
||||
|
||||
## Final Results
|
||||
|
||||
```
|
||||
Test Files 9 passed (9)
|
||||
Tests 118 passed (118)
|
||||
Duration 9.06s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Areas
|
||||
|
||||
- ✅ Query Hooks (list and detail fetching)
|
||||
- ✅ Mutation Hooks (create, update, delete, workflow)
|
||||
- ✅ Service Layer (API client calls)
|
||||
- ✅ Cache Keys (query cache key generation)
|
||||
- ✅ Toast Notifications (success and error toasts)
|
||||
- ✅ Error Handling (API error states)
|
||||
- ✅ Component Variants, Sizes, States
|
||||
|
||||
---
|
||||
|
||||
## How to Run Tests
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Run all tests once
|
||||
pnpm test --run
|
||||
|
||||
# Run tests in watch mode
|
||||
pnpm test:watch
|
||||
|
||||
# Run with coverage
|
||||
pnpm test:coverage
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remaining Optional Work
|
||||
|
||||
- [ ] E2E tests with Playwright
|
||||
- [ ] Additional component tests (Form, Table, Dialog)
|
||||
- [ ] Integration tests for page components
|
||||
78
specs/09-history/2025-12-11_frontend-integration-review.md
Normal file
78
specs/09-history/2025-12-11_frontend-integration-review.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Session Summary: Frontend Integration Review & Fixes
|
||||
|
||||
**Date:** 2025-12-11
|
||||
**Session ID:** ae7069dd-6475-48f9-8c85-21694e014975
|
||||
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Review frontend integration status and fix minor issues in Correspondences, RFAs, and Drawings modules.
|
||||
|
||||
## Work Completed
|
||||
|
||||
### 1. Integration Review ✅
|
||||
|
||||
Verified that all 3 core modules are properly integrated with Backend APIs:
|
||||
|
||||
| Module | Service | Hook | API Endpoint | Status |
|
||||
| ----------------- | ----------------------------- | ----------------------- | -------------------- | ---------- |
|
||||
| Correspondences | `correspondence.service.ts` | `use-correspondence.ts` | `/correspondences` | ✅ Real API |
|
||||
| RFAs | `rfa.service.ts` | `use-rfa.ts` | `/rfas` | ✅ Real API |
|
||||
| Contract Drawings | `contract-drawing.service.ts` | `use-drawing.ts` | `/drawings/contract` | ✅ Real API |
|
||||
| Shop Drawings | `shop-drawing.service.ts` | `use-drawing.ts` | `/drawings/shop` | ✅ Real API |
|
||||
|
||||
### 2. Minor Issues Fixed ✅
|
||||
|
||||
#### 2.1 `components/drawings/list.tsx`
|
||||
- **Issue:** Hardcoded `projectId: 1`
|
||||
- **Fix:** Added optional `projectId` prop to `DrawingListProps` interface
|
||||
|
||||
```diff
|
||||
interface DrawingListProps {
|
||||
type: "CONTRACT" | "SHOP";
|
||||
+ projectId?: number;
|
||||
}
|
||||
|
||||
-export function DrawingList({ type }: DrawingListProps) {
|
||||
- const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: 1 });
|
||||
+export function DrawingList({ type, projectId }: DrawingListProps) {
|
||||
+ const { data: drawings, isLoading, isError } = useDrawings(type, { projectId: projectId ?? 1 });
|
||||
```
|
||||
|
||||
#### 2.2 `hooks/use-drawing.ts`
|
||||
- **Issue:** `any` types in multiple places
|
||||
- **Fix:** Added proper types
|
||||
|
||||
```diff
|
||||
+type DrawingSearchParams = SearchContractDrawingDto | SearchShopDrawingDto;
|
||||
+type CreateDrawingData = CreateContractDrawingDto | CreateShopDrawingDto;
|
||||
|
||||
-export function useDrawings(type: DrawingType, params: any) {
|
||||
+export function useDrawings(type: DrawingType, params: DrawingSearchParams) {
|
||||
|
||||
-mutationFn: async (data: any) => {
|
||||
+mutationFn: async (data: CreateDrawingData) => {
|
||||
|
||||
-onError: (error: any) => {
|
||||
+onError: (error: Error & { response?: { data?: { message?: string } } }) => {
|
||||
```
|
||||
|
||||
#### 2.3 `hooks/use-correspondence.ts`
|
||||
- **Issue:** `any` types and missing mutations
|
||||
- **Fix:**
|
||||
- Added `ApiError` type for error handling
|
||||
- Imported `WorkflowActionDto` for proper typing
|
||||
- Added `useUpdateCorrespondence()` mutation
|
||||
- Added `useDeleteCorrespondence()` mutation
|
||||
- Replaced all `any` types with proper types
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `frontend/components/drawings/list.tsx`
|
||||
2. `frontend/hooks/use-drawing.ts`
|
||||
3. `frontend/hooks/use-correspondence.ts`
|
||||
|
||||
## Conclusion
|
||||
|
||||
All frontend business modules (Correspondences, RFAs, Drawings) are confirmed to be properly integrated with Backend APIs using TanStack Query. The security features (Idempotency-Key, JWT injection) are correctly implemented in the API client. Minor type safety issues have been resolved.
|
||||
Reference in New Issue
Block a user