uup
This commit is contained in:
39
app/lib/__tests__/auth-integration.test.ts
Normal file
39
app/lib/__tests__/auth-integration.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
// Test the actual bcrypt functionality without importing our modules
|
||||
describe("Authentication Integration", () => {
|
||||
describe("Password Hashing Integration", () => {
|
||||
it("should hash and verify passwords using bcrypt", async () => {
|
||||
const password = "testpassword123";
|
||||
|
||||
// Hash the password
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
expect(hashedPassword).toBeDefined();
|
||||
expect(hashedPassword).not.toBe(password);
|
||||
expect(hashedPassword.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify correct password
|
||||
const isValidPassword = await bcrypt.compare(password, hashedPassword);
|
||||
expect(isValidPassword).toBe(true);
|
||||
|
||||
// Verify incorrect password
|
||||
const isInvalidPassword = await bcrypt.compare("wrongpassword", hashedPassword);
|
||||
expect(isInvalidPassword).toBe(false);
|
||||
});
|
||||
|
||||
it("should generate different hashes for the same password", async () => {
|
||||
const password = "testpassword123";
|
||||
|
||||
const hash1 = await bcrypt.hash(password, 12);
|
||||
const hash2 = await bcrypt.hash(password, 12);
|
||||
|
||||
expect(hash1).not.toBe(hash2);
|
||||
|
||||
// But both should verify correctly
|
||||
expect(await bcrypt.compare(password, hash1)).toBe(true);
|
||||
expect(await bcrypt.compare(password, hash2)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
58
app/lib/__tests__/auth.test.ts
Normal file
58
app/lib/__tests__/auth.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { hasPermission, canAccessUserManagement } from "../auth-helpers.server";
|
||||
import { AUTH_LEVELS } from "~/types/auth";
|
||||
|
||||
// Mock the database
|
||||
vi.mock("../db.server", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findFirst: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock auth.server to avoid session secret requirement
|
||||
vi.mock("../auth.server", () => ({
|
||||
hashPassword: vi.fn(),
|
||||
verifyPassword: vi.fn(),
|
||||
createUserSession: vi.fn(),
|
||||
getUserSession: vi.fn(),
|
||||
getUserId: vi.fn(),
|
||||
requireUserId: vi.fn(),
|
||||
getUser: vi.fn(),
|
||||
requireUser: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("Authentication System", () => {
|
||||
|
||||
describe("Authorization Helpers", () => {
|
||||
it("should check permissions correctly", () => {
|
||||
// Superadmin should have access to everything
|
||||
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.SUPERADMIN)).toBe(true);
|
||||
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.ADMIN)).toBe(true);
|
||||
expect(hasPermission(AUTH_LEVELS.SUPERADMIN, AUTH_LEVELS.USER)).toBe(true);
|
||||
|
||||
// Admin should have access to admin and user levels
|
||||
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.SUPERADMIN)).toBe(false);
|
||||
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.ADMIN)).toBe(true);
|
||||
expect(hasPermission(AUTH_LEVELS.ADMIN, AUTH_LEVELS.USER)).toBe(true);
|
||||
|
||||
// User should only have access to user level
|
||||
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.SUPERADMIN)).toBe(false);
|
||||
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.ADMIN)).toBe(false);
|
||||
expect(hasPermission(AUTH_LEVELS.USER, AUTH_LEVELS.USER)).toBe(true);
|
||||
});
|
||||
|
||||
it("should check user management access correctly", () => {
|
||||
expect(canAccessUserManagement(AUTH_LEVELS.SUPERADMIN)).toBe(true);
|
||||
expect(canAccessUserManagement(AUTH_LEVELS.ADMIN)).toBe(true);
|
||||
expect(canAccessUserManagement(AUTH_LEVELS.USER)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
437
app/lib/__tests__/customer-management.test.ts
Normal file
437
app/lib/__tests__/customer-management.test.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
|
||||
// Mock Prisma first
|
||||
vi.mock('../db.server', () => ({
|
||||
prisma: {
|
||||
customer: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
getCustomers,
|
||||
createCustomer,
|
||||
updateCustomer,
|
||||
deleteCustomer,
|
||||
getCustomerById,
|
||||
getCustomersForSelect,
|
||||
searchCustomers,
|
||||
getCustomerStats
|
||||
} from '../customer-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
const mockPrisma = prisma as any;
|
||||
|
||||
describe('Customer Management Server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getCustomers', () => {
|
||||
it('should return customers with pagination', async () => {
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.customer.findMany.mockResolvedValue(mockCustomers);
|
||||
mockPrisma.customer.count.mockResolvedValue(1);
|
||||
|
||||
const result = await getCustomers('', 1, 10);
|
||||
|
||||
expect(result).toEqual({
|
||||
customers: mockCustomers,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
include: {
|
||||
vehicles: {
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
lastVisitDate: true,
|
||||
suggestedNextVisitDate: true,
|
||||
},
|
||||
},
|
||||
maintenanceVisits: {
|
||||
select: {
|
||||
id: true,
|
||||
visitDate: true,
|
||||
cost: true,
|
||||
maintenanceType: true,
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 5,
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle search query', async () => {
|
||||
mockPrisma.customer.findMany.mockResolvedValue([]);
|
||||
mockPrisma.customer.count.mockResolvedValue(0);
|
||||
|
||||
await getCustomers('أحمد', 1, 10);
|
||||
|
||||
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: 'أحمد' } },
|
||||
{ phone: { contains: 'أحمد' } },
|
||||
{ email: { contains: 'أحمد' } },
|
||||
{ address: { contains: 'أحمد' } },
|
||||
],
|
||||
},
|
||||
include: expect.any(Object),
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCustomer', () => {
|
||||
it('should create a new customer successfully', async () => {
|
||||
const customerData = {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
};
|
||||
|
||||
const mockCustomer = {
|
||||
id: 1,
|
||||
...customerData,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.customer.create.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await createCustomer(customerData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
customer: mockCustomer,
|
||||
});
|
||||
expect(mockPrisma.customer.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if phone already exists', async () => {
|
||||
const customerData = {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
};
|
||||
|
||||
const existingCustomer = {
|
||||
id: 2,
|
||||
name: 'محمد أحمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await createCustomer(customerData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'رقم الهاتف موجود بالفعل',
|
||||
});
|
||||
expect(mockPrisma.customer.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if email already exists', async () => {
|
||||
const customerData = {
|
||||
name: 'أحمد محمد',
|
||||
email: 'ahmed@example.com',
|
||||
};
|
||||
|
||||
const existingCustomer = {
|
||||
id: 2,
|
||||
name: 'محمد أحمد',
|
||||
phone: null,
|
||||
email: 'ahmed@example.com',
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await createCustomer(customerData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'البريد الإلكتروني موجود بالفعل',
|
||||
});
|
||||
expect(mockPrisma.customer.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCustomer', () => {
|
||||
it('should update customer successfully', async () => {
|
||||
const customerId = 1;
|
||||
const updateData = {
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
};
|
||||
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
const updatedCustomer = {
|
||||
...existingCustomer,
|
||||
...updateData,
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
mockPrisma.customer.findFirst.mockResolvedValue(null);
|
||||
mockPrisma.customer.update.mockResolvedValue(updatedCustomer);
|
||||
|
||||
const result = await updateCustomer(customerId, updateData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
customer: updatedCustomer,
|
||||
});
|
||||
expect(mockPrisma.customer.update).toHaveBeenCalledWith({
|
||||
where: { id: customerId },
|
||||
data: {
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if customer not found', async () => {
|
||||
const customerId = 999;
|
||||
const updateData = { name: 'أحمد محمد' };
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await updateCustomer(customerId, updateData);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'العميل غير موجود',
|
||||
});
|
||||
expect(mockPrisma.customer.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCustomer', () => {
|
||||
it('should delete customer successfully when no relationships exist', async () => {
|
||||
const customerId = 1;
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
mockPrisma.customer.delete.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: true,
|
||||
});
|
||||
expect(mockPrisma.customer.delete).toHaveBeenCalledWith({
|
||||
where: { id: customerId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return error if customer has vehicles', async () => {
|
||||
const customerId = 1;
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [{ id: 1, plateNumber: 'ABC-123' }],
|
||||
maintenanceVisits: [],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'لا يمكن حذف العميل لأنه يملك 1 مركبة. يرجى حذف المركبات أولاً',
|
||||
});
|
||||
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if customer has maintenance visits', async () => {
|
||||
const customerId = 1;
|
||||
const existingCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: null,
|
||||
address: null,
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [{ id: 1, visitDate: new Date() }],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(existingCustomer);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'لا يمكن حذف العميل لأنه لديه 1 زيارة صيانة. يرجى حذف الزيارات أولاً',
|
||||
});
|
||||
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return error if customer not found', async () => {
|
||||
const customerId = 999;
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await deleteCustomer(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
error: 'العميل غير موجود',
|
||||
});
|
||||
expect(mockPrisma.customer.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchCustomers', () => {
|
||||
it('should return empty array for short queries', async () => {
|
||||
const result = await searchCustomers('a');
|
||||
expect(result).toEqual([]);
|
||||
expect(mockPrisma.customer.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search customers by name, phone, and email', async () => {
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
},
|
||||
];
|
||||
|
||||
mockPrisma.customer.findMany.mockResolvedValue(mockCustomers);
|
||||
|
||||
const result = await searchCustomers('أحمد', 10);
|
||||
|
||||
expect(result).toEqual(mockCustomers);
|
||||
expect(mockPrisma.customer.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ name: { contains: 'أحمد' } },
|
||||
{ phone: { contains: 'أحمد' } },
|
||||
{ email: { contains: 'أحمد' } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomerStats', () => {
|
||||
it('should return customer statistics', async () => {
|
||||
const customerId = 1;
|
||||
const mockCustomer = {
|
||||
id: customerId,
|
||||
name: 'أحمد محمد',
|
||||
vehicles: [{ id: 1 }, { id: 2 }],
|
||||
maintenanceVisits: [
|
||||
{ cost: 100, visitDate: new Date('2024-01-15') },
|
||||
{ cost: 200, visitDate: new Date('2024-01-10') },
|
||||
{ cost: 150, visitDate: new Date('2024-01-05') },
|
||||
],
|
||||
};
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(mockCustomer);
|
||||
|
||||
const result = await getCustomerStats(customerId);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalVehicles: 2,
|
||||
totalVisits: 3,
|
||||
totalSpent: 450,
|
||||
lastVisitDate: new Date('2024-01-15'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if customer not found', async () => {
|
||||
const customerId = 999;
|
||||
|
||||
mockPrisma.customer.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await getCustomerStats(customerId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
293
app/lib/__tests__/customer-routes-integration.test.ts
Normal file
293
app/lib/__tests__/customer-routes-integration.test.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock the auth middleware
|
||||
vi.mock('../auth-middleware.server', () => ({
|
||||
requireAuth: vi.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
email: 'test@example.com',
|
||||
authLevel: 1,
|
||||
status: 'active',
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock the customer management functions
|
||||
vi.mock('../customer-management.server', () => ({
|
||||
getCustomers: vi.fn(),
|
||||
createCustomer: vi.fn(),
|
||||
updateCustomer: vi.fn(),
|
||||
deleteCustomer: vi.fn(),
|
||||
getCustomerById: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock validation
|
||||
vi.mock('../validation', () => ({
|
||||
validateCustomer: vi.fn(),
|
||||
}));
|
||||
|
||||
import { loader, action } from '../../routes/customers';
|
||||
import { getCustomers, createCustomer, updateCustomer, deleteCustomer } from '../customer-management.server';
|
||||
import { validateCustomer } from '../validation';
|
||||
|
||||
const mockGetCustomers = getCustomers as any;
|
||||
const mockCreateCustomer = createCustomer as any;
|
||||
const mockUpdateCustomer = updateCustomer as any;
|
||||
const mockDeleteCustomer = deleteCustomer as any;
|
||||
const mockValidateCustomer = validateCustomer as any;
|
||||
|
||||
describe('Customer Routes Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('loader', () => {
|
||||
it('should load customers with default pagination', async () => {
|
||||
const mockCustomers = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
vehicles: [],
|
||||
maintenanceVisits: [],
|
||||
},
|
||||
];
|
||||
|
||||
mockGetCustomers.mockResolvedValue({
|
||||
customers: mockCustomers,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/customers');
|
||||
const response = await loader({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockGetCustomers).toHaveBeenCalledWith('', 1, 10);
|
||||
expect(data.customers).toEqual(mockCustomers);
|
||||
expect(data.total).toBe(1);
|
||||
expect(data.totalPages).toBe(1);
|
||||
expect(data.currentPage).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle search and pagination parameters', async () => {
|
||||
mockGetCustomers.mockResolvedValue({
|
||||
customers: [],
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
|
||||
const request = new Request('http://localhost:3000/customers?search=أحمد&page=2&limit=20');
|
||||
await loader({ request, params: {}, context: {} });
|
||||
|
||||
expect(mockGetCustomers).toHaveBeenCalledWith('أحمد', 2, 20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('action', () => {
|
||||
it('should create customer successfully', async () => {
|
||||
const mockCustomer = {
|
||||
id: 1,
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockValidateCustomer.mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
mockCreateCustomer.mockResolvedValue({
|
||||
success: true,
|
||||
customer: mockCustomer,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'create');
|
||||
formData.append('name', 'أحمد محمد');
|
||||
formData.append('phone', '0501234567');
|
||||
formData.append('email', 'ahmed@example.com');
|
||||
formData.append('address', 'الرياض');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockValidateCustomer).toHaveBeenCalledWith({
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
});
|
||||
|
||||
expect(mockCreateCustomer).toHaveBeenCalledWith({
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض',
|
||||
});
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.customer).toEqual(mockCustomer);
|
||||
expect(data.action).toBe('create');
|
||||
expect(data.message).toBe('تم إنشاء العميل بنجاح');
|
||||
});
|
||||
|
||||
it('should return validation errors for invalid data', async () => {
|
||||
mockValidateCustomer.mockReturnValue({
|
||||
isValid: false,
|
||||
errors: {
|
||||
name: 'اسم العميل مطلوب',
|
||||
email: 'البريد الإلكتروني غير صحيح',
|
||||
},
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'create');
|
||||
formData.append('name', '');
|
||||
formData.append('email', 'invalid-email');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.errors).toEqual({
|
||||
name: 'اسم العميل مطلوب',
|
||||
email: 'البريد الإلكتروني غير صحيح',
|
||||
});
|
||||
expect(mockCreateCustomer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should update customer successfully', async () => {
|
||||
const mockCustomer = {
|
||||
id: 1,
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
email: 'ahmed.updated@example.com',
|
||||
address: 'جدة',
|
||||
createdDate: new Date(),
|
||||
updateDate: new Date(),
|
||||
};
|
||||
|
||||
mockValidateCustomer.mockReturnValue({
|
||||
isValid: true,
|
||||
errors: {},
|
||||
});
|
||||
|
||||
mockUpdateCustomer.mockResolvedValue({
|
||||
success: true,
|
||||
customer: mockCustomer,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'update');
|
||||
formData.append('id', '1');
|
||||
formData.append('name', 'أحمد محمد المحدث');
|
||||
formData.append('phone', '0509876543');
|
||||
formData.append('email', 'ahmed.updated@example.com');
|
||||
formData.append('address', 'جدة');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockUpdateCustomer).toHaveBeenCalledWith(1, {
|
||||
name: 'أحمد محمد المحدث',
|
||||
phone: '0509876543',
|
||||
email: 'ahmed.updated@example.com',
|
||||
address: 'جدة',
|
||||
});
|
||||
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.customer).toEqual(mockCustomer);
|
||||
expect(data.action).toBe('update');
|
||||
expect(data.message).toBe('تم تحديث العميل بنجاح');
|
||||
});
|
||||
|
||||
it('should delete customer successfully', async () => {
|
||||
mockDeleteCustomer.mockResolvedValue({
|
||||
success: true,
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
formData.append('id', '1');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(mockDeleteCustomer).toHaveBeenCalledWith(1);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.action).toBe('delete');
|
||||
expect(data.message).toBe('تم حذف العميل بنجاح');
|
||||
});
|
||||
|
||||
it('should handle delete errors', async () => {
|
||||
mockDeleteCustomer.mockResolvedValue({
|
||||
success: false,
|
||||
error: 'لا يمكن حذف العميل لأنه يملك مركبات',
|
||||
});
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'delete');
|
||||
formData.append('id', '1');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('لا يمكن حذف العميل لأنه يملك مركبات');
|
||||
});
|
||||
|
||||
it('should handle unknown action', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('_action', 'unknown');
|
||||
|
||||
const request = new Request('http://localhost:3000/customers', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const response = await action({ request, params: {}, context: {} });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('إجراء غير صحيح');
|
||||
expect(data.action).toBe('unknown');
|
||||
});
|
||||
});
|
||||
});
|
||||
109
app/lib/__tests__/customer-validation.test.ts
Normal file
109
app/lib/__tests__/customer-validation.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateCustomer } from '../validation';
|
||||
|
||||
describe('Customer Validation', () => {
|
||||
describe('validateCustomer', () => {
|
||||
it('should validate required name field', () => {
|
||||
const result = validateCustomer({ name: '' });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('اسم العميل مطلوب');
|
||||
});
|
||||
|
||||
it('should validate name length', () => {
|
||||
const longName = 'أ'.repeat(101);
|
||||
const result = validateCustomer({ name: longName });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('الاسم يجب أن يكون أقل من 100 حرف');
|
||||
});
|
||||
|
||||
it('should validate phone format', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: 'invalid-phone'
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.phone).toBe('رقم الهاتف غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid phone formats', () => {
|
||||
const validPhones = [
|
||||
'0501234567',
|
||||
'+966501234567',
|
||||
'050 123 4567',
|
||||
'050-123-4567',
|
||||
'(050) 123-4567',
|
||||
];
|
||||
|
||||
validPhones.forEach(phone => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.phone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate email format', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
email: 'invalid-email'
|
||||
});
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid email format', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
email: 'ahmed@example.com'
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.email).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should allow empty optional fields', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: ''
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate complete customer data', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
address: 'الرياض، المملكة العربية السعودية'
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle undefined fields gracefully', () => {
|
||||
const result = validateCustomer({
|
||||
name: 'أحمد محمد',
|
||||
phone: undefined,
|
||||
email: undefined,
|
||||
address: undefined
|
||||
});
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should trim whitespace from name', () => {
|
||||
const result = validateCustomer({ name: ' أحمد محمد ' });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject name with only whitespace', () => {
|
||||
const result = validateCustomer({ name: ' ' });
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('اسم العميل مطلوب');
|
||||
});
|
||||
});
|
||||
});
|
||||
278
app/lib/__tests__/expense-management.test.ts
Normal file
278
app/lib/__tests__/expense-management.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createExpense,
|
||||
getExpenses,
|
||||
getExpenseById,
|
||||
updateExpense,
|
||||
deleteExpense,
|
||||
getExpenseCategories,
|
||||
getExpensesByCategory,
|
||||
getTotalExpenses
|
||||
} from '../expense-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
describe('Expense Management', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.expense.deleteMany();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.expense.deleteMany();
|
||||
});
|
||||
|
||||
describe('createExpense', () => {
|
||||
it('should create a new expense', async () => {
|
||||
const expenseData = {
|
||||
description: 'قطع غيار للمحرك',
|
||||
category: 'قطع غيار',
|
||||
amount: 500.00,
|
||||
expenseDate: new Date('2024-01-15'),
|
||||
};
|
||||
|
||||
const expense = await createExpense(expenseData);
|
||||
|
||||
expect(expense).toBeDefined();
|
||||
expect(expense.description).toBe(expenseData.description);
|
||||
expect(expense.category).toBe(expenseData.category);
|
||||
expect(expense.amount).toBe(expenseData.amount);
|
||||
expect(expense.expenseDate).toEqual(expenseData.expenseDate);
|
||||
});
|
||||
|
||||
it('should create expense with current date if no date provided', async () => {
|
||||
const expenseData = {
|
||||
description: 'مصروف عام',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
};
|
||||
|
||||
const expense = await createExpense(expenseData);
|
||||
|
||||
expect(expense).toBeDefined();
|
||||
expect(expense.expenseDate).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenses', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test expenses
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{
|
||||
description: 'قطع غيار',
|
||||
category: 'قطع غيار',
|
||||
amount: 500.00,
|
||||
expenseDate: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
description: 'أدوات صيانة',
|
||||
category: 'أدوات',
|
||||
amount: 200.00,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
description: 'إيجار المحل',
|
||||
category: 'إيجار',
|
||||
amount: 3000.00,
|
||||
expenseDate: new Date('2024-01-01'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all expenses with pagination', async () => {
|
||||
const result = await getExpenses();
|
||||
|
||||
expect(result.expenses).toHaveLength(3);
|
||||
expect(result.total).toBe(3);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter expenses by search query', async () => {
|
||||
const result = await getExpenses('قطع غيار');
|
||||
|
||||
expect(result.expenses).toHaveLength(1);
|
||||
expect(result.expenses[0].description).toBe('قطع غيار');
|
||||
});
|
||||
|
||||
it('should filter expenses by category', async () => {
|
||||
const result = await getExpenses(undefined, 1, 10, 'أدوات');
|
||||
|
||||
expect(result.expenses).toHaveLength(1);
|
||||
expect(result.expenses[0].category).toBe('أدوات');
|
||||
});
|
||||
|
||||
it('should filter expenses by date range', async () => {
|
||||
const dateFrom = new Date('2024-01-10');
|
||||
const dateTo = new Date('2024-01-20');
|
||||
|
||||
const result = await getExpenses(undefined, 1, 10, undefined, dateFrom, dateTo);
|
||||
|
||||
expect(result.expenses).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle pagination correctly', async () => {
|
||||
const result = await getExpenses(undefined, 1, 2);
|
||||
|
||||
expect(result.expenses).toHaveLength(2);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenseById', () => {
|
||||
it('should get expense by ID', async () => {
|
||||
const createdExpense = await prisma.expense.create({
|
||||
data: {
|
||||
description: 'تست مصروف',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
},
|
||||
});
|
||||
|
||||
const expense = await getExpenseById(createdExpense.id);
|
||||
|
||||
expect(expense).toBeDefined();
|
||||
expect(expense?.id).toBe(createdExpense.id);
|
||||
expect(expense?.description).toBe('تست مصروف');
|
||||
});
|
||||
|
||||
it('should return null for non-existent expense', async () => {
|
||||
const expense = await getExpenseById(999);
|
||||
|
||||
expect(expense).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateExpense', () => {
|
||||
it('should update expense successfully', async () => {
|
||||
const createdExpense = await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف قديم',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
},
|
||||
});
|
||||
|
||||
const updateData = {
|
||||
description: 'مصروف محدث',
|
||||
category: 'قطع غيار',
|
||||
amount: 200.00,
|
||||
};
|
||||
|
||||
const updatedExpense = await updateExpense(createdExpense.id, updateData);
|
||||
|
||||
expect(updatedExpense.description).toBe(updateData.description);
|
||||
expect(updatedExpense.category).toBe(updateData.category);
|
||||
expect(updatedExpense.amount).toBe(updateData.amount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteExpense', () => {
|
||||
it('should delete expense successfully', async () => {
|
||||
const createdExpense = await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف للحذف',
|
||||
category: 'أخرى',
|
||||
amount: 100.00,
|
||||
},
|
||||
});
|
||||
|
||||
await deleteExpense(createdExpense.id);
|
||||
|
||||
const deletedExpense = await prisma.expense.findUnique({
|
||||
where: { id: createdExpense.id },
|
||||
});
|
||||
|
||||
expect(deletedExpense).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenseCategories', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{ description: 'مصروف 1', category: 'قطع غيار', amount: 100 },
|
||||
{ description: 'مصروف 2', category: 'أدوات', amount: 200 },
|
||||
{ description: 'مصروف 3', category: 'قطع غيار', amount: 150 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should get unique expense categories', async () => {
|
||||
const categories = await getExpenseCategories();
|
||||
|
||||
expect(categories).toHaveLength(2);
|
||||
expect(categories).toContain('قطع غيار');
|
||||
expect(categories).toContain('أدوات');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpensesByCategory', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{ description: 'مصروف 1', category: 'قطع غيار', amount: 100 },
|
||||
{ description: 'مصروف 2', category: 'أدوات', amount: 200 },
|
||||
{ description: 'مصروف 3', category: 'قطع غيار', amount: 150 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should group expenses by category', async () => {
|
||||
const result = await getExpensesByCategory();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const spareParts = result.find(r => r.category === 'قطع غيار');
|
||||
expect(spareParts?.total).toBe(250);
|
||||
expect(spareParts?.count).toBe(2);
|
||||
|
||||
const tools = result.find(r => r.category === 'أدوات');
|
||||
expect(tools?.total).toBe(200);
|
||||
expect(tools?.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTotalExpenses', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{
|
||||
description: 'مصروف 1',
|
||||
category: 'قطع غيار',
|
||||
amount: 100,
|
||||
expenseDate: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
description: 'مصروف 2',
|
||||
category: 'أدوات',
|
||||
amount: 200,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
description: 'مصروف 3',
|
||||
category: 'إيجار',
|
||||
amount: 3000,
|
||||
expenseDate: new Date('2023-12-15'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate total expenses', async () => {
|
||||
const total = await getTotalExpenses();
|
||||
|
||||
expect(total).toBe(3300);
|
||||
});
|
||||
|
||||
it('should calculate total expenses for date range', async () => {
|
||||
const dateFrom = new Date('2024-01-01');
|
||||
const dateTo = new Date('2024-01-31');
|
||||
|
||||
const total = await getTotalExpenses(dateFrom, dateTo);
|
||||
|
||||
expect(total).toBe(300); // Only expenses from January 2024
|
||||
});
|
||||
});
|
||||
});
|
||||
458
app/lib/__tests__/financial-reporting.test.ts
Normal file
458
app/lib/__tests__/financial-reporting.test.ts
Normal file
@@ -0,0 +1,458 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
getFinancialSummary,
|
||||
getMonthlyFinancialData,
|
||||
getIncomeByMaintenanceType,
|
||||
getExpenseBreakdown,
|
||||
getTopCustomersByRevenue,
|
||||
getFinancialTrends
|
||||
} from '../financial-reporting.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
describe('Financial Reporting', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.income.deleteMany();
|
||||
await prisma.expense.deleteMany();
|
||||
await prisma.maintenanceVisit.deleteMany();
|
||||
await prisma.vehicle.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.income.deleteMany();
|
||||
await prisma.expense.deleteMany();
|
||||
await prisma.maintenanceVisit.deleteMany();
|
||||
await prisma.vehicle.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
});
|
||||
|
||||
describe('getFinancialSummary', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test customer
|
||||
const customer = await prisma.customer.create({
|
||||
data: {
|
||||
name: 'عميل تجريبي',
|
||||
phone: '0501234567',
|
||||
},
|
||||
});
|
||||
|
||||
// Create test vehicle
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test maintenance visit
|
||||
const visit = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 200.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create test income
|
||||
await prisma.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: visit.id,
|
||||
amount: 200.00,
|
||||
incomeDate: new Date('2024-01-15'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create test expenses
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{
|
||||
description: 'قطع غيار',
|
||||
category: 'قطع غيار',
|
||||
amount: 50.00,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
{
|
||||
description: 'أدوات',
|
||||
category: 'أدوات',
|
||||
amount: 30.00,
|
||||
expenseDate: new Date('2024-01-12'),
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate financial summary correctly', async () => {
|
||||
const summary = await getFinancialSummary();
|
||||
|
||||
expect(summary.totalIncome).toBe(200.00);
|
||||
expect(summary.totalExpenses).toBe(80.00);
|
||||
expect(summary.netProfit).toBe(120.00);
|
||||
expect(summary.incomeCount).toBe(1);
|
||||
expect(summary.expenseCount).toBe(2);
|
||||
expect(summary.profitMargin).toBe(60.0); // (120/200) * 100
|
||||
});
|
||||
|
||||
it('should filter by date range', async () => {
|
||||
const dateFrom = new Date('2024-01-11');
|
||||
const dateTo = new Date('2024-01-20');
|
||||
|
||||
const summary = await getFinancialSummary(dateFrom, dateTo);
|
||||
|
||||
expect(summary.totalIncome).toBe(200.00);
|
||||
expect(summary.totalExpenses).toBe(30.00); // Only one expense in range
|
||||
expect(summary.netProfit).toBe(170.00);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getIncomeByMaintenanceType', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test data
|
||||
const customer = await prisma.customer.create({
|
||||
data: { name: 'عميل تجريبي', phone: '0501234567' },
|
||||
});
|
||||
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create maintenance visits with different types
|
||||
const visit1 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 200.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit2 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري شامل',
|
||||
cost: 150.00,
|
||||
kilometers: 51000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit3 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت مرة أخرى',
|
||||
cost: 180.00,
|
||||
kilometers: 52000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create corresponding income records
|
||||
await prisma.income.createMany({
|
||||
data: [
|
||||
{ maintenanceVisitId: visit1.id, amount: 200.00 },
|
||||
{ maintenanceVisitId: visit2.id, amount: 150.00 },
|
||||
{ maintenanceVisitId: visit3.id, amount: 180.00 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should group income by maintenance type', async () => {
|
||||
const result = await getIncomeByMaintenanceType();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
const oilChange = result.find(r => r.category === 'تغيير زيت');
|
||||
expect(oilChange?.amount).toBe(380.00);
|
||||
expect(oilChange?.count).toBe(2);
|
||||
expect(oilChange?.percentage).toBeCloseTo(71.7, 1); // 380/530 * 100
|
||||
|
||||
const inspection = result.find(r => r.category === 'فحص دوري');
|
||||
expect(inspection?.amount).toBe(150.00);
|
||||
expect(inspection?.count).toBe(1);
|
||||
expect(inspection?.percentage).toBeCloseTo(28.3, 1); // 150/530 * 100
|
||||
});
|
||||
});
|
||||
|
||||
describe('getExpenseBreakdown', () => {
|
||||
beforeEach(async () => {
|
||||
await prisma.expense.createMany({
|
||||
data: [
|
||||
{ description: 'قطع غيار 1', category: 'قطع غيار', amount: 100 },
|
||||
{ description: 'قطع غيار 2', category: 'قطع غيار', amount: 150 },
|
||||
{ description: 'أدوات 1', category: 'أدوات', amount: 200 },
|
||||
{ description: 'إيجار', category: 'إيجار', amount: 3000 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should group expenses by category with percentages', async () => {
|
||||
const result = await getExpenseBreakdown();
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
|
||||
const rent = result.find(r => r.category === 'إيجار');
|
||||
expect(rent?.amount).toBe(3000);
|
||||
expect(rent?.count).toBe(1);
|
||||
expect(rent?.percentage).toBeCloseTo(86.96, 1); // 3000/3450 * 100
|
||||
|
||||
const spareParts = result.find(r => r.category === 'قطع غيار');
|
||||
expect(spareParts?.amount).toBe(250);
|
||||
expect(spareParts?.count).toBe(2);
|
||||
expect(spareParts?.percentage).toBeCloseTo(7.25, 1); // 250/3450 * 100
|
||||
|
||||
const tools = result.find(r => r.category === 'أدوات');
|
||||
expect(tools?.amount).toBe(200);
|
||||
expect(tools?.count).toBe(1);
|
||||
expect(tools?.percentage).toBeCloseTo(5.80, 1); // 200/3450 * 100
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopCustomersByRevenue', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test customers
|
||||
const customer1 = await prisma.customer.create({
|
||||
data: { name: 'عميل أول', phone: '0501111111' },
|
||||
});
|
||||
|
||||
const customer2 = await prisma.customer.create({
|
||||
data: { name: 'عميل ثاني', phone: '0502222222' },
|
||||
});
|
||||
|
||||
// Create vehicles
|
||||
const vehicle1 = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-111',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer1.id,
|
||||
},
|
||||
});
|
||||
|
||||
const vehicle2 = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-222',
|
||||
bodyType: 'SUV',
|
||||
manufacturer: 'هيونداي',
|
||||
model: 'توسان',
|
||||
year: 2021,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer2.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create maintenance visits
|
||||
const visit1 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle1.id,
|
||||
customerId: customer1.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت',
|
||||
cost: 300.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit2 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle1.id,
|
||||
customerId: customer1.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري',
|
||||
cost: 200.00,
|
||||
kilometers: 51000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const visit3 = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle2.id,
|
||||
customerId: customer2.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت',
|
||||
cost: 150.00,
|
||||
kilometers: 30000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create income records
|
||||
await prisma.income.createMany({
|
||||
data: [
|
||||
{ maintenanceVisitId: visit1.id, amount: 300.00 },
|
||||
{ maintenanceVisitId: visit2.id, amount: 200.00 },
|
||||
{ maintenanceVisitId: visit3.id, amount: 150.00 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return top customers by revenue', async () => {
|
||||
const result = await getTopCustomersByRevenue(10);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
|
||||
// Should be sorted by revenue descending
|
||||
expect(result[0].customerName).toBe('عميل أول');
|
||||
expect(result[0].totalRevenue).toBe(500.00);
|
||||
expect(result[0].visitCount).toBe(2);
|
||||
|
||||
expect(result[1].customerName).toBe('عميل ثاني');
|
||||
expect(result[1].totalRevenue).toBe(150.00);
|
||||
expect(result[1].visitCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should limit results correctly', async () => {
|
||||
const result = await getTopCustomersByRevenue(1);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].customerName).toBe('عميل أول');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFinancialTrends', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test customer and vehicle
|
||||
const customer = await prisma.customer.create({
|
||||
data: { name: 'عميل تجريبي', phone: '0501234567' },
|
||||
});
|
||||
|
||||
const vehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: customer.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Create maintenance visits for different periods
|
||||
const currentVisit = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت حالي',
|
||||
cost: 300.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
const previousVisit = await prisma.maintenanceVisit.create({
|
||||
data: {
|
||||
vehicleId: vehicle.id,
|
||||
customerId: customer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت سابق',
|
||||
cost: 200.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
},
|
||||
});
|
||||
|
||||
// Create income for current period (Jan 2024)
|
||||
await prisma.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: currentVisit.id,
|
||||
amount: 300.00,
|
||||
incomeDate: new Date('2024-01-15'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create income for previous period (Dec 2023)
|
||||
await prisma.income.create({
|
||||
data: {
|
||||
maintenanceVisitId: previousVisit.id,
|
||||
amount: 200.00,
|
||||
incomeDate: new Date('2023-12-15'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create expenses for current period
|
||||
await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف حالي',
|
||||
category: 'قطع غيار',
|
||||
amount: 100.00,
|
||||
expenseDate: new Date('2024-01-10'),
|
||||
},
|
||||
});
|
||||
|
||||
// Create expenses for previous period
|
||||
await prisma.expense.create({
|
||||
data: {
|
||||
description: 'مصروف سابق',
|
||||
category: 'قطع غيار',
|
||||
amount: 80.00,
|
||||
expenseDate: new Date('2023-12-10'),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should calculate financial trends correctly', async () => {
|
||||
const dateFrom = new Date('2024-01-01');
|
||||
const dateTo = new Date('2024-01-31');
|
||||
|
||||
const result = await getFinancialTrends(dateFrom, dateTo);
|
||||
|
||||
expect(result.currentPeriod.totalIncome).toBe(300.00);
|
||||
expect(result.currentPeriod.totalExpenses).toBe(100.00);
|
||||
expect(result.currentPeriod.netProfit).toBe(200.00);
|
||||
|
||||
expect(result.previousPeriod.totalIncome).toBe(200.00);
|
||||
expect(result.previousPeriod.totalExpenses).toBe(80.00);
|
||||
expect(result.previousPeriod.netProfit).toBe(120.00);
|
||||
|
||||
// Income growth: (300-200)/200 * 100 = 50%
|
||||
expect(result.trends.incomeGrowth).toBeCloseTo(50.0, 1);
|
||||
|
||||
// Expense growth: (100-80)/80 * 100 = 25%
|
||||
expect(result.trends.expenseGrowth).toBeCloseTo(25.0, 1);
|
||||
|
||||
// Profit growth: (200-120)/120 * 100 = 66.67%
|
||||
expect(result.trends.profitGrowth).toBeCloseTo(66.67, 1);
|
||||
});
|
||||
});
|
||||
});
|
||||
201
app/lib/__tests__/form-validation.test.ts
Normal file
201
app/lib/__tests__/form-validation.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateUserData,
|
||||
validateCustomerData,
|
||||
validateVehicleData,
|
||||
validateMaintenanceVisitData,
|
||||
validateExpenseData
|
||||
} from '../form-validation';
|
||||
|
||||
describe('Form Validation', () => {
|
||||
describe('validateUserData', () => {
|
||||
it('should validate valid user data', () => {
|
||||
const validData = {
|
||||
name: 'أحمد محمد',
|
||||
username: 'ahmed123',
|
||||
email: 'ahmed@example.com',
|
||||
password: 'password123',
|
||||
authLevel: 3,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const result = validateUserData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid user data', () => {
|
||||
const invalidData = {
|
||||
name: '',
|
||||
username: 'ab',
|
||||
email: 'invalid-email',
|
||||
password: '123',
|
||||
authLevel: 5,
|
||||
status: 'invalid',
|
||||
};
|
||||
|
||||
const result = validateUserData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.name).toBe('الاسم مطلوب');
|
||||
expect(result.errors.username).toBe('اسم المستخدم يجب أن يكون على الأقل 3 أحرف');
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
expect(result.errors.password).toBe('كلمة المرور يجب أن تكون على الأقل 6 أحرف');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateCustomerData', () => {
|
||||
it('should validate valid customer data', () => {
|
||||
const validData = {
|
||||
name: 'محمد أحمد',
|
||||
phone: '+966501234567',
|
||||
email: 'mohammed@example.com',
|
||||
address: 'الرياض، المملكة العربية السعودية',
|
||||
};
|
||||
|
||||
const result = validateCustomerData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should validate customer with minimal data', () => {
|
||||
const validData = {
|
||||
name: 'سارة علي',
|
||||
phone: '',
|
||||
email: '',
|
||||
address: '',
|
||||
};
|
||||
|
||||
const result = validateCustomerData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid customer data', () => {
|
||||
const invalidData = {
|
||||
name: '',
|
||||
phone: 'invalid-phone',
|
||||
email: 'invalid-email',
|
||||
address: 'Valid address',
|
||||
};
|
||||
|
||||
const result = validateCustomerData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.name).toBe('اسم العميل مطلوب');
|
||||
expect(result.errors.phone).toBe('رقم الهاتف غير صحيح');
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateVehicleData', () => {
|
||||
it('should validate valid vehicle data', () => {
|
||||
const validData = {
|
||||
plateNumber: 'ABC-1234',
|
||||
bodyType: 'Sedan',
|
||||
manufacturer: 'Toyota',
|
||||
model: 'Camry',
|
||||
trim: 'LE',
|
||||
year: '2023',
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
cylinders: '4',
|
||||
engineDisplacement: '2.5',
|
||||
useType: 'personal',
|
||||
ownerId: '1',
|
||||
};
|
||||
|
||||
const result = validateVehicleData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid vehicle data', () => {
|
||||
const invalidData = {
|
||||
plateNumber: '',
|
||||
bodyType: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
year: '1800',
|
||||
transmission: 'invalid',
|
||||
fuel: 'invalid',
|
||||
cylinders: '20',
|
||||
engineDisplacement: '50',
|
||||
useType: 'invalid',
|
||||
ownerId: '0',
|
||||
};
|
||||
|
||||
const result = validateVehicleData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
|
||||
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
|
||||
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
|
||||
expect(result.errors.model).toBe('الموديل مطلوب');
|
||||
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateMaintenanceVisitData', () => {
|
||||
it('should validate valid maintenance visit data', () => {
|
||||
const validData = {
|
||||
vehicleId: '1',
|
||||
customerId: '1',
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك والفلتر',
|
||||
cost: '150.50',
|
||||
paymentStatus: 'paid',
|
||||
kilometers: '50000',
|
||||
nextVisitDelay: '3',
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisitData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid maintenance visit data', () => {
|
||||
const invalidData = {
|
||||
vehicleId: '0',
|
||||
customerId: '0',
|
||||
maintenanceType: '',
|
||||
description: '',
|
||||
cost: '-10',
|
||||
paymentStatus: 'invalid',
|
||||
kilometers: '-100',
|
||||
nextVisitDelay: '5',
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisitData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.vehicleId).toBe('المركبة مطلوبة');
|
||||
expect(result.errors.customerId).toBe('العميل مطلوب');
|
||||
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
|
||||
expect(result.errors.description).toBe('وصف الصيانة مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateExpenseData', () => {
|
||||
it('should validate valid expense data', () => {
|
||||
const validData = {
|
||||
description: 'شراء قطع غيار',
|
||||
category: 'قطع غيار',
|
||||
amount: '250.75',
|
||||
};
|
||||
|
||||
const result = validateExpenseData(validData);
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid expense data', () => {
|
||||
const invalidData = {
|
||||
description: '',
|
||||
category: '',
|
||||
amount: '0',
|
||||
};
|
||||
|
||||
const result = validateExpenseData(invalidData);
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors.description).toBe('وصف المصروف مطلوب');
|
||||
expect(result.errors.category).toBe('فئة المصروف مطلوبة');
|
||||
});
|
||||
});
|
||||
});
|
||||
434
app/lib/__tests__/maintenance-visit-management.test.ts
Normal file
434
app/lib/__tests__/maintenance-visit-management.test.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
createMaintenanceVisit,
|
||||
getMaintenanceVisits,
|
||||
getMaintenanceVisitById,
|
||||
updateMaintenanceVisit,
|
||||
deleteMaintenanceVisit,
|
||||
getVehicleMaintenanceHistory,
|
||||
getCustomerMaintenanceHistory
|
||||
} from '../maintenance-visit-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
import type { Customer, Vehicle } from '@prisma/client';
|
||||
|
||||
describe('Maintenance Visit Management', () => {
|
||||
let testCustomer: Customer;
|
||||
let testVehicle: Vehicle;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create test customer
|
||||
testCustomer = await prisma.customer.create({
|
||||
data: {
|
||||
name: 'أحمد محمد',
|
||||
phone: '0501234567',
|
||||
email: 'ahmed@example.com',
|
||||
},
|
||||
});
|
||||
|
||||
// Create test vehicle
|
||||
testVehicle = await prisma.vehicle.create({
|
||||
data: {
|
||||
plateNumber: 'أ ب ج 123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: testCustomer.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.income.deleteMany();
|
||||
await prisma.maintenanceVisit.deleteMany();
|
||||
await prisma.vehicle.deleteMany();
|
||||
await prisma.customer.deleteMany();
|
||||
});
|
||||
|
||||
describe('createMaintenanceVisit', () => {
|
||||
it('should create a maintenance visit successfully', async () => {
|
||||
const visitData = {
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك والفلتر',
|
||||
cost: 150.00,
|
||||
paymentStatus: 'paid',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const visit = await createMaintenanceVisit(visitData);
|
||||
|
||||
expect(visit).toBeDefined();
|
||||
expect(visit.vehicleId).toBe(testVehicle.id);
|
||||
expect(visit.customerId).toBe(testCustomer.id);
|
||||
expect(visit.maintenanceType).toBe('تغيير زيت');
|
||||
expect(visit.cost).toBe(150.00);
|
||||
expect(visit.nextVisitDelay).toBe(3);
|
||||
|
||||
// Check that vehicle was updated
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
expect(updatedVehicle?.lastVisitDate).toBeDefined();
|
||||
expect(updatedVehicle?.suggestedNextVisitDate).toBeDefined();
|
||||
|
||||
// Check that income was created
|
||||
const income = await prisma.income.findFirst({
|
||||
where: { maintenanceVisitId: visit.id },
|
||||
});
|
||||
expect(income).toBeDefined();
|
||||
expect(income?.amount).toBe(150.00);
|
||||
});
|
||||
|
||||
it('should calculate next visit date correctly', async () => {
|
||||
const visitData = {
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري شامل',
|
||||
cost: 200.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 2, // 2 months
|
||||
};
|
||||
|
||||
await createMaintenanceVisit(visitData);
|
||||
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
|
||||
expect(updatedVehicle?.suggestedNextVisitDate).toBeDefined();
|
||||
|
||||
// Check that the suggested date is approximately 2 months from now
|
||||
const now = new Date();
|
||||
const expectedDate = new Date();
|
||||
expectedDate.setMonth(expectedDate.getMonth() + 2);
|
||||
|
||||
const actualDate = updatedVehicle!.suggestedNextVisitDate!;
|
||||
const timeDiff = Math.abs(actualDate.getTime() - expectedDate.getTime());
|
||||
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(daysDiff).toBeLessThan(2); // Allow 2 days difference
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaintenanceVisits', () => {
|
||||
beforeEach(async () => {
|
||||
// Create test visits
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'فحص فرامل',
|
||||
description: 'فحص وتنظيف الفرامل',
|
||||
cost: 100.00,
|
||||
kilometers: 52000,
|
||||
nextVisitDelay: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all maintenance visits', async () => {
|
||||
const result = await getMaintenanceVisits();
|
||||
|
||||
expect(result.visits).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should search maintenance visits by maintenance type', async () => {
|
||||
const result = await getMaintenanceVisits('تغيير زيت');
|
||||
|
||||
expect(result.visits).toHaveLength(1);
|
||||
expect(result.visits[0].maintenanceType).toBe('تغيير زيت');
|
||||
});
|
||||
|
||||
it('should filter by vehicle ID', async () => {
|
||||
const result = await getMaintenanceVisits('', 1, 10, testVehicle.id);
|
||||
|
||||
expect(result.visits).toHaveLength(2);
|
||||
result.visits.forEach(visit => {
|
||||
expect(visit.vehicleId).toBe(testVehicle.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by customer ID', async () => {
|
||||
const result = await getMaintenanceVisits('', 1, 10, undefined, testCustomer.id);
|
||||
|
||||
expect(result.visits).toHaveLength(2);
|
||||
result.visits.forEach(visit => {
|
||||
expect(visit.customerId).toBe(testCustomer.id);
|
||||
});
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
const result = await getMaintenanceVisits('', 1, 1);
|
||||
|
||||
expect(result.visits).toHaveLength(1);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMaintenanceVisitById', () => {
|
||||
it('should get a maintenance visit by ID', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تغيير إطارات',
|
||||
description: 'تغيير الإطارات الأمامية',
|
||||
cost: 800.00,
|
||||
kilometers: 55000,
|
||||
nextVisitDelay: 4,
|
||||
});
|
||||
|
||||
const visit = await getMaintenanceVisitById(createdVisit.id);
|
||||
|
||||
expect(visit).toBeDefined();
|
||||
expect(visit?.id).toBe(createdVisit.id);
|
||||
expect(visit?.vehicle).toBeDefined();
|
||||
expect(visit?.customer).toBeDefined();
|
||||
expect(visit?.income).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return null for non-existent visit', async () => {
|
||||
const visit = await getMaintenanceVisitById(99999);
|
||||
expect(visit).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateMaintenanceVisit', () => {
|
||||
it('should update a maintenance visit successfully', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'فحص دوري',
|
||||
description: 'فحص دوري أولي',
|
||||
cost: 200.00,
|
||||
kilometers: 48000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
const updateData = {
|
||||
maintenanceType: 'فحص شامل',
|
||||
description: 'فحص شامل محدث',
|
||||
cost: 250.00,
|
||||
paymentStatus: 'paid',
|
||||
kilometers: 48500,
|
||||
nextVisitDelay: 2,
|
||||
};
|
||||
|
||||
const updatedVisit = await updateMaintenanceVisit(createdVisit.id, updateData);
|
||||
|
||||
expect(updatedVisit.maintenanceType).toBe('فحص شامل');
|
||||
expect(updatedVisit.description).toBe('فحص شامل محدث');
|
||||
expect(updatedVisit.cost).toBe(250.00);
|
||||
expect(updatedVisit.paymentStatus).toBe('paid');
|
||||
expect(updatedVisit.kilometers).toBe(48500);
|
||||
expect(updatedVisit.nextVisitDelay).toBe(2);
|
||||
|
||||
// Check that income was updated
|
||||
const income = await prisma.income.findFirst({
|
||||
where: { maintenanceVisitId: createdVisit.id },
|
||||
});
|
||||
expect(income?.amount).toBe(250.00);
|
||||
});
|
||||
|
||||
it('should update vehicle dates when visit date changes', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'صيانة عامة',
|
||||
description: 'صيانة عامة',
|
||||
cost: 300.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
const newVisitDate = new Date();
|
||||
newVisitDate.setDate(newVisitDate.getDate() - 5); // 5 days ago
|
||||
|
||||
await updateMaintenanceVisit(createdVisit.id, {
|
||||
visitDate: newVisitDate,
|
||||
nextVisitDelay: 4,
|
||||
});
|
||||
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
|
||||
expect(updatedVehicle?.lastVisitDate?.getTime()).toBe(newVisitDate.getTime());
|
||||
|
||||
// Check that suggested next visit date was recalculated
|
||||
const expectedNextDate = new Date(newVisitDate);
|
||||
expectedNextDate.setMonth(expectedNextDate.getMonth() + 4);
|
||||
|
||||
const actualNextDate = updatedVehicle!.suggestedNextVisitDate!;
|
||||
const timeDiff = Math.abs(actualNextDate.getTime() - expectedNextDate.getTime());
|
||||
const daysDiff = timeDiff / (1000 * 60 * 60 * 24);
|
||||
|
||||
expect(daysDiff).toBeLessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMaintenanceVisit', () => {
|
||||
it('should delete a maintenance visit successfully', async () => {
|
||||
const createdVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'تنظيف مكيف',
|
||||
description: 'تنظيف وصيانة المكيف',
|
||||
cost: 120.00,
|
||||
kilometers: 47000,
|
||||
nextVisitDelay: 2,
|
||||
});
|
||||
|
||||
await deleteMaintenanceVisit(createdVisit.id);
|
||||
|
||||
// Check that visit was deleted
|
||||
const deletedVisit = await prisma.maintenanceVisit.findUnique({
|
||||
where: { id: createdVisit.id },
|
||||
});
|
||||
expect(deletedVisit).toBeNull();
|
||||
|
||||
// Check that income was deleted (cascade)
|
||||
const income = await prisma.income.findFirst({
|
||||
where: { maintenanceVisitId: createdVisit.id },
|
||||
});
|
||||
expect(income).toBeNull();
|
||||
|
||||
// Check that vehicle dates were cleared
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
expect(updatedVehicle?.lastVisitDate).toBeNull();
|
||||
expect(updatedVehicle?.suggestedNextVisitDate).toBeNull();
|
||||
});
|
||||
|
||||
it('should update vehicle dates to most recent remaining visit after deletion', async () => {
|
||||
// Create two visits
|
||||
const firstVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت قديم',
|
||||
description: 'تغيير زيت قديم',
|
||||
cost: 100.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
visitDate: new Date('2023-01-01'),
|
||||
});
|
||||
|
||||
const secondVisit = await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت جديد',
|
||||
description: 'تغيير زيت جديد',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 2,
|
||||
visitDate: new Date('2023-02-01'),
|
||||
});
|
||||
|
||||
// Delete the more recent visit
|
||||
await deleteMaintenanceVisit(secondVisit.id);
|
||||
|
||||
// Check that vehicle dates were updated to the remaining visit
|
||||
const updatedVehicle = await prisma.vehicle.findUnique({
|
||||
where: { id: testVehicle.id },
|
||||
});
|
||||
|
||||
expect(updatedVehicle?.lastVisitDate?.getTime()).toBe(new Date('2023-01-01').getTime());
|
||||
|
||||
// Check that suggested next visit date was recalculated based on first visit
|
||||
const expectedNextDate = new Date('2023-01-01');
|
||||
expectedNextDate.setMonth(expectedNextDate.getMonth() + 3);
|
||||
|
||||
const actualNextDate = updatedVehicle!.suggestedNextVisitDate!;
|
||||
expect(actualNextDate.getTime()).toBe(expectedNextDate.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleMaintenanceHistory', () => {
|
||||
it('should get maintenance history for a vehicle', async () => {
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت أول',
|
||||
description: 'تغيير زيت أول',
|
||||
cost: 100.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'زيت ثاني',
|
||||
description: 'تغيير زيت ثاني',
|
||||
cost: 120.00,
|
||||
kilometers: 48000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
const history = await getVehicleMaintenanceHistory(testVehicle.id);
|
||||
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].vehicleId).toBe(testVehicle.id);
|
||||
expect(history[1].vehicleId).toBe(testVehicle.id);
|
||||
|
||||
// Should be ordered by visit date descending
|
||||
expect(new Date(history[0].visitDate).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(history[1].visitDate).getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCustomerMaintenanceHistory', () => {
|
||||
it('should get maintenance history for a customer', async () => {
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'صيانة أولى',
|
||||
description: 'صيانة أولى',
|
||||
cost: 200.00,
|
||||
kilometers: 45000,
|
||||
nextVisitDelay: 3,
|
||||
});
|
||||
|
||||
await createMaintenanceVisit({
|
||||
vehicleId: testVehicle.id,
|
||||
customerId: testCustomer.id,
|
||||
maintenanceType: 'صيانة ثانية',
|
||||
description: 'صيانة ثانية',
|
||||
cost: 250.00,
|
||||
kilometers: 48000,
|
||||
nextVisitDelay: 2,
|
||||
});
|
||||
|
||||
const history = await getCustomerMaintenanceHistory(testCustomer.id);
|
||||
|
||||
expect(history).toHaveLength(2);
|
||||
expect(history[0].customerId).toBe(testCustomer.id);
|
||||
expect(history[1].customerId).toBe(testCustomer.id);
|
||||
|
||||
// Should be ordered by visit date descending
|
||||
expect(new Date(history[0].visitDate).getTime()).toBeGreaterThanOrEqual(
|
||||
new Date(history[1].visitDate).getTime()
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
425
app/lib/__tests__/maintenance-visit-validation.test.ts
Normal file
425
app/lib/__tests__/maintenance-visit-validation.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateMaintenanceVisit } from '../validation';
|
||||
|
||||
describe('Maintenance Visit Validation', () => {
|
||||
describe('validateMaintenanceVisit', () => {
|
||||
it('should validate a complete maintenance visit successfully', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك والفلتر',
|
||||
cost: 150.50,
|
||||
paymentStatus: 'paid',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should skip validation for missing vehicleId (partial validation)', () => {
|
||||
const partialData = {
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject invalid vehicleId', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 0,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.vehicleId).toBe('المركبة مطلوبة');
|
||||
});
|
||||
|
||||
it('should skip validation for missing customerId (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject invalid customerId', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: -1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.customerId).toBe('العميل مطلوب');
|
||||
});
|
||||
|
||||
it('should skip validation for missing maintenanceType (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject empty maintenanceType', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: ' ',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
|
||||
});
|
||||
|
||||
it('should skip validation for missing description (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject empty description', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: '',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.description).toBe('وصف الصيانة مطلوب');
|
||||
});
|
||||
|
||||
it('should reject description that is too long', () => {
|
||||
const longDescription = 'أ'.repeat(501); // 501 characters
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: longDescription,
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.description).toBe('الوصف يجب أن يكون أقل من 500 حرف');
|
||||
});
|
||||
|
||||
it('should skip validation for missing cost (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject negative cost', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: -10.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
|
||||
});
|
||||
|
||||
it('should reject cost that is too high', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 1000000.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
|
||||
});
|
||||
|
||||
it('should validate payment status', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
paymentStatus: 'invalid_status',
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.paymentStatus).toBe('حالة الدفع غير صحيحة');
|
||||
});
|
||||
|
||||
it('should accept valid payment statuses', () => {
|
||||
const validStatuses = ['pending', 'paid', 'partial', 'cancelled'];
|
||||
|
||||
validStatuses.forEach(status => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
paymentStatus: status,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip validation for missing kilometers (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should reject negative kilometers', () => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: -1000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.kilometers).toBe('عدد الكيلومترات يجب أن يكون رقم موجب');
|
||||
});
|
||||
|
||||
it('should accept zero kilometers', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 0,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should skip validation for missing nextVisitDelay (partial validation)', () => {
|
||||
const partialData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should accept valid nextVisitDelay values', () => {
|
||||
const validDelays = [1, 2, 3, 4];
|
||||
|
||||
validDelays.forEach(delay => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: delay,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid nextVisitDelay values', () => {
|
||||
const invalidDelays = [0, 5, 6, -1];
|
||||
|
||||
invalidDelays.forEach(delay => {
|
||||
const invalidData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.00,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: delay,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(invalidData);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.nextVisitDelay).toBe('فترة الزيارة التالية يجب أن تكون 1، 2، 3، أو 4 أشهر');
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle partial validation for updates', () => {
|
||||
const partialData = {
|
||||
maintenanceType: 'فحص دوري',
|
||||
cost: 200.00,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate partial data with errors', () => {
|
||||
const partialData = {
|
||||
maintenanceType: '',
|
||||
cost: -50.00,
|
||||
paymentStatus: 'invalid',
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(partialData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.maintenanceType).toBe('نوع الصيانة مطلوب');
|
||||
expect(result.errors.cost).toBe('التكلفة يجب أن تكون بين 0 و 999999.99');
|
||||
expect(result.errors.paymentStatus).toBe('حالة الدفع غير صحيحة');
|
||||
});
|
||||
|
||||
it('should accept zero cost', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'فحص مجاني',
|
||||
description: 'فحص مجاني للعميل',
|
||||
cost: 0,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept decimal cost values', () => {
|
||||
const validData = {
|
||||
vehicleId: 1,
|
||||
customerId: 1,
|
||||
maintenanceType: 'تغيير زيت',
|
||||
description: 'تغيير زيت المحرك',
|
||||
cost: 150.75,
|
||||
kilometers: 50000,
|
||||
nextVisitDelay: 3,
|
||||
};
|
||||
|
||||
const result = validateMaintenanceVisit(validData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
app/lib/__tests__/route-protection-integration.test.ts
Normal file
107
app/lib/__tests__/route-protection-integration.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { checkPermission, createUnauthorizedResponse } from "../auth-middleware.server";
|
||||
import { AUTH_LEVELS, USER_STATUS } from "~/types/auth";
|
||||
import type { SafeUser } from "~/types/auth";
|
||||
|
||||
// Mock user data for testing permissions
|
||||
const mockSuperAdmin: SafeUser = {
|
||||
id: 1,
|
||||
name: "Super Admin",
|
||||
username: "superadmin",
|
||||
email: "super@example.com",
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
};
|
||||
|
||||
const mockAdmin: SafeUser = {
|
||||
id: 2,
|
||||
name: "Admin User",
|
||||
username: "admin",
|
||||
email: "admin@example.com",
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.ADMIN,
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
};
|
||||
|
||||
const mockUser: SafeUser = {
|
||||
id: 3,
|
||||
name: "Regular User",
|
||||
username: "user",
|
||||
email: "user@example.com",
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
createdDate: new Date(),
|
||||
editDate: new Date(),
|
||||
};
|
||||
|
||||
describe("Route Protection Integration Tests", () => {
|
||||
describe("checkPermission", () => {
|
||||
it("should correctly check view_all_users permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "view_all_users")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "view_all_users")).toBe(false);
|
||||
expect(checkPermission(mockUser, "view_all_users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly check create_users permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "create_users")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "create_users")).toBe(true);
|
||||
expect(checkPermission(mockUser, "create_users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly check manage_finances permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "manage_finances")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "manage_finances")).toBe(true);
|
||||
expect(checkPermission(mockUser, "manage_finances")).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly check view_reports permission", () => {
|
||||
expect(checkPermission(mockSuperAdmin, "view_reports")).toBe(true);
|
||||
expect(checkPermission(mockAdmin, "view_reports")).toBe(true);
|
||||
expect(checkPermission(mockUser, "view_reports")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for unknown permission", () => {
|
||||
expect(checkPermission(mockUser, "unknown_permission" as any)).toBe(false);
|
||||
expect(checkPermission(mockAdmin, "unknown_permission" as any)).toBe(false);
|
||||
expect(checkPermission(mockSuperAdmin, "unknown_permission" as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createUnauthorizedResponse", () => {
|
||||
it("should create response with default message", () => {
|
||||
const response = createUnauthorizedResponse();
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
|
||||
});
|
||||
|
||||
it("should create response with custom message", () => {
|
||||
const customMessage = "Custom error message";
|
||||
const response = createUnauthorizedResponse(customMessage);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=utf-8");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Auth Level Hierarchy", () => {
|
||||
it("should have correct auth level values", () => {
|
||||
expect(AUTH_LEVELS.SUPERADMIN).toBe(1);
|
||||
expect(AUTH_LEVELS.ADMIN).toBe(2);
|
||||
expect(AUTH_LEVELS.USER).toBe(3);
|
||||
});
|
||||
|
||||
it("should enforce correct hierarchy (lower number = higher privilege)", () => {
|
||||
expect(AUTH_LEVELS.SUPERADMIN < AUTH_LEVELS.ADMIN).toBe(true);
|
||||
expect(AUTH_LEVELS.ADMIN < AUTH_LEVELS.USER).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("User Status", () => {
|
||||
it("should have correct status values", () => {
|
||||
expect(USER_STATUS.ACTIVE).toBe("active");
|
||||
expect(USER_STATUS.INACTIVE).toBe("inactive");
|
||||
});
|
||||
});
|
||||
});
|
||||
299
app/lib/__tests__/user-management.test.ts
Normal file
299
app/lib/__tests__/user-management.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { prisma } from '../db.server';
|
||||
import { getUsers, createUser, updateUser, deleteUser, toggleUserStatus } from '../user-management.server';
|
||||
import { hashPassword } from '../auth.server';
|
||||
import { AUTH_LEVELS, USER_STATUS } from '~/types/auth';
|
||||
|
||||
describe('User Management', () => {
|
||||
beforeEach(async () => {
|
||||
// Clean up test data
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
email: {
|
||||
contains: 'test'
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the main superadmin exists for login functionality
|
||||
const existingSuperadmin = await prisma.user.findUnique({
|
||||
where: { username: 'superadmin' }
|
||||
});
|
||||
|
||||
if (!existingSuperadmin) {
|
||||
const hashedPassword = await hashPassword('admin123');
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: 'Super Administrator',
|
||||
username: 'superadmin',
|
||||
email: 'admin@carmaintenance.com',
|
||||
password: hashedPassword,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Clean up test data but preserve the main superadmin
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
AND: [
|
||||
{
|
||||
email: {
|
||||
contains: 'test'
|
||||
}
|
||||
},
|
||||
{
|
||||
username: {
|
||||
not: 'superadmin'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUsers', () => {
|
||||
it('should return users with role-based filtering', async () => {
|
||||
// Create test users with properly hashed passwords
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const superadmin = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Test Superadmin',
|
||||
username: 'testsuperadmin',
|
||||
email: 'testsuperadmin@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Test Admin',
|
||||
username: 'testadmin',
|
||||
email: 'testadmin@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.ADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
// Superadmin should see all users
|
||||
const superadminResult = await getUsers(AUTH_LEVELS.SUPERADMIN);
|
||||
expect(superadminResult.users.length).toBeGreaterThanOrEqual(2);
|
||||
|
||||
// Admin should not see superadmin users
|
||||
const adminResult = await getUsers(AUTH_LEVELS.ADMIN);
|
||||
const adminVisibleUsers = adminResult.users.filter(u => u.authLevel === AUTH_LEVELS.SUPERADMIN);
|
||||
expect(adminVisibleUsers.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should support search functionality', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
name: 'John Test User',
|
||||
username: 'johntestuser',
|
||||
email: 'johntest@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await getUsers(AUTH_LEVELS.SUPERADMIN, 'John');
|
||||
expect(result.users.some(u => u.name.includes('John'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createUser', () => {
|
||||
it('should create a new user successfully', async () => {
|
||||
const userData = {
|
||||
name: 'New Test User',
|
||||
username: 'newtestuser',
|
||||
email: 'newtest@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
const result = await createUser(userData, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.user?.name).toBe(userData.name);
|
||||
expect(result.user?.username).toBe(userData.username);
|
||||
expect(result.user?.email).toBe(userData.email);
|
||||
});
|
||||
|
||||
it('should prevent admin from creating superadmin', async () => {
|
||||
const userData = {
|
||||
name: 'Test Superadmin',
|
||||
username: 'testsuperadmin2',
|
||||
email: 'testsuperadmin2@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
const result = await createUser(userData, AUTH_LEVELS.ADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن للمدير إنشاء حساب مدير عام');
|
||||
});
|
||||
|
||||
it('should prevent duplicate username', async () => {
|
||||
const userData1 = {
|
||||
name: 'Test User 1',
|
||||
username: 'duplicatetest',
|
||||
email: 'test1@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
const userData2 = {
|
||||
name: 'Test User 2',
|
||||
username: 'duplicatetest', // Same username
|
||||
email: 'test2@example.com',
|
||||
password: 'password123',
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
};
|
||||
|
||||
await createUser(userData1, AUTH_LEVELS.SUPERADMIN);
|
||||
const result = await createUser(userData2, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('اسم المستخدم موجود بالفعل');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUser', () => {
|
||||
it('should update user successfully', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Original Name',
|
||||
username: 'originaluser' + Date.now(),
|
||||
email: 'original' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const updateData = {
|
||||
name: 'Updated Name',
|
||||
email: 'updated' + Date.now() + '@example.com',
|
||||
};
|
||||
|
||||
const result = await updateUser(user.id, updateData, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
if (!result.success) {
|
||||
console.log('Update failed:', result.error);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.name).toBe('Updated Name');
|
||||
expect(result.user?.email).toBe(updateData.email);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleUserStatus', () => {
|
||||
it('should toggle user status', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Test User',
|
||||
username: 'teststatususer' + Date.now(),
|
||||
email: 'teststatus' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await toggleUserStatus(user.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.user?.status).toBe(USER_STATUS.INACTIVE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUser', () => {
|
||||
it('should delete user successfully', async () => {
|
||||
const hashedPassword = await hashPassword('testpassword123');
|
||||
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Delete Test User',
|
||||
username: 'deletetestuser' + Date.now(),
|
||||
email: 'deletetest' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.USER,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await deleteUser(user.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
// Verify user is deleted
|
||||
const deletedUser = await prisma.user.findUnique({
|
||||
where: { id: user.id }
|
||||
});
|
||||
expect(deletedUser).toBeNull();
|
||||
});
|
||||
|
||||
it('should prevent deletion of last superadmin', async () => {
|
||||
// Delete any test superadmins but keep the main superadmin
|
||||
await prisma.user.deleteMany({
|
||||
where: {
|
||||
AND: [
|
||||
{ authLevel: AUTH_LEVELS.SUPERADMIN },
|
||||
{ username: { not: 'superadmin' } }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
// Try to delete the main superadmin (should fail as it's the last one)
|
||||
const mainSuperadmin = await prisma.user.findUnique({
|
||||
where: { username: 'superadmin' }
|
||||
});
|
||||
|
||||
if (mainSuperadmin) {
|
||||
const result = await deleteUser(mainSuperadmin.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن حذف آخر مدير عام في النظام');
|
||||
} else {
|
||||
// If main superadmin doesn't exist, create one and test
|
||||
const hashedPassword = await hashPassword('admin123');
|
||||
const superadmin = await prisma.user.create({
|
||||
data: {
|
||||
name: 'Last Superadmin',
|
||||
username: 'lastsuperadmin' + Date.now(),
|
||||
email: 'lastsuperadmin' + Date.now() + '@example.com',
|
||||
password: hashedPassword,
|
||||
authLevel: AUTH_LEVELS.SUPERADMIN,
|
||||
status: USER_STATUS.ACTIVE,
|
||||
}
|
||||
});
|
||||
|
||||
const result = await deleteUser(superadmin.id, AUTH_LEVELS.SUPERADMIN);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن حذف آخر مدير عام في النظام');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
222
app/lib/__tests__/validation-utils.test.ts
Normal file
222
app/lib/__tests__/validation-utils.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateField,
|
||||
validateFields,
|
||||
validateEmail,
|
||||
validatePhone,
|
||||
validatePassword,
|
||||
validateUsername,
|
||||
validatePlateNumber,
|
||||
validateYear,
|
||||
validateCurrency,
|
||||
sanitizeString,
|
||||
sanitizeNumber,
|
||||
sanitizeInteger,
|
||||
sanitizeFormData,
|
||||
PATTERNS
|
||||
} from '../validation-utils';
|
||||
|
||||
describe('Validation Utils', () => {
|
||||
describe('validateField', () => {
|
||||
it('should validate required fields', () => {
|
||||
const result1 = validateField('', { required: true });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('هذا الحقل مطلوب');
|
||||
|
||||
const result2 = validateField('value', { required: true });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate email fields', () => {
|
||||
const result1 = validateField('invalid-email', { email: true });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('البريد الإلكتروني غير صحيح');
|
||||
|
||||
const result2 = validateField('test@example.com', { email: true });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate phone fields', () => {
|
||||
const result1 = validateField('invalid-phone', { phone: true });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('رقم الهاتف غير صحيح');
|
||||
|
||||
const result2 = validateField('+966501234567', { phone: true });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate length constraints', () => {
|
||||
const result1 = validateField('ab', { minLength: 3 });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('يجب أن يكون على الأقل 3 أحرف');
|
||||
|
||||
const result2 = validateField('a'.repeat(101), { maxLength: 100 });
|
||||
expect(result2.isValid).toBe(false);
|
||||
expect(result2.error).toBe('يجب أن يكون أقل من 100 حرف');
|
||||
});
|
||||
|
||||
it('should validate numeric constraints', () => {
|
||||
const result1 = validateField(5, { min: 10 });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('يجب أن يكون على الأقل 10');
|
||||
|
||||
const result2 = validateField(15, { max: 10 });
|
||||
expect(result2.isValid).toBe(false);
|
||||
expect(result2.error).toBe('يجب أن يكون أقل من 10');
|
||||
});
|
||||
|
||||
it('should validate custom rules', () => {
|
||||
const customRule = (value: any) => {
|
||||
return value === 'forbidden' ? 'هذه القيمة غير مسموحة' : null;
|
||||
};
|
||||
|
||||
const result1 = validateField('forbidden', { custom: customRule });
|
||||
expect(result1.isValid).toBe(false);
|
||||
expect(result1.error).toBe('هذه القيمة غير مسموحة');
|
||||
|
||||
const result2 = validateField('allowed', { custom: customRule });
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateFields', () => {
|
||||
it('should validate multiple fields', () => {
|
||||
const data = {
|
||||
name: '',
|
||||
email: 'invalid-email',
|
||||
age: 5,
|
||||
};
|
||||
|
||||
const rules = {
|
||||
name: { required: true },
|
||||
email: { email: true },
|
||||
age: { min: 18 },
|
||||
};
|
||||
|
||||
const result = validateFields(data, rules);
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.name).toBe('هذا الحقل مطلوب');
|
||||
expect(result.errors.email).toBe('البريد الإلكتروني غير صحيح');
|
||||
expect(result.errors.age).toBe('يجب أن يكون على الأقل 18');
|
||||
});
|
||||
});
|
||||
|
||||
describe('specific validation functions', () => {
|
||||
it('should validate email', () => {
|
||||
const result1 = validateEmail('');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateEmail('test@example.com');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate phone', () => {
|
||||
const result1 = validatePhone('invalid');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validatePhone('+966501234567');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate password', () => {
|
||||
const result1 = validatePassword('123');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validatePassword('password123');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate username', () => {
|
||||
const result1 = validateUsername('ab');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateUsername('user123');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate plate number', () => {
|
||||
const result1 = validatePlateNumber('');
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validatePlateNumber('ABC-1234');
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate year', () => {
|
||||
const result1 = validateYear(1800);
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateYear(2023);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should validate currency', () => {
|
||||
const result1 = validateCurrency(-10);
|
||||
expect(result1.isValid).toBe(false);
|
||||
|
||||
const result2 = validateCurrency(100.50);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitization functions', () => {
|
||||
it('should sanitize strings', () => {
|
||||
expect(sanitizeString(' hello world ')).toBe('hello world');
|
||||
expect(sanitizeString('\t\n test \n\t')).toBe('test');
|
||||
});
|
||||
|
||||
it('should sanitize numbers', () => {
|
||||
expect(sanitizeNumber('123.45')).toBe(123.45);
|
||||
expect(sanitizeNumber('invalid')).toBe(null);
|
||||
expect(sanitizeNumber(456)).toBe(456);
|
||||
});
|
||||
|
||||
it('should sanitize integers', () => {
|
||||
expect(sanitizeInteger('123')).toBe(123);
|
||||
expect(sanitizeInteger('123.45')).toBe(123);
|
||||
expect(sanitizeInteger('invalid')).toBe(null);
|
||||
});
|
||||
|
||||
it('should sanitize form data', () => {
|
||||
const data = {
|
||||
name: ' John Doe ',
|
||||
age: 25,
|
||||
description: '\t\n Some text \n\t',
|
||||
};
|
||||
|
||||
const sanitized = sanitizeFormData(data);
|
||||
expect(sanitized.name).toBe('John Doe');
|
||||
expect(sanitized.age).toBe(25);
|
||||
expect(sanitized.description).toBe('Some text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('patterns', () => {
|
||||
it('should match email pattern', () => {
|
||||
expect(PATTERNS.email.test('test@example.com')).toBe(true);
|
||||
expect(PATTERNS.email.test('invalid-email')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match phone pattern', () => {
|
||||
expect(PATTERNS.phone.test('+966501234567')).toBe(true);
|
||||
expect(PATTERNS.phone.test('0501234567')).toBe(true);
|
||||
expect(PATTERNS.phone.test('invalid-phone')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match username pattern', () => {
|
||||
expect(PATTERNS.username.test('user123')).toBe(true);
|
||||
expect(PATTERNS.username.test('user_name')).toBe(true);
|
||||
expect(PATTERNS.username.test('user-name')).toBe(false);
|
||||
expect(PATTERNS.username.test('user name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should match numeric patterns', () => {
|
||||
expect(PATTERNS.numeric.test('12345')).toBe(true);
|
||||
expect(PATTERNS.numeric.test('123abc')).toBe(false);
|
||||
|
||||
expect(PATTERNS.decimal.test('123.45')).toBe(true);
|
||||
expect(PATTERNS.decimal.test('123')).toBe(true);
|
||||
expect(PATTERNS.decimal.test('123.45.67')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
443
app/lib/__tests__/vehicle-management.test.ts
Normal file
443
app/lib/__tests__/vehicle-management.test.ts
Normal file
@@ -0,0 +1,443 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import {
|
||||
createVehicle,
|
||||
updateVehicle,
|
||||
deleteVehicle,
|
||||
getVehicles,
|
||||
getVehicleById,
|
||||
getVehiclesForSelect,
|
||||
searchVehicles,
|
||||
getVehicleStats
|
||||
} from '../vehicle-management.server';
|
||||
import { prisma } from '../db.server';
|
||||
|
||||
// Mock Prisma
|
||||
vi.mock('../db.server', () => ({
|
||||
prisma: {
|
||||
vehicle: {
|
||||
create: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
customer: {
|
||||
findUnique: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Vehicle Management', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('createVehicle', () => {
|
||||
const validVehicleData = {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: 1,
|
||||
};
|
||||
|
||||
it('should create a vehicle successfully', async () => {
|
||||
const mockVehicle = { id: 1, ...validVehicleData };
|
||||
const mockCustomer = { id: 1, name: 'أحمد محمد' };
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
(prisma.customer.findUnique as any).mockResolvedValue(mockCustomer);
|
||||
(prisma.vehicle.create as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.vehicle).toEqual(mockVehicle);
|
||||
expect(prisma.vehicle.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
plateNumber: 'ABC-123',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
ownerId: 1,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if plate number already exists', async () => {
|
||||
const existingVehicle = { id: 2, plateNumber: 'ABC-123' };
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('رقم اللوحة موجود بالفعل');
|
||||
expect(prisma.vehicle.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if owner does not exist', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
(prisma.customer.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('المالك غير موجود');
|
||||
expect(prisma.vehicle.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle database errors', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
(prisma.customer.findUnique as any).mockResolvedValue({ id: 1 });
|
||||
(prisma.vehicle.create as any).mockRejectedValue(new Error('Database error'));
|
||||
|
||||
const result = await createVehicle(validVehicleData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('حدث خطأ أثناء إنشاء المركبة');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateVehicle', () => {
|
||||
const updateData = {
|
||||
plateNumber: 'XYZ-789',
|
||||
manufacturer: 'هوندا',
|
||||
model: 'أكورد',
|
||||
};
|
||||
|
||||
it('should update a vehicle successfully', async () => {
|
||||
const existingVehicle = { id: 1, plateNumber: 'ABC-123', ownerId: 1 };
|
||||
const updatedVehicle = { ...existingVehicle, ...updateData };
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
(prisma.vehicle.findFirst as any).mockResolvedValue(null);
|
||||
(prisma.vehicle.update as any).mockResolvedValue(updatedVehicle);
|
||||
|
||||
const result = await updateVehicle(1, updateData);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.vehicle).toEqual(updatedVehicle);
|
||||
expect(prisma.vehicle.update).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
data: expect.objectContaining({
|
||||
plateNumber: 'XYZ-789',
|
||||
manufacturer: 'هوندا',
|
||||
model: 'أكورد',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if vehicle does not exist', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await updateVehicle(999, updateData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('المركبة غير موجودة');
|
||||
expect(prisma.vehicle.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if new plate number conflicts', async () => {
|
||||
const existingVehicle = { id: 1, plateNumber: 'ABC-123' };
|
||||
const conflictVehicle = { id: 2, plateNumber: 'XYZ-789' };
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
(prisma.vehicle.findFirst as any).mockResolvedValue(conflictVehicle);
|
||||
|
||||
const result = await updateVehicle(1, updateData);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('رقم اللوحة موجود بالفعل');
|
||||
expect(prisma.vehicle.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteVehicle', () => {
|
||||
it('should delete a vehicle successfully', async () => {
|
||||
const existingVehicle = {
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
maintenanceVisits: []
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
(prisma.vehicle.delete as any).mockResolvedValue(existingVehicle);
|
||||
|
||||
const result = await deleteVehicle(1);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(prisma.vehicle.delete).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should fail if vehicle does not exist', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await deleteVehicle(999);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe('المركبة غير موجودة');
|
||||
expect(prisma.vehicle.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fail if vehicle has maintenance visits', async () => {
|
||||
const existingVehicle = {
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
maintenanceVisits: [{ id: 1 }, { id: 2 }]
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(existingVehicle);
|
||||
|
||||
const result = await deleteVehicle(1);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('لا يمكن حذف المركبة لأنها تحتوي على 2 زيارة صيانة');
|
||||
expect(prisma.vehicle.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicles', () => {
|
||||
it('should return vehicles with pagination', async () => {
|
||||
const mockVehicles = [
|
||||
{
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
manufacturer: 'تويوتا',
|
||||
owner: { id: 1, name: 'أحمد محمد' }
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
plateNumber: 'XYZ-789',
|
||||
manufacturer: 'هوندا',
|
||||
owner: { id: 2, name: 'محمد علي' }
|
||||
}
|
||||
];
|
||||
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue(mockVehicles);
|
||||
(prisma.vehicle.count as any).mockResolvedValue(2);
|
||||
|
||||
const result = await getVehicles('', 1, 10);
|
||||
|
||||
expect(result.vehicles).toEqual(mockVehicles);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.totalPages).toBe(1);
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: {},
|
||||
include: {
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
email: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by search query', async () => {
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue([]);
|
||||
(prisma.vehicle.count as any).mockResolvedValue(0);
|
||||
|
||||
await getVehicles('تويوتا', 1, 10);
|
||||
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ plateNumber: { contains: 'تويوتا' } },
|
||||
{ manufacturer: { contains: 'تويوتا' } },
|
||||
{ model: { contains: 'تويوتا' } },
|
||||
{ bodyType: { contains: 'تويوتا' } },
|
||||
{ owner: { name: { contains: 'تويوتا' } } },
|
||||
],
|
||||
},
|
||||
include: expect.any(Object),
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter by owner ID', async () => {
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue([]);
|
||||
(prisma.vehicle.count as any).mockResolvedValue(0);
|
||||
|
||||
await getVehicles('', 1, 10, 5);
|
||||
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: { ownerId: 5 },
|
||||
include: expect.any(Object),
|
||||
orderBy: { createdDate: 'desc' },
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleById', () => {
|
||||
it('should return vehicle with full relationships', async () => {
|
||||
const mockVehicle = {
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
owner: { id: 1, name: 'أحمد محمد' },
|
||||
maintenanceVisits: [
|
||||
{ id: 1, visitDate: new Date(), cost: 100 }
|
||||
]
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await getVehicleById(1);
|
||||
|
||||
expect(result).toEqual(mockVehicle);
|
||||
expect(prisma.vehicle.findUnique).toHaveBeenCalledWith({
|
||||
where: { id: 1 },
|
||||
include: {
|
||||
owner: true,
|
||||
maintenanceVisits: {
|
||||
include: {
|
||||
customer: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
phone: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { visitDate: 'desc' },
|
||||
take: 10,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if vehicle not found', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await getVehicleById(999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVehicleStats', () => {
|
||||
it('should calculate vehicle statistics', async () => {
|
||||
const mockVehicle = {
|
||||
id: 1,
|
||||
suggestedNextVisitDate: new Date('2024-06-01'),
|
||||
maintenanceVisits: [
|
||||
{ cost: 150, visitDate: new Date('2024-03-01') }, // Most recent first (desc order)
|
||||
{ cost: 200, visitDate: new Date('2024-02-01') },
|
||||
{ cost: 100, visitDate: new Date('2024-01-01') },
|
||||
]
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await getVehicleStats(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalVisits: 3,
|
||||
totalSpent: 450,
|
||||
lastVisitDate: new Date('2024-03-01'),
|
||||
nextSuggestedVisitDate: new Date('2024-06-01'),
|
||||
averageVisitCost: 150,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return null if vehicle not found', async () => {
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(null);
|
||||
|
||||
const result = await getVehicleStats(999);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle vehicle with no visits', async () => {
|
||||
const mockVehicle = {
|
||||
id: 1,
|
||||
suggestedNextVisitDate: null,
|
||||
maintenanceVisits: []
|
||||
};
|
||||
|
||||
(prisma.vehicle.findUnique as any).mockResolvedValue(mockVehicle);
|
||||
|
||||
const result = await getVehicleStats(1);
|
||||
|
||||
expect(result).toEqual({
|
||||
totalVisits: 0,
|
||||
totalSpent: 0,
|
||||
lastVisitDate: undefined,
|
||||
nextSuggestedVisitDate: undefined,
|
||||
averageVisitCost: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchVehicles', () => {
|
||||
it('should search vehicles by query', async () => {
|
||||
const mockVehicles = [
|
||||
{
|
||||
id: 1,
|
||||
plateNumber: 'ABC-123',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
owner: { id: 1, name: 'أحمد محمد' }
|
||||
}
|
||||
];
|
||||
|
||||
(prisma.vehicle.findMany as any).mockResolvedValue(mockVehicles);
|
||||
|
||||
const result = await searchVehicles('تويوتا');
|
||||
|
||||
expect(result).toEqual(mockVehicles);
|
||||
expect(prisma.vehicle.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
OR: [
|
||||
{ plateNumber: { contains: 'تويوتا' } },
|
||||
{ manufacturer: { contains: 'تويوتا' } },
|
||||
{ model: { contains: 'تويوتا' } },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
plateNumber: true,
|
||||
manufacturer: true,
|
||||
model: true,
|
||||
year: true,
|
||||
owner: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { plateNumber: 'asc' },
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array for short queries', async () => {
|
||||
const result = await searchVehicles('a');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.vehicle.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
379
app/lib/__tests__/vehicle-validation.test.ts
Normal file
379
app/lib/__tests__/vehicle-validation.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { validateVehicle } from '../validation';
|
||||
|
||||
describe('Vehicle Validation', () => {
|
||||
describe('validateVehicle', () => {
|
||||
const validVehicleData = {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: 1,
|
||||
};
|
||||
|
||||
it('should validate a complete valid vehicle', () => {
|
||||
const result = validateVehicle(validVehicleData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate a minimal valid vehicle', () => {
|
||||
const minimalData = {
|
||||
plateNumber: 'ABC-123',
|
||||
bodyType: 'سيدان',
|
||||
manufacturer: 'تويوتا',
|
||||
model: 'كامري',
|
||||
year: 2020,
|
||||
transmission: 'Automatic',
|
||||
fuel: 'Gasoline',
|
||||
useType: 'personal',
|
||||
ownerId: 1,
|
||||
};
|
||||
|
||||
const result = validateVehicle(minimalData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
describe('plateNumber validation', () => {
|
||||
it('should require plate number', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, plateNumber: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
|
||||
});
|
||||
|
||||
it('should require plate number to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, plateNumber: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.plateNumber).toBe('رقم اللوحة مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bodyType validation', () => {
|
||||
it('should require body type', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, bodyType: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
|
||||
});
|
||||
|
||||
it('should require body type to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, bodyType: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.bodyType).toBe('نوع الهيكل مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('manufacturer validation', () => {
|
||||
it('should require manufacturer', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, manufacturer: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
|
||||
});
|
||||
|
||||
it('should require manufacturer to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, manufacturer: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.manufacturer).toBe('الشركة المصنعة مطلوبة');
|
||||
});
|
||||
});
|
||||
|
||||
describe('model validation', () => {
|
||||
it('should require model', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, model: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.model).toBe('الموديل مطلوب');
|
||||
});
|
||||
|
||||
it('should require model to not be just whitespace', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, model: ' ' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.model).toBe('الموديل مطلوب');
|
||||
});
|
||||
});
|
||||
|
||||
describe('year validation', () => {
|
||||
it('should not validate undefined year (partial validation)', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, year: undefined as any });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject year below minimum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, year: 1989 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.year).toContain('السنة يجب أن تكون بين 1990');
|
||||
});
|
||||
|
||||
it('should reject year above maximum', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const result = validateVehicle({ ...validVehicleData, year: currentYear + 2 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.year).toContain('السنة يجب أن تكون بين');
|
||||
});
|
||||
|
||||
it('should accept current year', () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const result = validateVehicle({ ...validVehicleData, year: currentYear });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept next year', () => {
|
||||
const nextYear = new Date().getFullYear() + 1;
|
||||
const result = validateVehicle({ ...validVehicleData, year: nextYear });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transmission validation', () => {
|
||||
it('should require transmission', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, transmission: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.transmission).toBe('نوع ناقل الحركة غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid transmission types', () => {
|
||||
const automaticResult = validateVehicle({ ...validVehicleData, transmission: 'Automatic' });
|
||||
const manualResult = validateVehicle({ ...validVehicleData, transmission: 'Manual' });
|
||||
|
||||
expect(automaticResult.isValid).toBe(true);
|
||||
expect(manualResult.isValid).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject invalid transmission types', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, transmission: 'CVT' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.transmission).toBe('نوع ناقل الحركة غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fuel validation', () => {
|
||||
it('should require fuel type', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, fuel: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.fuel).toBe('نوع الوقود غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid fuel types', () => {
|
||||
const validFuels = ['Gasoline', 'Diesel', 'Hybrid', 'Mild Hybrid', 'Electric'];
|
||||
|
||||
validFuels.forEach(fuel => {
|
||||
const result = validateVehicle({ ...validVehicleData, fuel });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid fuel types', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, fuel: 'Nuclear' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.fuel).toBe('نوع الوقود غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cylinders validation', () => {
|
||||
it('should accept valid cylinder counts', () => {
|
||||
const validCylinders = [1, 2, 3, 4, 6, 8, 10, 12];
|
||||
|
||||
validCylinders.forEach(cylinders => {
|
||||
const result = validateVehicle({ ...validVehicleData, cylinders });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject cylinder count below minimum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, cylinders: 0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cylinders).toContain('عدد الأسطوانات يجب أن يكون بين 1');
|
||||
});
|
||||
|
||||
it('should reject cylinder count above maximum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, cylinders: 16 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.cylinders).toContain('عدد الأسطوانات يجب أن يكون بين 1 و 12');
|
||||
});
|
||||
|
||||
it('should accept null/undefined cylinders', () => {
|
||||
const result1 = validateVehicle({ ...validVehicleData, cylinders: undefined });
|
||||
const result2 = validateVehicle({ ...validVehicleData, cylinders: null as any });
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('engineDisplacement validation', () => {
|
||||
it('should accept valid engine displacements', () => {
|
||||
const validDisplacements = [1.0, 1.5, 2.0, 2.5, 3.0, 4.0, 5.0];
|
||||
|
||||
validDisplacements.forEach(engineDisplacement => {
|
||||
const result = validateVehicle({ ...validVehicleData, engineDisplacement });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject engine displacement at zero', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, engineDisplacement: 0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.engineDisplacement).toContain('سعة المحرك يجب أن تكون بين 0.1');
|
||||
});
|
||||
|
||||
it('should reject engine displacement above maximum', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, engineDisplacement: 15.0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.engineDisplacement).toContain('سعة المحرك يجب أن تكون بين 0.1 و 10');
|
||||
});
|
||||
|
||||
it('should accept null/undefined engine displacement', () => {
|
||||
const result1 = validateVehicle({ ...validVehicleData, engineDisplacement: undefined });
|
||||
const result2 = validateVehicle({ ...validVehicleData, engineDisplacement: null as any });
|
||||
|
||||
expect(result1.isValid).toBe(true);
|
||||
expect(result2.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useType validation', () => {
|
||||
it('should require use type', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, useType: '' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.useType).toBe('نوع الاستخدام غير صحيح');
|
||||
});
|
||||
|
||||
it('should accept valid use types', () => {
|
||||
const validUseTypes = ['personal', 'taxi', 'apps', 'loading', 'travel'];
|
||||
|
||||
validUseTypes.forEach(useType => {
|
||||
const result = validateVehicle({ ...validVehicleData, useType });
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject invalid use types', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, useType: 'military' });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.useType).toBe('نوع الاستخدام غير صحيح');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ownerId validation', () => {
|
||||
it('should not validate undefined owner ID (partial validation)', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: undefined as any });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(result.errors.ownerId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should reject zero owner ID', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: 0 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
|
||||
});
|
||||
|
||||
it('should reject negative owner ID', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: -1 });
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(result.errors.ownerId).toBe('مالك المركبة مطلوب');
|
||||
});
|
||||
|
||||
it('should accept positive owner ID', () => {
|
||||
const result = validateVehicle({ ...validVehicleData, ownerId: 5 });
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple validation errors', () => {
|
||||
it('should return all validation errors', () => {
|
||||
const invalidData = {
|
||||
plateNumber: '',
|
||||
bodyType: '',
|
||||
manufacturer: '',
|
||||
model: '',
|
||||
year: 1980,
|
||||
transmission: 'Invalid',
|
||||
fuel: 'Invalid',
|
||||
cylinders: 20,
|
||||
engineDisplacement: 15.0,
|
||||
useType: 'Invalid',
|
||||
ownerId: 0,
|
||||
};
|
||||
|
||||
const result = validateVehicle(invalidData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(Object.keys(result.errors)).toContain('plateNumber');
|
||||
expect(Object.keys(result.errors)).toContain('bodyType');
|
||||
expect(Object.keys(result.errors)).toContain('manufacturer');
|
||||
expect(Object.keys(result.errors)).toContain('model');
|
||||
expect(Object.keys(result.errors)).toContain('year');
|
||||
expect(Object.keys(result.errors)).toContain('transmission');
|
||||
expect(Object.keys(result.errors)).toContain('fuel');
|
||||
expect(Object.keys(result.errors)).toContain('cylinders');
|
||||
expect(Object.keys(result.errors)).toContain('engineDisplacement');
|
||||
expect(Object.keys(result.errors)).toContain('useType');
|
||||
expect(Object.keys(result.errors)).toContain('ownerId');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial validation', () => {
|
||||
it('should validate only provided fields', () => {
|
||||
const partialData = {
|
||||
plateNumber: 'XYZ-789',
|
||||
year: 2021,
|
||||
};
|
||||
|
||||
const result = validateVehicle(partialData);
|
||||
|
||||
expect(result.isValid).toBe(true);
|
||||
expect(Object.keys(result.errors)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should validate only provided fields with errors', () => {
|
||||
const partialData = {
|
||||
plateNumber: '',
|
||||
year: 1980,
|
||||
cylinders: 20,
|
||||
};
|
||||
|
||||
const result = validateVehicle(partialData);
|
||||
|
||||
expect(result.isValid).toBe(false);
|
||||
expect(Object.keys(result.errors)).toContain('plateNumber');
|
||||
expect(Object.keys(result.errors)).toContain('year');
|
||||
expect(Object.keys(result.errors)).toContain('cylinders');
|
||||
expect(Object.keys(result.errors)).not.toContain('manufacturer');
|
||||
expect(Object.keys(result.errors)).not.toContain('model');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user