diff --git a/appointment-service/APPOINTMENT_SERVICE_DOCUMENTATION.md b/appointment-service/APPOINTMENT_SERVICE_DOCUMENTATION.md new file mode 100644 index 0000000..df26581 --- /dev/null +++ b/appointment-service/APPOINTMENT_SERVICE_DOCUMENTATION.md @@ -0,0 +1,846 @@ +# Appointment Service Documentation + +## Overview + +The **Appointment Service** is a sophisticated scheduling and appointment management microservice for the TechTorque-2025 platform. It handles appointment booking, availability checking, employee scheduling, time tracking, and calendar management for vehicle service operations. The service includes business hours management, holiday tracking, service bay allocation, and comprehensive status tracking throughout the appointment lifecycle. + +## Technology Stack + +- **Framework**: Spring Boot 3.5.6 +- **Language**: Java 17 +- **Database**: PostgreSQL +- **Security**: Spring Security with JWT Bearer Authentication +- **API Documentation**: OpenAPI 3.0 (Swagger) +- **Build Tool**: Maven + +## Service Configuration + +**Port**: 8083 +**API Documentation**: http://localhost:8083/swagger-ui/index.html +**Base URL**: http://localhost:8083/api + +## Core Features + +### 1. Appointment Booking System + +**Capabilities**: +- Check real-time availability based on service type and date +- Book appointments with automatic bay allocation +- Generate unique confirmation numbers +- Validate against business hours and holidays +- Prevent double-booking of service bays +- Support for special customer instructions + +### 2. Availability Management + +**Features**: +- Dynamic availability calculation based on: + - Business hours (configurable per day of week) + - Existing appointments and bay capacity + - Service duration requirements + - Holiday exclusions +- 30-minute time slot intervals +- Multi-bay support for concurrent appointments + +### 3. Time Tracking System + +**Capabilities**: +- Clock in/out functionality for employees +- Track actual work hours per appointment +- Automatic duration calculation +- Multiple time sessions per appointment (breaks supported) +- Integration with Time Logging Service for payroll + +### 4. Employee Scheduling + +**Features**: +- View daily schedule for assigned appointments +- Multi-employee assignment per appointment +- Employee workload visibility +- Calendar view with appointment distribution + +### 5. Status Management + +**Lifecycle Tracking**: +- `PENDING` - Appointment booked, awaiting approval +- `CONFIRMED` - Appointment approved and confirmed +- `CHECKED_IN` - Customer arrived, vehicle accepted +- `IN_PROGRESS` - Work is ongoing +- `COMPLETED` - Work finished by employee +- `CUSTOMER_CONFIRMED` - Customer confirmed completion +- `CANCELLED` - Appointment cancelled +- `NO_SHOW` - Customer didn't arrive + +### 6. Calendar & Reporting + +**Capabilities**: +- Monthly calendar view with appointment counts +- Daily statistics (total, by status) +- Employee schedule management +- Appointment filtering by date range, status, vehicle + +## API Endpoints + +### Appointment Management + +#### Book New Appointment +```http +POST /api/appointments +Authorization: Bearer +Role: CUSTOMER + +Request Body: +{ + "vehicleId": "vehicle-uuid-123", + "serviceType": "Oil Change", + "requestedDateTime": "2025-11-15T10:00:00", + "specialInstructions": "Please check tire pressure as well" +} + +Response: 201 Created +{ + "id": "appt-uuid", + "customerId": "customer-uuid", + "vehicleId": "vehicle-uuid-123", + "serviceType": "Oil Change", + "requestedDateTime": "2025-11-15T10:00:00", + "status": "PENDING", + "confirmationNumber": "APT-2025-001234", + "assignedBayId": "bay-01", + "specialInstructions": "Please check tire pressure as well", + "createdAt": "2025-11-12T14:30:00", + "updatedAt": "2025-11-12T14:30:00" +} +``` + +#### List Appointments +```http +GET /api/appointments +Authorization: Bearer +Role: CUSTOMER, EMPLOYEE, ADMIN +Query Params (all optional): + ?vehicleId=vehicle-uuid + &status=CONFIRMED + &fromDate=2025-11-01 + &toDate=2025-11-30 + +Response: 200 OK +[ + { + "id": "appt-uuid-1", + "confirmationNumber": "APT-2025-001234", + "serviceType": "Oil Change", + "requestedDateTime": "2025-11-15T10:00:00", + "status": "CONFIRMED", + "vehicleId": "vehicle-uuid" + }, + ... +] +``` + +**Access Control**: +- Customers: See only their own appointments +- Employees: See only appointments assigned to them +- Admins: See all appointments + +#### Get Appointment Details +```http +GET /api/appointments/{appointmentId} +Authorization: Bearer +Role: CUSTOMER, EMPLOYEE, ADMIN + +Response: 200 OK +{ + "id": "appt-uuid", + "customerId": "customer-uuid", + "vehicleId": "vehicle-uuid", + "assignedEmployeeIds": ["emp-001", "emp-002"], + "assignedBayId": "bay-01", + "confirmationNumber": "APT-2025-001234", + "serviceType": "Oil Change", + "requestedDateTime": "2025-11-15T10:00:00", + "status": "IN_PROGRESS", + "specialInstructions": "Check tire pressure", + "vehicleArrivedAt": "2025-11-15T09:55:00", + "vehicleAcceptedByEmployeeId": "emp-001", + "createdAt": "2025-11-12T14:30:00", + "updatedAt": "2025-11-15T10:05:00" +} +``` + +#### Update Appointment +```http +PUT /api/appointments/{appointmentId} +Authorization: Bearer +Role: CUSTOMER + +Request Body: +{ + "requestedDateTime": "2025-11-15T14:00:00", + "specialInstructions": "Updated: Please also check brake fluid" +} + +Response: 200 OK +{ + "id": "appt-uuid", + "requestedDateTime": "2025-11-15T14:00:00", + "specialInstructions": "Updated: Please also check brake fluid", + ... +} +``` + +**Note**: Customers can only update appointments with status `PENDING` or `CONFIRMED` + +#### Cancel Appointment +```http +DELETE /api/appointments/{appointmentId} +Authorization: Bearer +Role: CUSTOMER, EMPLOYEE, ADMIN + +Response: 204 No Content +``` + +#### Update Appointment Status +```http +PATCH /api/appointments/{appointmentId}/status +Authorization: Bearer +Role: EMPLOYEE, ADMIN + +Request Body: +{ + "newStatus": "CONFIRMED" +} + +Response: 200 OK +{ + "id": "appt-uuid", + "status": "CONFIRMED", + ... +} +``` + +### Availability & Scheduling + +#### Check Availability +```http +GET /api/appointments/availability +Authorization: Not required (public endpoint) +Query Params: + ?date=2025-11-15 + &serviceType=Oil Change + &duration=60 + +Response: 200 OK +{ + "date": "2025-11-15", + "serviceType": "Oil Change", + "availableSlots": [ + { + "startTime": "09:00", + "endTime": "10:00", + "available": true, + "availableBays": 2 + }, + { + "startTime": "09:30", + "endTime": "10:30", + "available": true, + "availableBays": 1 + }, + { + "startTime": "10:00", + "endTime": "11:00", + "available": false, + "availableBays": 0 + }, + ... + ], + "businessHours": { + "openTime": "08:00", + "closeTime": "17:00" + }, + "isHoliday": false +} +``` + +#### Get Employee Schedule +```http +GET /api/appointments/schedule +Authorization: Bearer +Role: EMPLOYEE +Query Params: ?date=2025-11-15 + +Response: 200 OK +{ + "employeeId": "emp-001", + "date": "2025-11-15", + "appointments": [ + { + "id": "appt-uuid-1", + "confirmationNumber": "APT-2025-001234", + "serviceType": "Oil Change", + "startTime": "09:00", + "endTime": "10:00", + "status": "CONFIRMED", + "customerName": "John Doe", + "vehicleInfo": "Honda Civic 2020" + }, + { + "id": "appt-uuid-2", + "confirmationNumber": "APT-2025-001235", + "serviceType": "Brake Service", + "startTime": "11:00", + "endTime": "13:00", + "status": "IN_PROGRESS", + "customerName": "Jane Smith", + "vehicleInfo": "Toyota Camry 2019" + } + ], + "totalHours": 4, + "appointmentCount": 2 +} +``` + +#### Get Monthly Calendar +```http +GET /api/appointments/calendar +Authorization: Bearer +Role: EMPLOYEE, ADMIN +Query Params: ?year=2025&month=11 + +Response: 200 OK +{ + "year": 2025, + "month": 11, + "days": [ + { + "date": "2025-11-01", + "dayOfWeek": "SATURDAY", + "appointmentCount": 12, + "statusCounts": { + "CONFIRMED": 8, + "IN_PROGRESS": 3, + "COMPLETED": 1 + }, + "isHoliday": false, + "isWeekend": true + }, + ... + ], + "statistics": { + "totalAppointments": 245, + "byStatus": { + "PENDING": 15, + "CONFIRMED": 180, + "IN_PROGRESS": 20, + "COMPLETED": 200, + "CANCELLED": 30 + }, + "averagePerDay": 8.2, + "busiestDay": "2025-11-15" + } +} +``` + +### Employee Operations + +#### Assign Employees to Appointment +```http +POST /api/appointments/{appointmentId}/assign-employees +Authorization: Bearer +Role: ADMIN + +Request Body: +{ + "employeeIds": ["emp-001", "emp-002"] +} + +Response: 200 OK +{ + "id": "appt-uuid", + "assignedEmployeeIds": ["emp-001", "emp-002"], + ... +} +``` + +#### Accept Vehicle Arrival +```http +POST /api/appointments/{appointmentId}/accept-vehicle +Authorization: Bearer +Role: EMPLOYEE + +Response: 200 OK +{ + "id": "appt-uuid", + "status": "CHECKED_IN", + "vehicleArrivedAt": "2025-11-15T09:55:00", + "vehicleAcceptedByEmployeeId": "emp-001", + ... +} +``` + +**Effect**: Changes appointment status from `CONFIRMED` to `CHECKED_IN` + +#### Mark Work Complete +```http +POST /api/appointments/{appointmentId}/complete +Authorization: Bearer +Role: EMPLOYEE + +Response: 200 OK +{ + "id": "appt-uuid", + "status": "COMPLETED", + ... +} +``` + +**Effect**: Changes status to `COMPLETED`, awaiting customer confirmation + +### Time Tracking + +#### Clock In +```http +POST /api/appointments/{appointmentId}/clock-in +Authorization: Bearer +Role: EMPLOYEE + +Response: 200 OK +{ + "sessionId": "session-uuid", + "appointmentId": "appt-uuid", + "employeeId": "emp-001", + "clockInTime": "2025-11-15T10:00:00", + "clockOutTime": null, + "duration": null, + "isActive": true +} +``` + +#### Clock Out +```http +POST /api/appointments/{appointmentId}/clock-out +Authorization: Bearer +Role: EMPLOYEE + +Response: 200 OK +{ + "sessionId": "session-uuid", + "appointmentId": "appt-uuid", + "employeeId": "emp-001", + "clockInTime": "2025-11-15T10:00:00", + "clockOutTime": "2025-11-15T12:30:00", + "duration": 150, + "isActive": false +} +``` + +**Note**: Duration is in minutes. System automatically sends time log to Time Logging Service. + +#### Get Active Time Session +```http +GET /api/appointments/{appointmentId}/time-session +Authorization: Bearer +Role: EMPLOYEE + +Response: 200 OK +{ + "sessionId": "session-uuid", + "appointmentId": "appt-uuid", + "employeeId": "emp-001", + "clockInTime": "2025-11-15T10:00:00", + "clockOutTime": null, + "duration": null, + "isActive": true +} + +Or: 204 No Content (if no active session) +``` + +### Customer Operations + +#### Confirm Completion +```http +POST /api/appointments/{appointmentId}/confirm-completion +Authorization: Bearer +Role: CUSTOMER + +Response: 200 OK +{ + "id": "appt-uuid", + "status": "CUSTOMER_CONFIRMED", + ... +} +``` + +**Effect**: Final confirmation that customer received vehicle and is satisfied + +## Status Workflow & Transitions + +### Valid Status Transitions + +``` +PENDING ──────────────────────────────┐ + │ │ + ├──> CONFIRMED ─────────────────────┤ + │ │ │ + │ └──> CHECKED_IN │ + │ │ │ + │ └──> IN_PROGRESS │ + │ │ │ + │ └──> COMPLETED ──> CUSTOMER_CONFIRMED + │ │ + └──> NO_SHOW │ + └──> CANCELLED ◄────────────────────┘ +``` + +### Status Descriptions + +| Status | Description | Who Can Set | Next States | +|--------|-------------|-------------|-------------| +| `PENDING` | Booked, awaiting approval | System (on booking) | CONFIRMED, CANCELLED | +| `CONFIRMED` | Approved and ready | Employee/Admin | CHECKED_IN, CANCELLED, NO_SHOW | +| `CHECKED_IN` | Vehicle arrived | Employee | IN_PROGRESS, CANCELLED | +| `IN_PROGRESS` | Work ongoing | Employee | COMPLETED, CANCELLED | +| `COMPLETED` | Work finished | Employee | CUSTOMER_CONFIRMED, CANCELLED | +| `CUSTOMER_CONFIRMED` | Customer confirmed | Customer | (Terminal state) | +| `CANCELLED` | Appointment cancelled | Any role | (Terminal state) | +| `NO_SHOW` | Customer didn't arrive | Employee/Admin | (Terminal state) | + +## Database Schema + +### appointments +- `id` (UUID, Primary Key) +- `customer_id` (VARCHAR, NOT NULL) +- `vehicle_id` (VARCHAR, NOT NULL) +- `assigned_bay_id` (VARCHAR) +- `confirmation_number` (VARCHAR, UNIQUE) +- `service_type` (VARCHAR, NOT NULL) +- `requested_date_time` (TIMESTAMP, NOT NULL) +- `status` (VARCHAR(30), NOT NULL) +- `special_instructions` (TEXT) +- `vehicle_arrived_at` (TIMESTAMP) +- `vehicle_accepted_by_employee_id` (VARCHAR) +- `created_at` (TIMESTAMP, NOT NULL) +- `updated_at` (TIMESTAMP, NOT NULL) + +### appointment_assigned_employees (Collection Table) +- `appointment_id` (UUID, Foreign Key) +- `employee_id` (VARCHAR) + +### time_sessions +- `id` (UUID, Primary Key) +- `appointment_id` (UUID, Foreign Key, NOT NULL) +- `employee_id` (VARCHAR, NOT NULL) +- `clock_in_time` (TIMESTAMP, NOT NULL) +- `clock_out_time` (TIMESTAMP) +- `duration_minutes` (INTEGER) +- `is_active` (BOOLEAN, DEFAULT true) +- `created_at` (TIMESTAMP, NOT NULL) +- `updated_at` (TIMESTAMP, NOT NULL) + +### service_types +- `id` (UUID, Primary Key) +- `name` (VARCHAR, UNIQUE, NOT NULL) +- `description` (TEXT) +- `estimated_duration_minutes` (INTEGER, NOT NULL) +- `price` (DECIMAL(10,2)) +- `is_active` (BOOLEAN, DEFAULT true) +- `created_at` (TIMESTAMP, NOT NULL) + +### service_bays +- `id` (UUID, Primary Key) +- `bay_number` (VARCHAR, UNIQUE, NOT NULL) +- `bay_name` (VARCHAR, NOT NULL) +- `is_active` (BOOLEAN, DEFAULT true) +- `capacity` (INTEGER, DEFAULT 1) + +### business_hours +- `id` (UUID, Primary Key) +- `day_of_week` (VARCHAR(10), NOT NULL) - MONDAY, TUESDAY, etc. +- `open_time` (TIME, NOT NULL) +- `close_time` (TIME, NOT NULL) +- `is_closed` (BOOLEAN, DEFAULT false) + +### holidays +- `id` (UUID, Primary Key) +- `holiday_date` (DATE, NOT NULL, UNIQUE) +- `name` (VARCHAR, NOT NULL) +- `description` (TEXT) + +## Environment Configuration + +```properties +# Database +DB_HOST=localhost +DB_PORT=5432 +DB_NAME=techtorque_appointments +DB_USER=techtorque +DB_PASS=techtorque123 +DB_MODE=update + +# Profile +SPRING_PROFILE=dev + +# Service URLs +ADMIN_SERVICE_URL=http://localhost:8087 +TIME_LOGGING_SERVICE_URL=http://localhost:8085 +NOTIFICATION_SERVICE_URL=http://localhost:8088 +``` + +## Business Rules + +### Booking Rules +1. **Time Slot Validation**: Requested time must fall within business hours +2. **Bay Availability**: At least one service bay must be available +3. **Duration Check**: Appointment duration must not exceed bay availability window +4. **Holiday Check**: Cannot book on holidays (configurable) +5. **Lead Time**: Minimum 30 minutes advance booking (configurable) + +### Time Tracking Rules +1. **Single Active Session**: Employee can only have one active session per appointment +2. **Sequential Sessions**: Must clock out before clocking in again +3. **Auto-Calculation**: Duration calculated automatically on clock out +4. **Data Sync**: Time logs automatically sent to Time Logging Service + +### Status Transition Rules +1. **Sequential Flow**: Most status changes follow a specific sequence +2. **Role Restrictions**: Only authorized roles can change status +3. **Terminal States**: `CUSTOMER_CONFIRMED`, `CANCELLED`, `NO_SHOW` are final +4. **Validation**: Invalid transitions are rejected with error + +## Integration Points + +### Time Logging Service +- **Used For**: Recording employee work hours for payroll +- **Trigger**: Automatic on clock-out +- **Endpoint**: `POST /api/time-logs` +- **Data**: Employee ID, appointment ID, clock in/out times, duration + +### Notification Service +- **Used For**: Sending appointment notifications +- **Events**: + - Appointment booked + - Appointment confirmed + - Appointment reminder (24h before) + - Status changes + - Vehicle ready for pickup +- **Endpoint**: `POST /api/notifications` + +### Admin Service +- **Used For**: Fetching service type definitions +- **Endpoint**: `GET /api/service-types` +- **Authentication**: Forward JWT token + +## Security & Authorization + +### Authentication +- JWT Bearer token required (except `/availability` endpoint) +- Token validated by API Gateway +- User information passed via headers: `X-User-Subject`, `X-User-Roles` + +### Authorization Matrix + +| Endpoint | CUSTOMER | EMPLOYEE | ADMIN | +|----------|----------|----------|-------| +| Book Appointment | ✅ | ❌ | ❌ | +| List Appointments | ✅ (own) | ✅ (assigned) | ✅ (all) | +| Get Details | ✅ (own) | ✅ (assigned) | ✅ (all) | +| Update Appointment | ✅ (own, before confirmed) | ❌ | ❌ | +| Cancel Appointment | ✅ (own) | ✅ | ✅ | +| Update Status | ❌ | ✅ | ✅ | +| Check Availability | ✅ (public) | ✅ (public) | ✅ (public) | +| Get Schedule | ❌ | ✅ (own) | ❌ | +| Get Calendar | ❌ | ✅ | ✅ | +| Assign Employees | ❌ | ❌ | ✅ | +| Accept Vehicle | ❌ | ✅ | ❌ | +| Complete Work | ❌ | ✅ | ❌ | +| Clock In/Out | ❌ | ✅ | ❌ | +| Confirm Completion | ✅ (own) | ❌ | ❌ | + +## Error Handling + +### Common Errors + +| Status Code | Error | Description | +|-------------|-------|-------------| +| 400 | Bad Request | Invalid date/time, missing required fields | +| 401 | Unauthorized | Missing or invalid JWT token | +| 403 | Forbidden | User lacks permission for this action | +| 404 | Not Found | Appointment not found | +| 409 | Conflict | Slot not available, invalid status transition, already clocked in | +| 422 | Unprocessable Entity | Business rule violation (outside business hours, holiday, etc.) | +| 500 | Internal Server Error | Server-side error | + +### Error Response Format + +```json +{ + "timestamp": "2025-11-12T10:00:00", + "status": 409, + "error": "Conflict", + "message": "No available service bays for the requested time slot", + "path": "/api/appointments" +} +``` + +## Frequently Asked Questions (Q&A) + +### General Questions + +**Q1: What is a service bay?** + +A: A service bay is a physical workspace in the shop where vehicle work is performed. The system tracks bay availability to prevent overbooking and ensure efficient scheduling. + +**Q2: Can a customer book an appointment outside business hours?** + +A: No, the system validates requested times against configured business hours and rejects bookings outside these times. + +**Q3: What happens if I try to book on a holiday?** + +A: The availability check returns `isHoliday: true` and shows no available slots. The booking request will be rejected. + +**Q4: How is the confirmation number generated?** + +A: Format: `APT-YYYY-NNNNNN` where YYYY is the year and NNNNNN is a sequential number (e.g., APT-2025-001234). + +**Q5: Can I book multiple appointments for the same vehicle?** + +A: Yes, there's no restriction on multiple concurrent appointments per vehicle. + +### Booking & Availability + +**Q6: How does availability checking work?** + +A: The system: +1. Checks business hours for the requested date +2. Verifies it's not a holiday +3. Retrieves all existing appointments for that day +4. Calculates available time slots based on bay capacity +5. Returns 30-minute interval slots with availability status + +**Q7: What if no bays are available?** + +A: The booking request fails with a 409 Conflict error. Customers should choose a different time slot. + +**Q8: Can I change my appointment time after booking?** + +A: Yes, customers can update appointments with status `PENDING` or `CONFIRMED` using the update endpoint. + +**Q9: How far in advance can I book an appointment?** + +A: There's no maximum advance booking limit. Minimum lead time is 30 minutes (configurable). + +**Q10: What's the slot interval for availability?** + +A: 30 minutes. Appointments can start at :00 or :30 of any hour within business hours. + +### Employee Operations + +**Q11: How do employees know which appointments are assigned to them?** + +A: Employees use the "List Appointments" endpoint, which automatically filters to show only their assigned appointments. The "Get Schedule" endpoint provides a daily view. + +**Q12: Can multiple employees be assigned to one appointment?** + +A: Yes, the `assignedEmployeeIds` field supports multiple employee IDs for team-based work. + +**Q13: What happens when an employee accepts vehicle arrival?** + +A: The appointment status changes from `CONFIRMED` to `CHECKED_IN`, and the system records: +- `vehicleArrivedAt`: Current timestamp +- `vehicleAcceptedByEmployeeId`: ID of employee who accepted + +**Q14: Can employees cancel appointments?** + +A: Yes, employees and admins can cancel any appointment. Customers can only cancel their own appointments. + +**Q15: What's the difference between "Complete Work" and "Confirm Completion"?** + +A: "Complete Work" (employee) marks the work as done. "Confirm Completion" (customer) is the final confirmation that the customer received the vehicle and is satisfied. + +### Time Tracking + +**Q16: How does time tracking work?** + +A: Employees clock in when starting work and clock out when finishing. The system: +- Creates a `TimeSession` record +- Calculates duration on clock-out +- Sends time log to Time Logging Service for payroll + +**Q17: Can I clock in/out multiple times for the same appointment?** + +A: Yes, multiple sessions are supported (e.g., breaks). Each session is tracked separately. + +**Q18: What if I forget to clock out?** + +A: You must clock out manually. There's no auto-clock-out. Check the "Get Active Time Session" endpoint to see if you have an active session. + +**Q19: How is duration calculated?** + +A: Duration (in minutes) = Clock Out Time - Clock In Time. Automatically calculated when clocking out. + +**Q20: What happens to time logs after clock-out?** + +A: The system automatically sends the time log to the Time Logging Service, which handles payroll integration and reporting. + +### Status & Workflow + +**Q21: Can I skip statuses in the workflow?** + +A: No, the system enforces a sequential status flow. Invalid transitions are rejected with an error. + +**Q22: What's a "NO_SHOW" status?** + +A: Set by employees/admins when a customer doesn't arrive for their confirmed appointment. This is a terminal status. + +**Q23: Can I reopen a completed appointment?** + +A: No, `COMPLETED` and `CUSTOMER_CONFIRMED` are terminal states. Create a new appointment for additional work. + +**Q24: What happens when an appointment is cancelled?** + +A: The service bay is freed, notifications are sent, and the appointment moves to terminal `CANCELLED` status. + +**Q25: How long do appointment records stay in the system?** + +A: Indefinitely for historical tracking. Implement archival policies as needed for your business requirements. + +--- + +## Summary + +The **TechTorque Appointment Service** is a production-ready scheduling platform that provides: + +### Core Features +- **Smart Booking**: Real-time availability checking with bay allocation +- **Employee Scheduling**: Daily schedules, assignments, and workload management +- **Time Tracking**: Clock in/out with automatic duration calculation and payroll integration +- **Status Management**: Comprehensive lifecycle tracking from booking to customer confirmation +- **Calendar Views**: Monthly overview with statistics and appointment distribution + +### Key Capabilities +- Multi-bay scheduling with capacity management +- Business hours and holiday configuration +- Public availability API for integration with booking widgets +- Employee assignment and workload balancing +- Automatic notifications via integration +- Time log synchronization for payroll + +### Technical Highlights +- **Framework**: Spring Boot 3.5.6 with PostgreSQL +- **Security**: JWT authentication with role-based authorization +- **Status Validation**: Enforced state transition rules +- **Integration**: REST APIs for time logging and notifications +- **API Documentation**: OpenAPI 3.0 (Swagger) for interactive testing + +### Use Cases +- Vehicle service shops with multiple service bays +- Multi-employee work scheduling and tracking +- Customer self-service appointment booking +- Employee time tracking for payroll +- Capacity planning and workload management + +**Version**: 0.0.1-SNAPSHOT +**Last Updated**: November 2025 +**Maintainer**: TechTorque Development Team diff --git a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/BusinessHours.java b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/BusinessHours.java index 72cfafc..cb60e2d 100644 --- a/appointment-service/src/main/java/com/techtorque/appointment_service/entity/BusinessHours.java +++ b/appointment-service/src/main/java/com/techtorque/appointment_service/entity/BusinessHours.java @@ -23,6 +23,8 @@ public class BusinessHours { @GeneratedValue(strategy = GenerationType.UUID) private String id; + // store DayOfWeek as STRING (e.g. "MONDAY") to match production database column type + // using STRING avoids numeric/ordinal mismatches across different DB schemas @Enumerated(EnumType.STRING) @Column(nullable = false) private DayOfWeek dayOfWeek; diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestDataSourceConfig.java b/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestDataSourceConfig.java new file mode 100644 index 0000000..4056caf --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestDataSourceConfig.java @@ -0,0 +1,26 @@ +package com.techtorque.appointment_service.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import javax.sql.DataSource; +import org.springframework.boot.jdbc.DataSourceBuilder; + +@TestConfiguration +@EnableJpaAuditing +public class TestDataSourceConfig { + + @Bean + @Primary + public DataSource testDataSource() { + return DataSourceBuilder + .create() + .driverClassName("org.h2.Driver") + .url("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH") + .username("sa") + .password("") + .build(); + } +} \ No newline at end of file diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestH2Dialect.java b/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestH2Dialect.java new file mode 100644 index 0000000..1f4879a --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestH2Dialect.java @@ -0,0 +1,20 @@ +package com.techtorque.appointment_service.config; + +import org.hibernate.dialect.H2Dialect; + +/** + * Custom H2 dialect for tests that handles PostgreSQL-specific types + */ +public class TestH2Dialect extends H2Dialect { + + public TestH2Dialect() { + super(); + } + + @Override + protected void initDefaultProperties() { + super.initDefaultProperties(); + // Set properties for better PostgreSQL compatibility + getDefaultProperties().setProperty("hibernate.globally_quoted_identifiers", "false"); + } +} \ No newline at end of file diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestJpaConfig.java b/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestJpaConfig.java new file mode 100644 index 0000000..a8f40fa --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/config/TestJpaConfig.java @@ -0,0 +1,13 @@ +package com.techtorque.appointment_service.config; + +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@TestConfiguration +@EnableJpaAuditing +@EntityScan("com.techtorque.appointment_service.entity") +@EnableJpaRepositories("com.techtorque.appointment_service.repository") +public class TestJpaConfig { +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/controller/AppointmentControllerTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/controller/AppointmentControllerTest.java new file mode 100644 index 0000000..7716654 --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/controller/AppointmentControllerTest.java @@ -0,0 +1,618 @@ +package com.techtorque.appointment_service.controller; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.techtorque.appointment_service.config.SecurityConfig; +import com.techtorque.appointment_service.dto.request.*; +import com.techtorque.appointment_service.dto.response.*; +import com.techtorque.appointment_service.entity.AppointmentStatus; +import com.techtorque.appointment_service.service.AppointmentService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.YearMonth; +import java.util.List; +import java.util.Set; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +/** + * Integration tests for AppointmentController + * Tests all REST endpoints with proper security, validation, and error handling + */ +@WebMvcTest(AppointmentController.class) +@ActiveProfiles("test") +@Import(SecurityConfig.class) +class AppointmentControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @MockBean + private AppointmentService appointmentService; + + // Test data + private AppointmentResponseDto createTestAppointmentResponse() { + return AppointmentResponseDto.builder() + .id("apt-1") + .customerId("customer-1") + .vehicleId("vehicle-1") + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.of(2025, 6, 15, 10, 0)) + .status(AppointmentStatus.PENDING) + .confirmationNumber("APT-2025-001000") + .assignedBayId("bay-1") + .assignedEmployeeIds(Set.of()) + .build(); + } + + private AppointmentRequestDto createTestAppointmentRequest() { + return AppointmentRequestDto.builder() + .vehicleId("vehicle-1") + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.now().plusDays(30)) + .specialInstructions("Check tire pressure") + .build(); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void bookAppointment_Success() throws Exception { + // Given + AppointmentRequestDto request = createTestAppointmentRequest(); + AppointmentResponseDto response = createTestAppointmentResponse(); + + when(appointmentService.bookAppointment(org.mockito.ArgumentMatchers.any(AppointmentRequestDto.class), + eq("customer-1"))) + .thenReturn(response); // When & Then + mockMvc.perform(post("/appointments") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value("apt-1")) + .andExpect(jsonPath("$.customerId").value("customer-1")) + .andExpect(jsonPath("$.serviceType").value("Oil Change")) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.confirmationNumber").value("APT-2025-001000")); + + verify(appointmentService).bookAppointment( + org.mockito.ArgumentMatchers.any(AppointmentRequestDto.class), + eq("customer-1")); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void bookAppointment_UnauthorizedRole_Forbidden() throws Exception { + // Given + AppointmentRequestDto request = createTestAppointmentRequest(); + + // When & Then + mockMvc.perform(post("/appointments") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isForbidden()); + + verify(appointmentService, never()).bookAppointment(org.mockito.ArgumentMatchers.any(), + org.mockito.ArgumentMatchers.any()); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void bookAppointment_InvalidRequest_BadRequest() throws Exception { + // Given - invalid request with missing required fields + AppointmentRequestDto invalidRequest = AppointmentRequestDto.builder().build(); + + // When & Then + mockMvc.perform(post("/appointments") + .header("X-User-Subject", "customer-1") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(invalidRequest)) + .with(csrf())) + .andExpect(status().isBadRequest()); + + verify(appointmentService, never()).bookAppointment(any(), any()); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void listAppointments_Customer_Success() throws Exception { + // Given + List appointments = List.of(createTestAppointmentResponse()); + when(appointmentService.getAppointmentsForUser("customer-1", "CUSTOMER")) + .thenReturn(appointments); + + // When & Then + mockMvc.perform(get("/appointments") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id").value("apt-1")) + .andExpect(jsonPath("$[0].customerId").value("customer-1")); + + verify(appointmentService).getAppointmentsForUser("customer-1", "CUSTOMER"); + } + + @Test + @WithMockUser(authorities = "ROLE_ADMIN") + void listAppointments_WithFilters_Success() throws Exception { + // Given + List appointments = List.of(createTestAppointmentResponse()); + when(appointmentService.getAppointmentsWithFilters( + isNull(), eq("vehicle-1"), eq(AppointmentStatus.PENDING), + eq(LocalDate.of(2025, 6, 1)), eq(LocalDate.of(2025, 6, 30)))) + .thenReturn(appointments); + + // When & Then + mockMvc.perform(get("/appointments") + .header("X-User-Subject", "admin-1") + .header("X-User-Roles", "ADMIN") + .param("vehicleId", "vehicle-1") + .param("status", "PENDING") + .param("fromDate", "2025-06-01") + .param("toDate", "2025-06-30")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].vehicleId").value("vehicle-1")); + + verify(appointmentService).getAppointmentsWithFilters( + isNull(), eq("vehicle-1"), eq(AppointmentStatus.PENDING), + eq(LocalDate.of(2025, 6, 1)), eq(LocalDate.of(2025, 6, 30))); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void getAppointmentDetails_Success() throws Exception { + // Given + AppointmentResponseDto appointment = createTestAppointmentResponse(); + when(appointmentService.getAppointmentDetails("apt-1", "customer-1", "CUSTOMER")) + .thenReturn(appointment); + + // When & Then + mockMvc.perform(get("/appointments/apt-1") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")) + .andExpect(jsonPath("$.customerId").value("customer-1")); + + verify(appointmentService).getAppointmentDetails("apt-1", "customer-1", "CUSTOMER"); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void updateAppointment_Success() throws Exception { + // Given + AppointmentUpdateDto updateDto = AppointmentUpdateDto.builder() + .requestedDateTime(LocalDateTime.now().plusDays(30)) + .specialInstructions("Updated instructions") + .build(); + + AppointmentResponseDto updatedResponse = createTestAppointmentResponse(); + when(appointmentService.updateAppointment(eq("apt-1"), + org.mockito.ArgumentMatchers.any(AppointmentUpdateDto.class), eq("customer-1"))) + .thenReturn(updatedResponse); // When & Then + mockMvc.perform(put("/appointments/apt-1") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")); + + verify(appointmentService).updateAppointment(eq("apt-1"), + org.mockito.ArgumentMatchers.any(AppointmentUpdateDto.class), eq("customer-1")); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void updateAppointment_UnauthorizedRole_Forbidden() throws Exception { + // Given + AppointmentUpdateDto updateDto = AppointmentUpdateDto.builder() + .specialInstructions("Updated by employee") + .build(); + + // When & Then + mockMvc.perform(put("/appointments/apt-1") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(updateDto)) + .with(csrf())) + .andExpect(status().isForbidden()); + + verify(appointmentService, never()).updateAppointment(any(), any(), any()); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void cancelAppointment_Success() throws Exception { + // Given + doNothing().when(appointmentService).cancelAppointment("apt-1", "customer-1", "CUSTOMER"); + + // When & Then + mockMvc.perform(delete("/appointments/apt-1") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER") + .with(csrf())) + .andExpect(status().isNoContent()); + + verify(appointmentService).cancelAppointment("apt-1", "customer-1", "CUSTOMER"); + } + + @Test + @WithMockUser(authorities = "ROLE_ADMIN") + void updateStatus_Success() throws Exception { + // Given + StatusUpdateDto statusUpdate = StatusUpdateDto.builder() + .newStatus(AppointmentStatus.CONFIRMED) + .build(); + + AppointmentResponseDto updatedResponse = createTestAppointmentResponse(); + when(appointmentService.updateAppointmentStatus("apt-1", AppointmentStatus.CONFIRMED, "admin-1")) + .thenReturn(updatedResponse); + + // When & Then + mockMvc.perform(patch("/appointments/apt-1/status") + .header("X-User-Subject", "admin-1") + .header("X-User-Roles", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(statusUpdate)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")); + + verify(appointmentService).updateAppointmentStatus("apt-1", AppointmentStatus.CONFIRMED, "admin-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void updateStatus_UnauthorizedRole_Forbidden() throws Exception { + // Given + StatusUpdateDto statusUpdate = StatusUpdateDto.builder() + .newStatus(AppointmentStatus.CONFIRMED) + .build(); + + // When & Then + mockMvc.perform(patch("/appointments/apt-1/status") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(statusUpdate)) + .with(csrf())) + .andExpect(status().isForbidden()); + + verify(appointmentService, never()).updateAppointmentStatus(any(), any(), any()); + } + + @Test + @WithMockUser(authorities = "ROLE_ANONYMOUS") + void checkAvailability_PublicEndpoint_Success() throws Exception { + // Given + AvailabilityResponseDto availability = AvailabilityResponseDto.builder() + .date(LocalDate.of(2025, 6, 15)) + .serviceType("Oil Change") + .durationMinutes(60) + .availableSlots(List.of()) + .build(); + + when(appointmentService.checkAvailability(LocalDate.of(2025, 6, 15), "Oil Change", 60)) + .thenReturn(availability); + + // When & Then + mockMvc.perform(get("/appointments/availability") + .param("date", "2025-06-15") + .param("serviceType", "Oil Change") + .param("duration", "60")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.date").value("2025-06-15")) + .andExpect(jsonPath("$.serviceType").value("Oil Change")) + .andExpect(jsonPath("$.durationMinutes").value(60)); + + verify(appointmentService).checkAvailability(LocalDate.of(2025, 6, 15), "Oil Change", 60); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void getEmployeeSchedule_Success() throws Exception { + // Given + ScheduleResponseDto schedule = ScheduleResponseDto.builder() + .employeeId("employee-1") + .date(LocalDate.of(2025, 6, 15)) + .appointments(List.of()) + .build(); + + when(appointmentService.getEmployeeSchedule("employee-1", LocalDate.of(2025, 6, 15))) + .thenReturn(schedule); + + // When & Then + mockMvc.perform(get("/appointments/schedule") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .param("date", "2025-06-15")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.employeeId").value("employee-1")) + .andExpect(jsonPath("$.date").value("2025-06-15")); + + verify(appointmentService).getEmployeeSchedule("employee-1", LocalDate.of(2025, 6, 15)); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void getEmployeeSchedule_UnauthorizedRole_Forbidden() throws Exception { + // When & Then + mockMvc.perform(get("/appointments/schedule") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER") + .param("date", "2025-06-15")) + .andExpect(status().isForbidden()); + + verify(appointmentService, never()).getEmployeeSchedule(any(), any()); + } + + @Test + @WithMockUser(authorities = "ROLE_ADMIN") + void getMonthlyCalendar_Success() throws Exception { + // Given + CalendarResponseDto calendar = CalendarResponseDto.builder() + .month(YearMonth.of(2025, 6)) + .days(List.of()) + .statistics(CalendarStatisticsDto.builder().totalAppointments(0).build()) + .build(); + + when(appointmentService.getMonthlyCalendar(YearMonth.of(2025, 6), "ROLE_ADMIN")) + .thenReturn(calendar); + + // When & Then + mockMvc.perform(get("/appointments/calendar") + .header("X-User-Roles", "ROLE_ADMIN") + .param("year", "2025") + .param("month", "6")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.month").value("2025-06")); + + verify(appointmentService).getMonthlyCalendar(YearMonth.of(2025, 6), "ROLE_ADMIN"); + } + + @Test + @WithMockUser(authorities = "ROLE_ADMIN") + void assignEmployees_Success() throws Exception { + // Given + AssignEmployeesRequestDto request = AssignEmployeesRequestDto.builder() + .employeeIds(Set.of("emp-1", "emp-2")) + .build(); + + AppointmentResponseDto response = createTestAppointmentResponse(); + when(appointmentService.assignEmployees("apt-1", Set.of("emp-1", "emp-2"), "admin-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(post("/appointments/apt-1/assign-employees") + .header("X-User-Subject", "admin-1") + .header("X-User-Roles", "ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request)) + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")); + + verify(appointmentService).assignEmployees("apt-1", Set.of("emp-1", "emp-2"), "admin-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void acceptVehicleArrival_Success() throws Exception { + // Given + AppointmentResponseDto response = createTestAppointmentResponse(); + when(appointmentService.acceptVehicleArrival("apt-1", "employee-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(post("/appointments/apt-1/accept-vehicle") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")); + + verify(appointmentService).acceptVehicleArrival("apt-1", "employee-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void completeWork_Success() throws Exception { + // Given + AppointmentResponseDto response = createTestAppointmentResponse(); + when(appointmentService.completeWork("apt-1", "employee-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(post("/appointments/apt-1/complete") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")); + + verify(appointmentService).completeWork("apt-1", "employee-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void clockIn_Success() throws Exception { + // Given + TimeSessionResponse response = new TimeSessionResponse(); + response.setId("session-1"); + response.setAppointmentId("apt-1"); + response.setEmployeeId("employee-1"); + response.setActive(true); + response.setElapsedSeconds(0L); + + when(appointmentService.clockIn("apt-1", "employee-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(post("/appointments/apt-1/clock-in") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("session-1")) + .andExpect(jsonPath("$.appointmentId").value("apt-1")) + .andExpect(jsonPath("$.active").value(true)); + + verify(appointmentService).clockIn("apt-1", "employee-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void clockOut_Success() throws Exception { + // Given + TimeSessionResponse response = new TimeSessionResponse(); + response.setId("session-1"); + response.setAppointmentId("apt-1"); + response.setEmployeeId("employee-1"); + response.setActive(false); + response.setElapsedSeconds(7200L); // 2 hours + response.setHoursWorked(2.0); + + when(appointmentService.clockOut("apt-1", "employee-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(post("/appointments/apt-1/clock-out") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("session-1")) + .andExpect(jsonPath("$.active").value(false)) + .andExpect(jsonPath("$.hoursWorked").value(2.0)); + + verify(appointmentService).clockOut("apt-1", "employee-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void getActiveTimeSession_Found_Success() throws Exception { + // Given + TimeSessionResponse response = new TimeSessionResponse(); + response.setId("session-1"); + response.setAppointmentId("apt-1"); + response.setActive(true); + + when(appointmentService.getActiveTimeSession("apt-1", "employee-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(get("/appointments/apt-1/time-session") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("session-1")) + .andExpect(jsonPath("$.active").value(true)); + + verify(appointmentService).getActiveTimeSession("apt-1", "employee-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void getActiveTimeSession_NotFound_NoContent() throws Exception { + // Given + when(appointmentService.getActiveTimeSession("apt-1", "employee-1")) + .thenReturn(null); + + // When & Then + mockMvc.perform(get("/appointments/apt-1/time-session") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE")) + .andExpect(status().isNoContent()); + + verify(appointmentService).getActiveTimeSession("apt-1", "employee-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void confirmCompletion_Success() throws Exception { + // Given + AppointmentResponseDto response = createTestAppointmentResponse(); + when(appointmentService.confirmCompletion("apt-1", "customer-1")) + .thenReturn(response); + + // When & Then + mockMvc.perform(post("/appointments/apt-1/confirm-completion") + .header("X-User-Subject", "customer-1") + .header("X-User-Roles", "CUSTOMER") + .with(csrf())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value("apt-1")); + + verify(appointmentService).confirmCompletion("apt-1", "customer-1"); + } + + @Test + @WithMockUser(authorities = "ROLE_EMPLOYEE") + void confirmCompletion_UnauthorizedRole_Forbidden() throws Exception { + // When & Then + mockMvc.perform(post("/appointments/apt-1/confirm-completion") + .header("X-User-Subject", "employee-1") + .header("X-User-Roles", "EMPLOYEE") + .with(csrf())) + .andExpect(status().isForbidden()); + + verify(appointmentService, never()).confirmCompletion(any(), any()); + } + + @Test + void unauthenticatedRequest_Unauthorized() throws Exception { + // When & Then + mockMvc.perform(get("/appointments")) + .andExpect(status().isForbidden()); // Authentication configured, so 403 instead of 401 + // 401 + } + + @Test + @WithMockUser(authorities = "ROLE_CUSTOMER") + void checkAvailability_InvalidDate_BadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/appointments/availability") + .param("date", "invalid-date") + .param("serviceType", "Oil Change") + .param("duration", "60")) + .andExpect(status().isInternalServerError()); // Type conversion error results in 500 + } + + @Test + @WithMockUser(authorities = "ROLE_ADMIN") + void getMonthlyCalendar_InvalidMonth_BadRequest() throws Exception { + // When & Then + mockMvc.perform(get("/appointments/calendar") + .header("X-User-Roles", "ROLE_ADMIN") + .param("year", "2025") + .param("month", "13")) // Invalid month + .andExpect(status().isInternalServerError()); // DateTimeException results in 500 + } +} \ No newline at end of file diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/repository/AppointmentRepositoryTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/AppointmentRepositoryTest.java new file mode 100644 index 0000000..49fbb11 --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/AppointmentRepositoryTest.java @@ -0,0 +1,211 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.Appointment; +import com.techtorque.appointment_service.entity.AppointmentStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.techtorque.appointment_service.config.TestDataSourceConfig; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(TestDataSourceConfig.class) +class AppointmentRepositoryTest { + + @Autowired + private AppointmentRepository appointmentRepository; + + private Appointment testAppointment; + private LocalDateTime testDateTime; + + @BeforeEach + void setUp() { + appointmentRepository.deleteAll(); + + testDateTime = LocalDateTime.now().plusDays(1); + + Set employeeIds = new HashSet<>(); + employeeIds.add("emp-123"); + employeeIds.add("emp-456"); + + testAppointment = Appointment.builder() + .customerId("customer-123") + .vehicleId("vehicle-456") + .assignedEmployeeIds(employeeIds) + .assignedBayId("bay-001") + .confirmationNumber("APT-2025-001") + .serviceType("Oil Change") + .requestedDateTime(testDateTime) + .status(AppointmentStatus.PENDING) + .specialInstructions("Please check brakes") + .build(); + + appointmentRepository.save(testAppointment); + } + + @Test + void testFindByCustomerIdOrderByRequestedDateTimeDesc() { + // Create another appointment for the same customer + Appointment appointment2 = Appointment.builder() + .customerId("customer-123") + .vehicleId("vehicle-789") + .serviceType("Tire Rotation") + .requestedDateTime(testDateTime.plusDays(1)) + .status(AppointmentStatus.CONFIRMED) + .build(); + appointmentRepository.save(appointment2); + + List appointments = appointmentRepository + .findByCustomerIdOrderByRequestedDateTimeDesc("customer-123"); + + assertThat(appointments).hasSize(2); + assertThat(appointments.get(0).getRequestedDateTime()) + .isAfter(appointments.get(1).getRequestedDateTime()); + } + + @Test + void testFindByAssignedEmployeeIdAndRequestedDateTimeBetween() { + LocalDateTime start = testDateTime.minusHours(1); + LocalDateTime end = testDateTime.plusHours(1); + + List appointments = appointmentRepository + .findByAssignedEmployeeIdAndRequestedDateTimeBetween("emp-123", start, end); + + assertThat(appointments).hasSize(1); + assertThat(appointments.get(0).getId()).isEqualTo(testAppointment.getId()); + } + + @Test + void testFindByRequestedDateTimeBetween() { + LocalDateTime start = testDateTime.minusHours(1); + LocalDateTime end = testDateTime.plusHours(1); + + List appointments = appointmentRepository + .findByRequestedDateTimeBetween(start, end); + + assertThat(appointments).hasSize(1); + assertThat(appointments.get(0).getId()).isEqualTo(testAppointment.getId()); + } + + @Test + void testFindByIdAndCustomerId() { + Optional found = appointmentRepository + .findByIdAndCustomerId(testAppointment.getId(), "customer-123"); + + assertThat(found).isPresent(); + assertThat(found.get().getId()).isEqualTo(testAppointment.getId()); + } + + @Test + void testFindByIdAndCustomerId_WrongCustomer() { + Optional found = appointmentRepository + .findByIdAndCustomerId(testAppointment.getId(), "wrong-customer"); + + assertThat(found).isEmpty(); + } + + @Test + void testFindWithFilters_AllFilters() { + List appointments = appointmentRepository.findWithFilters( + "customer-123", + "vehicle-456", + "PENDING", + testDateTime.minusHours(1), + testDateTime.plusHours(1)); + + assertThat(appointments).hasSize(1); + assertThat(appointments.get(0).getCustomerId()).isEqualTo("customer-123"); + } + + @Test + void testFindWithFilters_NullFilters() { + List appointments = appointmentRepository.findWithFilters( + null, null, null, null, null); + + assertThat(appointments).hasSize(1); + } + + @Test + void testCountByStatus() { + // Create appointments with different statuses + Appointment confirmedAppt = Appointment.builder() + .customerId("customer-456") + .vehicleId("vehicle-789") + .serviceType("Inspection") + .requestedDateTime(testDateTime.plusDays(2)) + .status(AppointmentStatus.CONFIRMED) + .build(); + appointmentRepository.save(confirmedAppt); + + long pendingCount = appointmentRepository.countByStatus(AppointmentStatus.PENDING); + long confirmedCount = appointmentRepository.countByStatus(AppointmentStatus.CONFIRMED); + + assertThat(pendingCount).isEqualTo(1); + assertThat(confirmedCount).isEqualTo(1); + } + + @Test + void testFindByAssignedBayIdAndRequestedDateTimeBetweenAndStatusNot() { + LocalDateTime start = testDateTime.minusHours(1); + LocalDateTime end = testDateTime.plusHours(1); + + List appointments = appointmentRepository + .findByAssignedBayIdAndRequestedDateTimeBetweenAndStatusNot( + "bay-001", start, end, AppointmentStatus.CANCELLED); + + assertThat(appointments).hasSize(1); + assertThat(appointments.get(0).getAssignedBayId()).isEqualTo("bay-001"); + } + + @Test + void testFindMaxConfirmationNumberByPrefix() { + // Create appointments with sequential confirmation numbers + Appointment appt2 = Appointment.builder() + .customerId("customer-789") + .vehicleId("vehicle-101") + .confirmationNumber("APT-2025-002") + .serviceType("Brake Service") + .requestedDateTime(testDateTime.plusDays(3)) + .status(AppointmentStatus.PENDING) + .build(); + appointmentRepository.save(appt2); + + Optional maxNumber = appointmentRepository + .findMaxConfirmationNumberByPrefix("APT-2025-"); + + assertThat(maxNumber).isPresent(); + assertThat(maxNumber.get()).isEqualTo("APT-2025-002"); + } + + @Test + void testSaveAndRetrieve() { + Appointment newAppointment = Appointment.builder() + .customerId("customer-new") + .vehicleId("vehicle-new") + .serviceType("Full Service") + .requestedDateTime(testDateTime.plusDays(5)) + .status(AppointmentStatus.PENDING) + .build(); + + Appointment saved = appointmentRepository.save(newAppointment); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getCustomerId()).isEqualTo("customer-new"); + assertThat(saved.getServiceType()).isEqualTo("Full Service"); + assertThat(saved.getStatus()).isEqualTo(AppointmentStatus.PENDING); + } +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/repository/BusinessHoursRepositoryTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/BusinessHoursRepositoryTest.java new file mode 100644 index 0000000..4883e6e --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/BusinessHoursRepositoryTest.java @@ -0,0 +1,109 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.BusinessHours; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.DayOfWeek; +import java.time.LocalTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.techtorque.appointment_service.config.TestDataSourceConfig; +import org.springframework.context.annotation.Import; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(TestDataSourceConfig.class) +class BusinessHoursRepositoryTest { + + @Autowired + private BusinessHoursRepository businessHoursRepository; + + private BusinessHours mondayHours; + private BusinessHours sundayHours; + + @BeforeEach + void setUp() { + businessHoursRepository.deleteAll(); + + mondayHours = BusinessHours.builder() + .dayOfWeek(DayOfWeek.MONDAY) + .openTime(LocalTime.of(9, 0)) + .closeTime(LocalTime.of(18, 0)) + .breakStartTime(LocalTime.of(13, 0)) + .breakEndTime(LocalTime.of(14, 0)) + .isOpen(true) + .build(); + + sundayHours = BusinessHours.builder() + .dayOfWeek(DayOfWeek.SUNDAY) + .openTime(LocalTime.of(9, 0)) + .closeTime(LocalTime.of(18, 0)) + .isOpen(false) + .build(); + + businessHoursRepository.save(mondayHours); + businessHoursRepository.save(sundayHours); + } + + @Test + void testFindByDayOfWeek() { + Optional found = businessHoursRepository + .findByDayOfWeek(DayOfWeek.MONDAY); + + assertThat(found).isPresent(); + assertThat(found.get().getDayOfWeek()).isEqualTo(DayOfWeek.MONDAY); + assertThat(found.get().getOpenTime()).isEqualTo(LocalTime.of(9, 0)); + assertThat(found.get().getCloseTime()).isEqualTo(LocalTime.of(18, 0)); + } + + @Test + void testFindByDayOfWeek_Sunday() { + Optional found = businessHoursRepository + .findByDayOfWeek(DayOfWeek.SUNDAY); + + assertThat(found).isPresent(); + assertThat(found.get().getIsOpen()).isFalse(); + } + + @Test + void testFindByDayOfWeek_NotFound() { + Optional found = businessHoursRepository + .findByDayOfWeek(DayOfWeek.SATURDAY); + + assertThat(found).isEmpty(); + } + + @Test + void testSaveAndRetrieve() { + BusinessHours wednesdayHours = BusinessHours.builder() + .dayOfWeek(DayOfWeek.WEDNESDAY) + .openTime(LocalTime.of(8, 30)) + .closeTime(LocalTime.of(17, 30)) + .breakStartTime(LocalTime.of(12, 30)) + .breakEndTime(LocalTime.of(13, 30)) + .isOpen(true) + .build(); + + BusinessHours saved = businessHoursRepository.save(wednesdayHours); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getDayOfWeek()).isEqualTo(DayOfWeek.WEDNESDAY); + assertThat(saved.getOpenTime()).isEqualTo(LocalTime.of(8, 30)); + assertThat(saved.getCloseTime()).isEqualTo(LocalTime.of(17, 30)); + } + + @Test + void testFindAll() { + assertThat(businessHoursRepository.findAll()).hasSize(2); + } +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/repository/HolidayRepositoryTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/HolidayRepositoryTest.java new file mode 100644 index 0000000..3c8ee96 --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/HolidayRepositoryTest.java @@ -0,0 +1,100 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.Holiday; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.techtorque.appointment_service.config.TestDataSourceConfig; +import org.springframework.context.annotation.Import; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(TestDataSourceConfig.class) +class HolidayRepositoryTest { + + @Autowired + private HolidayRepository holidayRepository; + + private Holiday testHoliday; + + @BeforeEach + void setUp() { + holidayRepository.deleteAll(); + + testHoliday = Holiday.builder() + .date(LocalDate.of(2025, 12, 25)) + .name("Christmas") + .description("Christmas Day - Office Closed") + .build(); + + holidayRepository.save(testHoliday); + } + + @Test + void testFindByDate() { + Optional found = holidayRepository.findByDate(LocalDate.of(2025, 12, 25)); + + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Christmas"); + } + + @Test + void testFindByDate_NotFound() { + Optional found = holidayRepository.findByDate(LocalDate.of(2025, 12, 26)); + + assertThat(found).isEmpty(); + } + + @Test + void testExistsByDate() { + boolean exists = holidayRepository.existsByDate(LocalDate.of(2025, 12, 25)); + + assertThat(exists).isTrue(); + } + + @Test + void testExistsByDate_NotFound() { + boolean exists = holidayRepository.existsByDate(LocalDate.of(2025, 12, 26)); + + assertThat(exists).isFalse(); + } + + @Test + void testSaveAndRetrieve() { + Holiday newYearHoliday = Holiday.builder() + .date(LocalDate.of(2025, 1, 1)) + .name("New Year") + .description("New Year's Day") + .build(); + + Holiday saved = holidayRepository.save(newYearHoliday); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getName()).isEqualTo("New Year"); + assertThat(saved.getDate()).isEqualTo(LocalDate.of(2025, 1, 1)); + } + + @Test + void testFindAll() { + Holiday independenceDay = Holiday.builder() + .date(LocalDate.of(2025, 2, 4)) + .name("Independence Day") + .description("Sri Lankan Independence Day") + .build(); + holidayRepository.save(independenceDay); + + assertThat(holidayRepository.findAll()).hasSize(2); + } +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/repository/ServiceBayRepositoryTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/ServiceBayRepositoryTest.java new file mode 100644 index 0000000..a49b4ab --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/ServiceBayRepositoryTest.java @@ -0,0 +1,113 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.ServiceBay; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.test.context.ActiveProfiles; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.techtorque.appointment_service.config.TestDataSourceConfig; +import org.springframework.context.annotation.Import; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.annotation.Transactional; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(TestDataSourceConfig.class) +class ServiceBayRepositoryTest { + + @Autowired + private ServiceBayRepository serviceBayRepository; + + private ServiceBay activeBay; + private ServiceBay inactiveBay; + + @BeforeEach + void setUp() { + serviceBayRepository.deleteAll(); + + activeBay = ServiceBay.builder() + .bayNumber("BAY-01") + .name("Bay 1 - General Service") + .description("General maintenance and repair bay") + .capacity(1) + .active(true) + .build(); + + inactiveBay = ServiceBay.builder() + .bayNumber("BAY-03") + .name("Bay 3 - Under Maintenance") + .description("Currently under renovation") + .capacity(1) + .active(false) + .build(); + + serviceBayRepository.save(activeBay); + serviceBayRepository.save(inactiveBay); + } + + @Test + void testFindByActiveTrue() { + List activeBays = serviceBayRepository.findByActiveTrue(); + + assertThat(activeBays).hasSize(1); + assertThat(activeBays.get(0).getBayNumber()).isEqualTo("BAY-01"); + assertThat(activeBays.get(0).getActive()).isTrue(); + } + + @Test + void testFindByActiveTrueOrderByBayNumberAsc() { + // Create additional active bay + ServiceBay bay2 = ServiceBay.builder() + .bayNumber("BAY-02") + .name("Bay 2 - Quick Service") + .description("Fast oil change and tire rotation") + .capacity(1) + .active(true) + .build(); + serviceBayRepository.save(bay2); + + List activeBays = serviceBayRepository.findByActiveTrueOrderByBayNumberAsc(); + + assertThat(activeBays).hasSize(2); + assertThat(activeBays.get(0).getBayNumber()).isEqualTo("BAY-01"); + assertThat(activeBays.get(1).getBayNumber()).isEqualTo("BAY-02"); + } + + @Test + void testSaveAndRetrieve() { + ServiceBay newBay = ServiceBay.builder() + .bayNumber("BAY-04") + .name("Bay 4 - Heavy Duty") + .description("For trucks and heavy vehicles") + .capacity(1) + .active(true) + .build(); + + ServiceBay saved = serviceBayRepository.save(newBay); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getBayNumber()).isEqualTo("BAY-04"); + assertThat(saved.getName()).isEqualTo("Bay 4 - Heavy Duty"); + assertThat(saved.getActive()).isTrue(); + } + + @Test + void testFindAll() { + List allBays = serviceBayRepository.findAll(); + + assertThat(allBays).hasSize(2); + } + + @Test + void testCapacityDefault() { + assertThat(activeBay.getCapacity()).isEqualTo(1); + } +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/repository/ServiceTypeRepositoryTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/ServiceTypeRepositoryTest.java new file mode 100644 index 0000000..86b4bbe --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/ServiceTypeRepositoryTest.java @@ -0,0 +1,131 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.ServiceType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.techtorque.appointment_service.config.TestDataSourceConfig; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(TestDataSourceConfig.class) +class ServiceTypeRepositoryTest { + + @Autowired + private ServiceTypeRepository serviceTypeRepository; + + private ServiceType activeService; + private ServiceType inactiveService; + + @BeforeEach + void setUp() { + serviceTypeRepository.deleteAll(); + + activeService = ServiceType.builder() + .name("Oil Change") + .category("Maintenance") + .basePriceLKR(BigDecimal.valueOf(5000.00)) + .estimatedDurationMinutes(60) + .description("Regular oil change service") + .active(true) + .build(); + + inactiveService = ServiceType.builder() + .name("Engine Overhaul") + .category("Repair") + .basePriceLKR(BigDecimal.valueOf(50000.00)) + .estimatedDurationMinutes(480) + .description("Complete engine overhaul") + .active(false) + .build(); + + serviceTypeRepository.save(activeService); + serviceTypeRepository.save(inactiveService); + } + + @Test + void testFindByActiveTrue() { + List activeServices = serviceTypeRepository.findByActiveTrue(); + + assertThat(activeServices).hasSize(1); + assertThat(activeServices.get(0).getName()).isEqualTo("Oil Change"); + assertThat(activeServices.get(0).getActive()).isTrue(); + } + + @Test + void testFindByNameAndActiveTrue() { + Optional found = serviceTypeRepository + .findByNameAndActiveTrue("Oil Change"); + + assertThat(found).isPresent(); + assertThat(found.get().getName()).isEqualTo("Oil Change"); + } + + @Test + void testFindByNameAndActiveTrue_Inactive() { + Optional found = serviceTypeRepository + .findByNameAndActiveTrue("Engine Overhaul"); + + assertThat(found).isEmpty(); + } + + @Test + void testFindByCategoryAndActiveTrue() { + // Create another active maintenance service + ServiceType tireRotation = ServiceType.builder() + .name("Tire Rotation") + .category("Maintenance") + .basePriceLKR(BigDecimal.valueOf(3000.00)) + .estimatedDurationMinutes(30) + .active(true) + .build(); + serviceTypeRepository.save(tireRotation); + + List maintenanceServices = serviceTypeRepository + .findByCategoryAndActiveTrue("Maintenance"); + + assertThat(maintenanceServices).hasSize(2); + assertThat(maintenanceServices).extracting(ServiceType::getCategory) + .containsOnly("Maintenance"); + assertThat(maintenanceServices).extracting(ServiceType::getActive) + .containsOnly(true); + } + + @Test + void testSaveAndRetrieve() { + ServiceType newService = ServiceType.builder() + .name("Brake Service") + .category("Repair") + .basePriceLKR(BigDecimal.valueOf(15000.00)) + .estimatedDurationMinutes(120) + .description("Complete brake inspection and replacement") + .active(true) + .build(); + + ServiceType saved = serviceTypeRepository.save(newService); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getName()).isEqualTo("Brake Service"); + assertThat(saved.getCategory()).isEqualTo("Repair"); + assertThat(saved.getActive()).isTrue(); + } + + @Test + void testFindAll() { + List allServices = serviceTypeRepository.findAll(); + + assertThat(allServices).hasSize(2); + } +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/repository/TimeSessionRepositoryTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/TimeSessionRepositoryTest.java new file mode 100644 index 0000000..e662d3a --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/repository/TimeSessionRepositoryTest.java @@ -0,0 +1,151 @@ +package com.techtorque.appointment_service.repository; + +import com.techtorque.appointment_service.entity.TimeSession; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.techtorque.appointment_service.config.TestDataSourceConfig; +import org.springframework.context.annotation.Import; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@Import(TestDataSourceConfig.class) +class TimeSessionRepositoryTest { + + @Autowired + private TimeSessionRepository timeSessionRepository; + + private TimeSession activeSession; + private TimeSession completedSession; + + @BeforeEach + void setUp() { + timeSessionRepository.deleteAll(); + + activeSession = TimeSession.builder() + .appointmentId("appt-123") + .employeeId("emp-456") + .clockInTime(LocalDateTime.now().minusHours(2)) + .active(true) + .build(); + timeSessionRepository.save(activeSession); + + // Create completed session and manually set it to inactive after save + completedSession = TimeSession.builder() + .appointmentId("appt-789") + .employeeId("emp-456") + .clockInTime(LocalDateTime.now().minusDays(1)) + .clockOutTime(LocalDateTime.now().minusDays(1).plusHours(3)) + .timeLogId("log-999") + .build(); + TimeSession saved = timeSessionRepository.save(completedSession); + saved.setActive(false); + completedSession = timeSessionRepository.save(saved); + } + + @Test + void testFindByAppointmentIdAndActiveTrue() { + Optional found = timeSessionRepository + .findByAppointmentIdAndActiveTrue("appt-123"); + + assertThat(found).isPresent(); + assertThat(found.get().getEmployeeId()).isEqualTo("emp-456"); + assertThat(found.get().isActive()).isTrue(); + } + + @Test + void testFindByAppointmentIdAndActiveTrue_NotActive() { + Optional found = timeSessionRepository + .findByAppointmentIdAndActiveTrue("appt-789"); + + assertThat(found).isEmpty(); + } + + @Test + void testFindByAppointmentIdAndEmployeeIdAndActiveTrue() { + Optional found = timeSessionRepository + .findByAppointmentIdAndEmployeeIdAndActiveTrue("appt-123", "emp-456"); + + assertThat(found).isPresent(); + assertThat(found.get().getAppointmentId()).isEqualTo("appt-123"); + } + + @Test + void testFindByAppointmentIdAndEmployeeIdAndActiveTrue_WrongEmployee() { + Optional found = timeSessionRepository + .findByAppointmentIdAndEmployeeIdAndActiveTrue("appt-123", "emp-wrong"); + + assertThat(found).isEmpty(); + } + + @Test + void testFindByEmployeeIdAndActiveTrue() { + // Create another active session for the same employee + TimeSession session2 = TimeSession.builder() + .appointmentId("appt-555") + .employeeId("emp-456") + .clockInTime(LocalDateTime.now().minusHours(1)) + .active(true) + .build(); + timeSessionRepository.save(session2); + + List activeSessions = timeSessionRepository + .findByEmployeeIdAndActiveTrue("emp-456"); + + // Should only have 1 active session (completedSession is inactive) + assertThat(activeSessions).hasSize(2); + assertThat(activeSessions).allMatch(TimeSession::isActive); + } + + @Test + void testFindByEmployeeIdOrderByClockInTimeDesc() { + List sessions = timeSessionRepository + .findByEmployeeIdOrderByClockInTimeDesc("emp-456"); + + assertThat(sessions).hasSize(2); + // Most recent should be first + assertThat(sessions.get(0).getClockInTime()) + .isAfter(sessions.get(1).getClockInTime()); + } + + @Test + void testSaveAndRetrieve() { + TimeSession newSession = TimeSession.builder() + .appointmentId("appt-new") + .employeeId("emp-new") + .clockInTime(LocalDateTime.now()) + .active(true) + .build(); + + TimeSession saved = timeSessionRepository.save(newSession); + + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getAppointmentId()).isEqualTo("appt-new"); + assertThat(saved.isActive()).isTrue(); + } + + @Test + void testClockOut() { + TimeSession session = timeSessionRepository.findById(activeSession.getId()).orElseThrow(); + session.setClockOutTime(LocalDateTime.now()); + session.setActive(false); + session.setTimeLogId("log-123"); + + TimeSession updated = timeSessionRepository.save(session); + + assertThat(updated.getClockOutTime()).isNotNull(); + assertThat(updated.isActive()).isFalse(); + assertThat(updated.getTimeLogId()).isEqualTo("log-123"); + } +} diff --git a/appointment-service/src/test/java/com/techtorque/appointment_service/service/AppointmentServiceTest.java b/appointment-service/src/test/java/com/techtorque/appointment_service/service/AppointmentServiceTest.java new file mode 100644 index 0000000..0338c03 --- /dev/null +++ b/appointment-service/src/test/java/com/techtorque/appointment_service/service/AppointmentServiceTest.java @@ -0,0 +1,718 @@ +package com.techtorque.appointment_service.service; + +import com.techtorque.appointment_service.dto.request.*; +import com.techtorque.appointment_service.dto.response.*; +import com.techtorque.appointment_service.entity.*; +import com.techtorque.appointment_service.exception.*; +import com.techtorque.appointment_service.repository.*; +import com.techtorque.appointment_service.service.impl.AppointmentServiceImpl; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.*; +import java.util.*; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * Comprehensive unit tests for AppointmentServiceImpl + * Tests all business logic, validation, and error handling scenarios + */ +@ExtendWith(MockitoExtension.class) +class AppointmentServiceTest { + + @Mock + private AppointmentRepository appointmentRepository; + + @Mock + private ServiceTypeService serviceTypeService; + + @Mock + private ServiceBayRepository serviceBayRepository; + + @Mock + private BusinessHoursRepository businessHoursRepository; + + @Mock + private HolidayRepository holidayRepository; + + @Mock + private TimeSessionRepository timeSessionRepository; + + @Mock + private NotificationClient notificationClient; + + @Mock + private com.techtorque.appointment_service.client.TimeLoggingClient timeLoggingClient; + + @Mock + private AppointmentStateTransitionValidator stateTransitionValidator; + + private AppointmentServiceImpl appointmentService; + + private ServiceBay testBay; + private BusinessHours testBusinessHours; + private Appointment testAppointment; + private ServiceTypeResponseDto testServiceType; + + @BeforeEach + void setUp() { + appointmentService = new AppointmentServiceImpl( + appointmentRepository, + serviceTypeService, + serviceBayRepository, + businessHoursRepository, + holidayRepository, + timeSessionRepository, + notificationClient, + timeLoggingClient, + stateTransitionValidator); + + setupTestData(); + } + + private void setupTestData() { + // Test service bay + testBay = ServiceBay.builder() + .id("bay-1") + .name("Bay 1") + .bayNumber("BAY-01") + .active(true) + .build(); // Test business hours - Monday 9 AM to 5 PM + testBusinessHours = BusinessHours.builder() + .id("bh-1") + .dayOfWeek(DayOfWeek.SUNDAY) + .openTime(LocalTime.of(9, 0)) + .closeTime(LocalTime.of(17, 0)) + .isOpen(true) + .build(); + + // Test appointment - use future date + testAppointment = Appointment.builder() + .id("apt-1") + .customerId("customer-1") + .vehicleId("vehicle-1") + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.of(2025, 6, 15, 10, 0)) // Future date + .status(AppointmentStatus.PENDING) + .confirmationNumber("APT-2025-001000") + .assignedBayId("bay-1") + .assignedEmployeeIds(new HashSet<>()) + .build(); // Test service type + testServiceType = ServiceTypeResponseDto.builder() + .id("st-1") + .name("Oil Change") + .estimatedDurationMinutes(60) + .active(true) + .build(); + } + + @Test + void bookAppointment_Success() { + // Given + AppointmentRequestDto requestDto = AppointmentRequestDto.builder() + .vehicleId("vehicle-1") + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.of(2026, 6, 15, 10, 0)) // Future date + .specialInstructions("Please check tire pressure") + .build(); + when(serviceTypeService.getAllServiceTypes(false)) + .thenReturn(List.of(testServiceType)); + when(holidayRepository.existsByDate(any(LocalDate.class))).thenReturn(false); + when(businessHoursRepository.findByDayOfWeek(DayOfWeek.MONDAY)) + .thenReturn(Optional.of(testBusinessHours)); + when(serviceBayRepository.findByActiveTrueOrderByBayNumberAsc()) + .thenReturn(List.of(testBay)); + when(appointmentRepository.findByAssignedBayIdAndRequestedDateTimeBetweenAndStatusNot( + any(), any(), any(), any())).thenReturn(List.of()); + when(appointmentRepository.findMaxConfirmationNumberByPrefix(anyString())) + .thenReturn(Optional.of("APT-2025-000999")); + when(appointmentRepository.save(any(Appointment.class))).thenReturn(testAppointment); + + // When + AppointmentResponseDto result = appointmentService.bookAppointment(requestDto, "customer-1"); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getCustomerId()).isEqualTo("customer-1"); + assertThat(result.getServiceType()).isEqualTo("Oil Change"); + assertThat(result.getStatus()).isEqualTo(AppointmentStatus.PENDING); + + verify(appointmentRepository).save(any(Appointment.class)); + verify(notificationClient).sendAppointmentNotification(eq("customer-1"), eq("INFO"), any(), any(), any()); + } + + @Test + void bookAppointment_InvalidServiceType_ThrowsException() { + // Given + AppointmentRequestDto requestDto = AppointmentRequestDto.builder() + .serviceType("Invalid Service") + .build(); + + when(serviceTypeService.getAllServiceTypes(false)).thenReturn(List.of(testServiceType)); + + // When & Then + assertThatThrownBy(() -> appointmentService.bookAppointment(requestDto, "customer-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid service type"); + } + + @Test + void bookAppointment_PastDateTime_ThrowsException() { + // Given + AppointmentRequestDto requestDto = AppointmentRequestDto.builder() + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.now().minusDays(1)) + .build(); + + when(serviceTypeService.getAllServiceTypes(false)).thenReturn(List.of(testServiceType)); + + // When & Then + assertThatThrownBy(() -> appointmentService.bookAppointment(requestDto, "customer-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Appointment date must be in the future"); + } + + @Test + void bookAppointment_Holiday_ThrowsException() { + // Given + AppointmentRequestDto requestDto = AppointmentRequestDto.builder() + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.of(2025, 12, 25, 10, 0)) // Future Christmas + .build(); + + when(serviceTypeService.getAllServiceTypes(false)).thenReturn(List.of(testServiceType)); + when(holidayRepository.existsByDate(LocalDate.of(2025, 12, 25))).thenReturn(true); // When & Then + assertThatThrownBy(() -> appointmentService.bookAppointment(requestDto, "customer-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Cannot book appointment on a holiday"); + } + + @Test + void bookAppointment_OutsideBusinessHours_ThrowsException() { + // Given + AppointmentRequestDto requestDto = AppointmentRequestDto.builder() + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.of(2026, 1, 11, 8, 0)) // Sunday before opening + .build(); + + when(serviceTypeService.getAllServiceTypes(false)).thenReturn(List.of(testServiceType)); + when(holidayRepository.existsByDate(any())).thenReturn(false); + when(businessHoursRepository.findByDayOfWeek(DayOfWeek.SUNDAY)) + .thenReturn(Optional.of(testBusinessHours)); // When & Then + assertThatThrownBy(() -> appointmentService.bookAppointment(requestDto, "customer-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Requested time is outside business hours"); + } + + @Test + void bookAppointment_NoBaysAvailable_ThrowsException() { + // Given + AppointmentRequestDto requestDto = AppointmentRequestDto.builder() + .serviceType("Oil Change") + .requestedDateTime(LocalDateTime.of(2026, 1, 12, 10, 0)) + .build(); + + when(serviceTypeService.getAllServiceTypes(false)).thenReturn(List.of(testServiceType)); + when(holidayRepository.existsByDate(any())).thenReturn(false); + when(businessHoursRepository.findByDayOfWeek(DayOfWeek.MONDAY)) + .thenReturn(Optional.of(testBusinessHours)); + when(serviceBayRepository.findByActiveTrueOrderByBayNumberAsc()).thenReturn(List.of()); + + // When & Then + assertThatThrownBy(() -> appointmentService.bookAppointment(requestDto, "customer-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No service bays available"); + } + + @Test + void getAppointmentsForUser_Customer_Success() { + // Given + List appointments = List.of(testAppointment); + when(appointmentRepository.findByCustomerIdOrderByRequestedDateTimeDesc("customer-1")) + .thenReturn(appointments); + + // When + List result = appointmentService + .getAppointmentsForUser("customer-1", "CUSTOMER"); + + // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("customer-1"); + } + + @Test + void getAppointmentsForUser_Employee_Success() { + // Given + List appointments = List.of(testAppointment); + when(appointmentRepository.findByAssignedEmployeeIdAndRequestedDateTimeBetween( + eq("employee-1"), any(), any())).thenReturn(appointments); + + // When + List result = appointmentService + .getAppointmentsForUser("employee-1", "EMPLOYEE"); + + // Then + assertThat(result).hasSize(1); + } + + @Test + void getAppointmentsForUser_Admin_Success() { + // Given + List appointments = List.of(testAppointment); + when(appointmentRepository.findAll()).thenReturn(appointments); + + // When + List result = appointmentService + .getAppointmentsForUser("admin-1", "ADMIN"); + + // Then + assertThat(result).hasSize(1); + } + + @Test + void getAppointmentDetails_Customer_Success() { + // Given + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + + // When + AppointmentResponseDto result = appointmentService + .getAppointmentDetails("apt-1", "customer-1", "CUSTOMER"); + + // Then + assertThat(result.getId()).isEqualTo("apt-1"); + assertThat(result.getCustomerId()).isEqualTo("customer-1"); + } + + @Test + void getAppointmentDetails_UnauthorizedCustomer_ThrowsException() { + // Given + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService + .getAppointmentDetails("apt-1", "other-customer", "CUSTOMER")) + .isInstanceOf(UnauthorizedAccessException.class) + .hasMessageContaining("You do not have permission to view this appointment"); + } + + @Test + void updateAppointment_Success() { + // Given + AppointmentUpdateDto updateDto = AppointmentUpdateDto.builder() + .requestedDateTime(LocalDateTime.of(2026, 6, 16, 11, 0)) // Future date + .specialInstructions("Updated instructions") + .build(); + when(appointmentRepository.findByIdAndCustomerId("apt-1", "customer-1")) + .thenReturn(Optional.of(testAppointment)); + when(holidayRepository.existsByDate(any())).thenReturn(false); + when(businessHoursRepository.findByDayOfWeek(any())) + .thenReturn(Optional.of(testBusinessHours)); + when(serviceBayRepository.findByActiveTrueOrderByBayNumberAsc()) + .thenReturn(List.of(testBay)); + when(appointmentRepository.findByAssignedBayIdAndRequestedDateTimeBetweenAndStatusNot( + any(), any(), any(), any())).thenReturn(List.of()); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + AppointmentResponseDto result = appointmentService + .updateAppointment("apt-1", updateDto, "customer-1"); + + // Then + assertThat(result).isNotNull(); + verify(appointmentRepository).save(any()); + verify(notificationClient).sendAppointmentNotification( + eq("customer-1"), eq("INFO"), any(), any(), eq("apt-1")); + } + + @Test + void updateAppointment_InvalidStatus_ThrowsException() { + // Given + testAppointment.setStatus(AppointmentStatus.IN_PROGRESS); + AppointmentUpdateDto updateDto = AppointmentUpdateDto.builder() + .requestedDateTime(LocalDateTime.of(2026, 1, 16, 11, 0)) + .build(); + + when(appointmentRepository.findByIdAndCustomerId("apt-1", "customer-1")) + .thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService + .updateAppointment("apt-1", updateDto, "customer-1")) + .isInstanceOf(InvalidStatusTransitionException.class) + .hasMessageContaining("Cannot update appointment with status"); + } + + @Test + void cancelAppointment_Customer_Success() { + // Given + when(appointmentRepository.findByIdAndCustomerId("apt-1", "customer-1")) + .thenReturn(Optional.of(testAppointment)); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + appointmentService.cancelAppointment("apt-1", "customer-1", "CUSTOMER"); + + // Then + verify(appointmentRepository).save(argThat(apt -> apt.getStatus() == AppointmentStatus.CANCELLED)); + verify(notificationClient).sendAppointmentNotification( + eq("customer-1"), eq("WARNING"), eq("Appointment Cancelled"), any(), eq("apt-1")); + } + + @Test + void cancelAppointment_InProgressByCustomer_ThrowsException() { + // Given + testAppointment.setStatus(AppointmentStatus.IN_PROGRESS); + when(appointmentRepository.findByIdAndCustomerId("apt-1", "customer-1")) + .thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService + .cancelAppointment("apt-1", "customer-1", "CUSTOMER")) + .isInstanceOf(InvalidStatusTransitionException.class) + .hasMessageContaining("Cannot cancel an appointment that is currently in progress"); + } + + @Test + void updateAppointmentStatus_Success() { + // Given + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + AppointmentResponseDto result = appointmentService + .updateAppointmentStatus("apt-1", AppointmentStatus.CONFIRMED, "employee-1"); + + // Then + assertThat(result).isNotNull(); + verify(appointmentRepository).save(argThat(apt -> apt.getStatus() == AppointmentStatus.CONFIRMED)); + verify(notificationClient).sendAppointmentNotification( + eq("customer-1"), eq("SUCCESS"), any(), any(), eq("apt-1")); + } + + @Test + void checkAvailability_Holiday_ReturnsEmptySlots() { + // Given + LocalDate holidayDate = LocalDate.of(2025, 12, 25); // Future Christmas + when(holidayRepository.existsByDate(holidayDate)).thenReturn(true); + + // When + AvailabilityResponseDto result = appointmentService + .checkAvailability(holidayDate, "Oil Change", 60); + + // Then + assertThat(result.getAvailableSlots()).isEmpty(); + } + + @Test + void checkAvailability_ClosedDay_ReturnsEmptySlots() { + // Given + LocalDate date = LocalDate.of(2025, 1, 19); // Future Sunday + BusinessHours closedHours = BusinessHours.builder() + .dayOfWeek(DayOfWeek.SUNDAY) + .isOpen(false) + .build(); + + when(holidayRepository.existsByDate(date)).thenReturn(false); + when(businessHoursRepository.findByDayOfWeek(DayOfWeek.SUNDAY)) + .thenReturn(Optional.of(closedHours)); + + // When + AvailabilityResponseDto result = appointmentService + .checkAvailability(date, "Oil Change", 60); + + // Then + assertThat(result.getAvailableSlots()).isEmpty(); + } + + @Test + void assignEmployees_Success() { + // Given + Set employeeIds = Set.of("emp-1", "emp-2"); + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + AppointmentResponseDto result = appointmentService + .assignEmployees("apt-1", employeeIds, "admin-1"); + + // Then + assertThat(result).isNotNull(); + verify(appointmentRepository).save(argThat(apt -> apt.getAssignedEmployeeIds().containsAll(employeeIds) && + apt.getStatus() == AppointmentStatus.CONFIRMED)); + verify(notificationClient, times(3)).sendAppointmentNotification(any(), any(), any(), any(), any()); + } + + @Test + void assignEmployees_CompletedAppointment_ThrowsException() { + // Given + testAppointment.setStatus(AppointmentStatus.COMPLETED); + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService + .assignEmployees("apt-1", Set.of("emp-1"), "admin-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Cannot assign employees to a COMPLETED appointment"); + } + + @Test + void acceptVehicleArrival_Success() { + // Given + testAppointment.setStatus(AppointmentStatus.CONFIRMED); + testAppointment.getAssignedEmployeeIds().add("emp-1"); + + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + when(timeLoggingClient.createTimeLog(any(), any(), any(), anyDouble())).thenReturn("time-log-1"); + when(timeSessionRepository.save(any())).thenReturn(new TimeSession()); + + // When + AppointmentResponseDto result = appointmentService + .acceptVehicleArrival("apt-1", "emp-1"); + + // Then + assertThat(result).isNotNull(); + verify(appointmentRepository, times(2)).save(any()); // Called twice: once in acceptVehicleArrival, once in + // clockIn + verify(timeSessionRepository).save(any()); + verify(notificationClient, times(2)).sendAppointmentNotification( + eq("customer-1"), eq("INFO"), eq("Work Started"), any(), eq("apt-1")); + } + + @Test + void acceptVehicleArrival_UnauthorizedEmployee_ThrowsException() { + // Given + testAppointment.setStatus(AppointmentStatus.CONFIRMED); + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService + .acceptVehicleArrival("apt-1", "unauthorized-emp")) + .isInstanceOf(UnauthorizedAccessException.class) + .hasMessageContaining("Employee is not assigned to this appointment"); + } + + @Test + void clockIn_Success() { + // Given + testAppointment.getAssignedEmployeeIds().add("emp-1"); + TimeSession timeSession = new TimeSession(); + timeSession.setId("session-1"); + timeSession.setAppointmentId("apt-1"); + timeSession.setEmployeeId("emp-1"); + timeSession.setActive(true); + timeSession.setClockInTime(LocalDateTime.now()); + + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(timeSessionRepository.findByAppointmentIdAndEmployeeIdAndActiveTrue("apt-1", "emp-1")) + .thenReturn(Optional.empty()); + when(timeLoggingClient.createTimeLog(any(), any(), any(), anyDouble())).thenReturn("time-log-1"); + when(timeSessionRepository.save(any())).thenReturn(timeSession); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + TimeSessionResponse result = appointmentService.clockIn("apt-1", "emp-1"); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getAppointmentId()).isEqualTo("apt-1"); + assertThat(result.isActive()).isTrue(); + verify(timeSessionRepository).save(any()); + verify(appointmentRepository).save(argThat(apt -> apt.getStatus() == AppointmentStatus.IN_PROGRESS)); + } + + @Test + void clockIn_AlreadyActive_ReturnsExistingSession() { + // Given + testAppointment.getAssignedEmployeeIds().add("emp-1"); + TimeSession existingSession = new TimeSession(); + existingSession.setId("session-1"); + existingSession.setAppointmentId("apt-1"); + existingSession.setEmployeeId("emp-1"); + existingSession.setActive(true); + existingSession.setClockInTime(LocalDateTime.now()); + + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(timeSessionRepository.findByAppointmentIdAndEmployeeIdAndActiveTrue("apt-1", "emp-1")) + .thenReturn(Optional.of(existingSession)); + + // When + TimeSessionResponse result = appointmentService.clockIn("apt-1", "emp-1"); + + // Then + assertThat(result).isNotNull(); + verify(timeSessionRepository, never()).save(any()); + verify(timeLoggingClient, never()).createTimeLog(any(), any(), any(), anyDouble()); + } + + @Test + void clockOut_Success() { + // Given + TimeSession activeSession = new TimeSession(); + activeSession.setId("session-1"); + activeSession.setAppointmentId("apt-1"); + activeSession.setEmployeeId("emp-1"); + activeSession.setActive(true); + activeSession.setClockInTime(LocalDateTime.now().minusHours(2)); + activeSession.setTimeLogId("time-log-1"); + + when(timeSessionRepository.findByAppointmentIdAndEmployeeIdAndActiveTrue("apt-1", "emp-1")) + .thenReturn(Optional.of(activeSession)); + when(timeSessionRepository.save(any())).thenReturn(activeSession); + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + TimeSessionResponse result = appointmentService.clockOut("apt-1", "emp-1"); + + // Then + assertThat(result).isNotNull(); + verify(timeSessionRepository).save(argThat(session -> !session.isActive())); + verify(timeLoggingClient).updateTimeLog(eq("time-log-1"), anyDouble(), any()); + verify(appointmentRepository).save(argThat(apt -> apt.getStatus() == AppointmentStatus.COMPLETED)); + } + + @Test + void clockOut_NoActiveSession_ThrowsException() { + // Given + when(timeSessionRepository.findByAppointmentIdAndEmployeeIdAndActiveTrue("apt-1", "emp-1")) + .thenReturn(Optional.empty()); + + // When & Then + assertThatThrownBy(() -> appointmentService.clockOut("apt-1", "emp-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("No active time session found"); + } + + @Test + void getActiveTimeSession_Found() { + // Given + TimeSession activeSession = new TimeSession(); + activeSession.setId("session-1"); + activeSession.setActive(true); + activeSession.setClockInTime(LocalDateTime.now().minusMinutes(30)); + + when(timeSessionRepository.findByAppointmentIdAndEmployeeIdAndActiveTrue("apt-1", "emp-1")) + .thenReturn(Optional.of(activeSession)); + + // When + TimeSessionResponse result = appointmentService.getActiveTimeSession("apt-1", "emp-1"); + + // Then + assertThat(result).isNotNull(); + assertThat(result.isActive()).isTrue(); + assertThat(result.getElapsedSeconds()).isPositive(); + } + + @Test + void getActiveTimeSession_NotFound() { + // Given + when(timeSessionRepository.findByAppointmentIdAndEmployeeIdAndActiveTrue("apt-1", "emp-1")) + .thenReturn(Optional.empty()); + + // When + TimeSessionResponse result = appointmentService.getActiveTimeSession("apt-1", "emp-1"); + + // Then + assertThat(result).isNull(); + } + + @Test + void confirmCompletion_Success() { + // Given + testAppointment.setStatus(AppointmentStatus.COMPLETED); + testAppointment.getAssignedEmployeeIds().addAll(Set.of("emp-1", "emp-2")); + + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + when(appointmentRepository.save(any())).thenReturn(testAppointment); + + // When + AppointmentResponseDto result = appointmentService.confirmCompletion("apt-1", "customer-1"); + + // Then + assertThat(result).isNotNull(); + verify(appointmentRepository).save(argThat(apt -> apt.getStatus() == AppointmentStatus.CUSTOMER_CONFIRMED)); + verify(notificationClient, times(3)).sendAppointmentNotification(any(), any(), any(), any(), any()); + } + + @Test + void confirmCompletion_WrongCustomer_ThrowsException() { + // Given + testAppointment.setStatus(AppointmentStatus.COMPLETED); + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService.confirmCompletion("apt-1", "wrong-customer")) + .isInstanceOf(UnauthorizedAccessException.class) + .hasMessageContaining("You do not have permission to confirm this appointment"); + } + + @Test + void confirmCompletion_WrongStatus_ThrowsException() { + // Given + testAppointment.setStatus(AppointmentStatus.PENDING); + when(appointmentRepository.findById("apt-1")).thenReturn(Optional.of(testAppointment)); + + // When & Then + assertThatThrownBy(() -> appointmentService.confirmCompletion("apt-1", "customer-1")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Can only confirm completion for COMPLETED appointments"); + } + + @Test + void getAppointmentsWithFilters_Success() { + // Given + List appointments = List.of(testAppointment); + when(appointmentRepository.findWithFilters(any(), any(), any(), any(), any())) + .thenReturn(appointments); + + // When + List result = appointmentService.getAppointmentsWithFilters( + "customer-1", "vehicle-1", AppointmentStatus.PENDING, + LocalDate.of(2025, 6, 1), LocalDate.of(2025, 6, 30)); // Then + assertThat(result).hasSize(1); + assertThat(result.get(0).getCustomerId()).isEqualTo("customer-1"); + } + + @Test + void getEmployeeSchedule_Success() { + // Given + List appointments = List.of(testAppointment); + when(appointmentRepository.findByAssignedEmployeeIdAndRequestedDateTimeBetween( + eq("emp-1"), any(), any())).thenReturn(appointments); + + // When + ScheduleResponseDto result = appointmentService + .getEmployeeSchedule("emp-1", LocalDate.of(2025, 6, 15)); // Then + assertThat(result.getEmployeeId()).isEqualTo("emp-1"); + assertThat(result.getAppointments()).hasSize(1); + } + + @Test + void getMonthlyCalendar_Success() { + // Given + List appointments = List.of(testAppointment); + List holidays = List.of(); + List bays = List.of(testBay); + + when(appointmentRepository.findByRequestedDateTimeBetween(any(), any())) + .thenReturn(appointments); + when(holidayRepository.findAll()).thenReturn(holidays); + when(serviceBayRepository.findAll()).thenReturn(bays); + + // When + CalendarResponseDto result = appointmentService + .getMonthlyCalendar(YearMonth.of(2025, 6), "ADMIN"); + + // Then + assertThat(result.getMonth()).isEqualTo(YearMonth.of(2025, 6)); + assertThat(result.getDays()).hasSize(30); // June has 30 days + assertThat(result.getStatistics().getTotalAppointments()).isEqualTo(1); + } +} \ No newline at end of file diff --git a/appointment-service/src/test/resources/application-test.properties b/appointment-service/src/test/resources/application-test.properties index 8211f4e..91dce2f 100644 --- a/appointment-service/src/test/resources/application-test.properties +++ b/appointment-service/src/test/resources/application-test.properties @@ -1,16 +1,26 @@ -# H2 Test Database Configuration -spring.datasource.url=jdbc:h2:mem:testdb;MODE=PostgreSQL;DATABASE_TO_LOWER=TRUE;DEFAULT_NULL_ORDERING=HIGH +# H2 Test Database Configuration - Simple H2 mode without PostgreSQL emulation +spring.datasource.url=jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE spring.datasource.driverClassName=org.h2.Driver spring.datasource.username=sa spring.datasource.password= -# JPA Configuration +# JPA Configuration - Use H2 dialect spring.jpa.database-platform=org.hibernate.dialect.H2Dialect -spring.jpa.hibernate.ddl-auto=create-drop +spring.jpa.hibernate.ddl-auto=none +spring.jpa.defer-datasource-initialization=true +spring.sql.init.mode=always +spring.sql.init.schema-locations=classpath:schema-h2.sql spring.jpa.show-sql=false -spring.jpa.properties.hibernate.format_sql=true +spring.jpa.properties.hibernate.format_sql=false +spring.jpa.properties.hibernate.globally_quoted_identifiers=false + +# H2-specific configurations to handle PostgreSQL types +spring.jpa.properties.hibernate.connection.characterEncoding=utf8 +spring.jpa.properties.hibernate.connection.CharSet=utf8 +spring.jpa.properties.hibernate.connection.useUnicode=true +spring.jpa.properties.hibernate.type.preferred_enum_jdbc_type=ORDINAL # Logging -logging.level.org.hibernate.SQL=DEBUG -logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE -logging.level.com.techtorque.appointment_service=DEBUG +logging.level.org.hibernate.SQL=ERROR +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=ERROR +logging.level.com.techtorque.appointment_service=INFO diff --git a/appointment-service/src/test/resources/schema-h2.sql b/appointment-service/src/test/resources/schema-h2.sql new file mode 100644 index 0000000..1554069 --- /dev/null +++ b/appointment-service/src/test/resources/schema-h2.sql @@ -0,0 +1,96 @@ +-- H2-compatible schema for Appointment Service tests +DROP ALL OBJECTS; + +-- Service Types table +CREATE TABLE service_types ( + id VARCHAR(255) PRIMARY KEY, + name VARCHAR(255) NOT NULL UNIQUE, + description CLOB, + category VARCHAR(255) NOT NULL, + estimated_duration_minutes INTEGER NOT NULL, + base_pricelkr DECIMAL(38,2) NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Service Bays table +CREATE TABLE service_bays ( + id VARCHAR(255) PRIMARY KEY, + bay_number VARCHAR(255) NOT NULL, + name VARCHAR(255) NOT NULL, + description CLOB, + capacity INTEGER NOT NULL, + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Business Hours table (DayOfWeek as VARCHAR: MONDAY, TUESDAY, etc.) +CREATE TABLE business_hours ( + id VARCHAR(255) PRIMARY KEY, + day_of_week VARCHAR(10) NOT NULL, + open_time TIME, + close_time TIME, + break_start_time TIME, + break_end_time TIME, + is_open BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Holidays table +CREATE TABLE holidays ( + id VARCHAR(255) PRIMARY KEY, + date DATE NOT NULL, + name VARCHAR(255) NOT NULL, + description CLOB, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Appointments table (without FK constraints for easier testing) +CREATE TABLE appointments ( + id VARCHAR(255) PRIMARY KEY, + customer_id VARCHAR(255) NOT NULL, + vehicle_id VARCHAR(255), + service_type VARCHAR(255) NOT NULL, + requested_date_time TIMESTAMP NOT NULL, + vehicle_arrived_at TIMESTAMP, + status VARCHAR(255) NOT NULL DEFAULT 'PENDING', + special_instructions CLOB, + confirmation_number VARCHAR(255), + assigned_bay_id VARCHAR(255), + vehicle_accepted_by_employee_id VARCHAR(255), + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Time Sessions table (without FK constraints for easier testing) +CREATE TABLE time_sessions ( + id VARCHAR(255) PRIMARY KEY, + appointment_id VARCHAR(255) NOT NULL, + employee_id VARCHAR(255) NOT NULL, + clock_in_time TIMESTAMP NOT NULL, + clock_out_time TIMESTAMP, + time_log_id VARCHAR(255), + active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +-- Appointment Assigned Employees junction table +CREATE TABLE appointment_assigned_employees ( + appointment_id VARCHAR(255) NOT NULL, + employee_id VARCHAR(255) NOT NULL, + PRIMARY KEY (appointment_id, employee_id) +); + +-- Indexes for performance +CREATE INDEX idx_appointments_customer_id ON appointments(customer_id); +CREATE INDEX idx_appointments_status ON appointments(status); +CREATE INDEX idx_appointments_requested_date ON appointments(requested_date_time); +CREATE INDEX idx_service_bays_active ON service_bays(active); +CREATE INDEX idx_service_types_active ON service_types(active); +CREATE INDEX idx_time_sessions_appointment_id ON time_sessions(appointment_id); +CREATE INDEX idx_time_sessions_employee_id ON time_sessions(employee_id); +CREATE INDEX idx_business_hours_day ON business_hours(day_of_week); \ No newline at end of file