diff --git a/docs/collection/skyroster.postman_collection.json b/docs/collection/skyroster.postman_collection.json index 4b4fd0e..8365c29 100644 --- a/docs/collection/skyroster.postman_collection.json +++ b/docs/collection/skyroster.postman_collection.json @@ -199,10 +199,43 @@ ] }, { - "name": "Admin", + "name": "Aircraft", "item": [ { - "name": "GET /api/admin/users", + "name": "POST /api/aircraft", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{token}}", + "type": "text" + }, + { + "key": "Content-Type", + "value": "application/json", + "type": "text" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"registrationNumber\": \"SP-LRA\",\n \"aircraftTypeCode\": \"B738\",\n \"operationalBaseCode\": \"EPWA\"\n}" + }, + "url": { + "raw": "{{base_url}}/api/aircraft", + "host": [ + "{{base_url}}" + ], + "path": [ + "api", + "aircraft" + ] + } + }, + "response": [] + }, + { + "name": "GET /api/aircraft", "request": { "method": "GET", "header": [ @@ -213,14 +246,13 @@ } ], "url": { - "raw": "{{base_url}}/api/admin/users", + "raw": "{{base_url}}/api/aircraft", "host": [ "{{base_url}}" ], "path": [ "api", - "admin", - "users" + "aircraft" ] } }, diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/aircraft/AddAircraftUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/aircraft/AddAircraftUseCase.java new file mode 100644 index 0000000..7b6756d --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/aircraft/AddAircraftUseCase.java @@ -0,0 +1,52 @@ +package pl.skyroster.skyroster_backend.application.aircraft; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.skyroster.skyroster_backend.domain.exception.AircraftAlreadyExistsException; +import pl.skyroster.skyroster_backend.domain.exception.AircraftTypeNotFoundException; +import pl.skyroster.skyroster_backend.domain.exception.OperationalBaseNotFoundException; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.domain.model.AircraftType; +import pl.skyroster.skyroster_backend.domain.model.OperationalBase; +import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; +import pl.skyroster.skyroster_backend.domain.port.AircraftTypeRepository; +import pl.skyroster.skyroster_backend.domain.port.OperationalBaseRepository; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Service +public class AddAircraftUseCase { + + private final AircraftRepository aircraftRepository; + private final AircraftTypeRepository aircraftTypeRepository; + private final OperationalBaseRepository operationalBaseRepository; + + public AddAircraftUseCase(AircraftRepository aircraftRepository, + AircraftTypeRepository aircraftTypeRepository, + OperationalBaseRepository operationalBaseRepository) { + this.aircraftRepository = aircraftRepository; + this.aircraftTypeRepository = aircraftTypeRepository; + this.operationalBaseRepository = operationalBaseRepository; + } + + @Transactional + public Aircraft execute(String registrationNumber, String aircraftTypeCode, String operationalBaseCode) { + if (registrationNumber == null || registrationNumber.isBlank()) { + throw new IllegalArgumentException("Registration number must not be blank"); + } + + if (aircraftRepository.existsByRegistrationNumber(registrationNumber)) { + throw new AircraftAlreadyExistsException(registrationNumber); + } + + AircraftType type = aircraftTypeRepository.findByIcaoCode(aircraftTypeCode) + .orElseThrow(() -> new AircraftTypeNotFoundException(aircraftTypeCode)); + + OperationalBase base = operationalBaseRepository.findByIcaoCode(operationalBaseCode) + .orElseThrow(() -> new OperationalBaseNotFoundException(operationalBaseCode)); + + Aircraft aircraft = new Aircraft(UUID.randomUUID(), registrationNumber, LocalDateTime.now(), type, base); + return aircraftRepository.save(aircraft); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/aircraft/GetAircraftUseCase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/aircraft/GetAircraftUseCase.java new file mode 100644 index 0000000..5a985a8 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/application/aircraft/GetAircraftUseCase.java @@ -0,0 +1,23 @@ +package pl.skyroster.skyroster_backend.application.aircraft; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; + +import java.util.List; + +@Service +public class GetAircraftUseCase { + + private final AircraftRepository aircraftRepository; + + public GetAircraftUseCase(AircraftRepository aircraftRepository) { + this.aircraftRepository = aircraftRepository; + } + + @Transactional(readOnly = true) + public List execute() { + return aircraftRepository.findAll(); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/AircraftAlreadyExistsException.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/AircraftAlreadyExistsException.java new file mode 100644 index 0000000..22949c7 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/AircraftAlreadyExistsException.java @@ -0,0 +1,7 @@ +package pl.skyroster.skyroster_backend.domain.exception; + +public class AircraftAlreadyExistsException extends RuntimeException { + public AircraftAlreadyExistsException(String registrationNumber) { + super("Aircraft with registration number '%s' already exists".formatted(registrationNumber)); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/AircraftTypeNotFoundException.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/AircraftTypeNotFoundException.java new file mode 100644 index 0000000..934ee2e --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/AircraftTypeNotFoundException.java @@ -0,0 +1,7 @@ +package pl.skyroster.skyroster_backend.domain.exception; + +public class AircraftTypeNotFoundException extends RuntimeException { + public AircraftTypeNotFoundException(String icaoCode) { + super("Aircraft type with code '%s' not found".formatted(icaoCode)); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/OperationalBaseNotFoundException.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/OperationalBaseNotFoundException.java new file mode 100644 index 0000000..c326912 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/exception/OperationalBaseNotFoundException.java @@ -0,0 +1,7 @@ +package pl.skyroster.skyroster_backend.domain.exception; + +public class OperationalBaseNotFoundException extends RuntimeException { + public OperationalBaseNotFoundException(String icaoCode) { + super("Operational base with code '%s' not found".formatted(icaoCode)); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Aircraft.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Aircraft.java new file mode 100644 index 0000000..e77ae9a --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/Aircraft.java @@ -0,0 +1,41 @@ +package pl.skyroster.skyroster_backend.domain.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Entity +@Table(name = "aircraft") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class Aircraft { + + @Id + private UUID id; + + @Column(name = "registration_number", nullable = false, unique = true, length = 15) + private String registrationNumber; + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "aircraft_type_id", nullable = false) + private AircraftType aircraftType; + + @ManyToOne(fetch = FetchType.EAGER) + @JoinColumn(name = "operational_base_id", nullable = false) + private OperationalBase operationalBase; + +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/AircraftType.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/AircraftType.java new file mode 100644 index 0000000..6b5910b --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/AircraftType.java @@ -0,0 +1,28 @@ +package pl.skyroster.skyroster_backend.domain.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "aircraft_types") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class AircraftType { + + @Id + private UUID id; + + @Column(name = "icao_code", nullable = false, unique = true, length = 4) + private String icaoCode; + + @Column(nullable = false, length = 100) + private String name; +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/OperationalBase.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/OperationalBase.java new file mode 100644 index 0000000..c21b0d4 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/model/OperationalBase.java @@ -0,0 +1,28 @@ +package pl.skyroster.skyroster_backend.domain.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.UUID; + +@Entity +@Table(name = "operational_bases") +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class OperationalBase { + + @Id + private UUID id; + + @Column(name = "icao_code", nullable = false, unique = true, length = 4) + private String icaoCode; + + @Column(nullable = false, length = 100) + private String name; +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java new file mode 100644 index 0000000..aad2ced --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftRepository.java @@ -0,0 +1,11 @@ +package pl.skyroster.skyroster_backend.domain.port; + +import pl.skyroster.skyroster_backend.domain.model.Aircraft; + +import java.util.List; + +public interface AircraftRepository { + Aircraft save(Aircraft aircraft); + List findAll(); + boolean existsByRegistrationNumber(String registrationNumber); +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftTypeRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftTypeRepository.java new file mode 100644 index 0000000..b88e9c7 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/AircraftTypeRepository.java @@ -0,0 +1,9 @@ +package pl.skyroster.skyroster_backend.domain.port; + +import pl.skyroster.skyroster_backend.domain.model.AircraftType; + +import java.util.Optional; + +public interface AircraftTypeRepository { + Optional findByIcaoCode(String icaoCode); +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java new file mode 100644 index 0000000..cd26ebd --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/domain/port/OperationalBaseRepository.java @@ -0,0 +1,9 @@ +package pl.skyroster.skyroster_backend.domain.port; + +import pl.skyroster.skyroster_backend.domain.model.OperationalBase; + +import java.util.Optional; + +public interface OperationalBaseRepository { + Optional findByIcaoCode(String icaoCode); +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AdminController.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AdminController.java deleted file mode 100644 index c66baad..0000000 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AdminController.java +++ /dev/null @@ -1,24 +0,0 @@ -package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RestController; -import pl.skyroster.skyroster_backend.generated.api.ApiApi; -import pl.skyroster.skyroster_backend.generated.model.UserInfo; - -import java.util.List; - -// TODO: Temporary stub controller — replace with real implementation -@RestController -public class AdminController { - - @GetMapping(ApiApi.PATH_GET_ADMIN_USERS) - public ResponseEntity> getAdminUsers() { - List mockUsers = List.of( - new UserInfo().username("admin").email("admin@skyroster.local").roles(List.of("operations_administrator")), - new UserInfo().username("compliance").email("compliance@skyroster.local").roles(List.of("compliance_officer")), - new UserInfo().username("schedule_planner").email("schedule_planner@skyroster.local").roles(List.of("schedule_planner")) - ); - return ResponseEntity.ok(mockUsers); - } -} \ No newline at end of file diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java new file mode 100644 index 0000000..3e2a7c6 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftController.java @@ -0,0 +1,62 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import pl.skyroster.skyroster_backend.application.aircraft.AddAircraftUseCase; +import pl.skyroster.skyroster_backend.application.aircraft.GetAircraftUseCase; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.generated.api.ApiApi; +import pl.skyroster.skyroster_backend.generated.model.AddAircraftRequest; +import pl.skyroster.skyroster_backend.generated.model.AircraftResponse; +import pl.skyroster.skyroster_backend.generated.model.AircraftTypeInfo; +import pl.skyroster.skyroster_backend.generated.model.OperationalBaseInfo; + +import java.util.List; + +@RestController +public class AircraftController { + + private final AddAircraftUseCase addAircraftUseCase; + private final GetAircraftUseCase getAircraftUseCase; + + public AircraftController(AddAircraftUseCase addAircraftUseCase, GetAircraftUseCase getAircraftUseCase) { + this.addAircraftUseCase = addAircraftUseCase; + this.getAircraftUseCase = getAircraftUseCase; + } + + @PostMapping(ApiApi.PATH_ADD_AIRCRAFT) + public ResponseEntity addAircraft(@RequestBody AddAircraftRequest request) { + Aircraft aircraft = addAircraftUseCase.execute( + request.getRegistrationNumber(), + request.getAircraftTypeCode(), + request.getOperationalBaseCode() + ); + return ResponseEntity.status(HttpStatus.CREATED).body(toResponse(aircraft)); + } + + @GetMapping(ApiApi.PATH_GET_AIRCRAFT) + public ResponseEntity> getAircraft() { + List response = getAircraftUseCase.execute().stream() + .map(this::toResponse) + .toList(); + return ResponseEntity.ok(response); + } + + private AircraftResponse toResponse(Aircraft aircraft) { + return new AircraftResponse() + .id(aircraft.getId()) + .registrationNumber(aircraft.getRegistrationNumber()) + .aircraftType(new AircraftTypeInfo() + .id(aircraft.getAircraftType().getId()) + .icaoCode(aircraft.getAircraftType().getIcaoCode()) + .name(aircraft.getAircraftType().getName())) + .operationalBase(new OperationalBaseInfo() + .id(aircraft.getOperationalBase().getId()) + .icaoCode(aircraft.getOperationalBase().getIcaoCode()) + .name(aircraft.getOperationalBase().getName())); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java new file mode 100644 index 0000000..04d3f60 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftRepository.java @@ -0,0 +1,10 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; + +import java.util.UUID; + +public interface JpaAircraftRepository extends JpaRepository, AircraftRepository { +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftTypeRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftTypeRepository.java new file mode 100644 index 0000000..1853265 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaAircraftTypeRepository.java @@ -0,0 +1,10 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import pl.skyroster.skyroster_backend.domain.model.AircraftType; +import pl.skyroster.skyroster_backend.domain.port.AircraftTypeRepository; + +import java.util.UUID; + +public interface JpaAircraftTypeRepository extends JpaRepository, AircraftTypeRepository { +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaOperationalBaseRepository.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaOperationalBaseRepository.java new file mode 100644 index 0000000..6f2f239 --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/adapter/out/persistence/JpaOperationalBaseRepository.java @@ -0,0 +1,10 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.out.persistence; + +import org.springframework.data.jpa.repository.JpaRepository; +import pl.skyroster.skyroster_backend.domain.model.OperationalBase; +import pl.skyroster.skyroster_backend.domain.port.OperationalBaseRepository; + +import java.util.UUID; + +public interface JpaOperationalBaseRepository extends JpaRepository, OperationalBaseRepository { +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java new file mode 100644 index 0000000..4d1539a --- /dev/null +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/GlobalExceptionHandler.java @@ -0,0 +1,52 @@ +package pl.skyroster.skyroster_backend.infrastructure.config; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import pl.skyroster.skyroster_backend.domain.exception.AircraftAlreadyExistsException; +import pl.skyroster.skyroster_backend.domain.exception.AircraftTypeNotFoundException; +import pl.skyroster.skyroster_backend.domain.exception.OperationalBaseNotFoundException; +import pl.skyroster.skyroster_backend.generated.model.ErrorResponse; + +import java.time.OffsetDateTime; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(AircraftAlreadyExistsException.class) + public ResponseEntity handleAircraftAlreadyExists(AircraftAlreadyExistsException ex, + HttpServletRequest request) { + return buildResponse(HttpStatus.CONFLICT, ex.getMessage(), request); + } + + @ExceptionHandler(AircraftTypeNotFoundException.class) + public ResponseEntity handleAircraftTypeNotFound(AircraftTypeNotFoundException ex, + HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request); + } + + @ExceptionHandler(OperationalBaseNotFoundException.class) + public ResponseEntity handleOperationalBaseNotFound(OperationalBaseNotFoundException ex, + HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument(IllegalArgumentException ex, + HttpServletRequest request) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage(), request); + } + + private ResponseEntity buildResponse(HttpStatus status, String message, + HttpServletRequest request) { + ErrorResponse error = new ErrorResponse() + .status(status.value()) + .error(status.getReasonPhrase()) + .message(message) + .timestamp(OffsetDateTime.now()) + .path(request.getRequestURI()); + return ResponseEntity.status(status).body(error); + } +} diff --git a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java index ad5659a..2df005b 100644 --- a/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java +++ b/skyroster-backend/src/main/java/pl/skyroster/skyroster_backend/infrastructure/config/security/SecurityConfig.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -43,9 +44,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/health").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll() - .requestMatchers("/api/admin/**").hasRole("operations_administrator") .requestMatchers("/api/compliance/**").hasRole("compliance_officer") .requestMatchers("/api/planning/**").hasRole("schedule_planner") + .requestMatchers(HttpMethod.POST, "/api/aircraft/**").hasRole("operations_administrator") + .requestMatchers(HttpMethod.GET, "/api/aircraft/**").authenticated() .requestMatchers("/api/**").authenticated() .anyRequest().denyAll() ) diff --git a/skyroster-backend/src/main/resources/api/openapi.yaml b/skyroster-backend/src/main/resources/api/openapi.yaml index ca19940..2e8006f 100644 --- a/skyroster-backend/src/main/resources/api/openapi.yaml +++ b/skyroster-backend/src/main/resources/api/openapi.yaml @@ -41,20 +41,20 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' - /api/admin/users: + /api/compliance/reports: get: - operationId: getAdminUsers - tags: [Admin] - summary: List users (admin only) + operationId: getComplianceReports + tags: [Compliance] + summary: List compliance reports responses: '200': - description: List of users + description: List of compliance reports content: application/json: schema: type: array items: - $ref: '#/components/schemas/UserInfo' + $ref: '#/components/schemas/ComplianceReport' '401': description: Unauthorized content: @@ -68,20 +68,20 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' - /api/compliance/reports: + /api/planning/schedules: get: - operationId: getComplianceReports - tags: [Compliance] - summary: List compliance reports + operationId: getPlanningSchedules + tags: [Planning] + summary: List schedules responses: '200': - description: List of compliance reports + description: List of schedules content: application/json: schema: type: array items: - $ref: '#/components/schemas/ComplianceReport' + $ref: '#/components/schemas/Schedule' '401': description: Unauthorized content: @@ -95,20 +95,30 @@ paths: schema: $ref: '#/components/schemas/ErrorResponse' - /api/planning/schedules: - get: - operationId: getPlanningSchedules - tags: [Planning] - summary: List schedules + /api/aircraft: + post: + operationId: addAircraft + tags: [Aircraft] + summary: Add a new aircraft to the fleet + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/AddAircraftRequest' responses: - '200': - description: List of schedules + '201': + description: Aircraft created content: application/json: schema: - type: array - items: - $ref: '#/components/schemas/Schedule' + $ref: '#/components/schemas/AircraftResponse' + '400': + description: Bad request (invalid type or base) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': description: Unauthorized content: @@ -121,6 +131,31 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '409': + description: Aircraft with this registration already exists + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + get: + operationId: getAircraft + tags: [Aircraft] + summary: List all aircraft in the fleet + responses: + '200': + description: List of aircraft + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/AircraftResponse' + '401': + description: Unauthorized + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' components: securitySchemes: @@ -225,4 +260,77 @@ components: date: type: string format: date - example: "2026-03-20" \ No newline at end of file + example: "2026-03-20" + + AddAircraftRequest: + type: object + required: + - registrationNumber + - aircraftTypeCode + - operationalBaseCode + properties: + registrationNumber: + type: string + description: Aircraft registration number + example: "SP-LRA" + aircraftTypeCode: + type: string + description: ICAO aircraft type code + example: "B738" + operationalBaseCode: + type: string + description: ICAO operational base code + example: "EPWA" + + AircraftTypeInfo: + type: object + required: + - id + - icaoCode + - name + properties: + id: + type: string + format: uuid + icaoCode: + type: string + example: "B738" + name: + type: string + example: "Boeing 737-800" + + OperationalBaseInfo: + type: object + required: + - id + - icaoCode + - name + properties: + id: + type: string + format: uuid + icaoCode: + type: string + example: "EPWA" + name: + type: string + example: "Warsaw Chopin" + + AircraftResponse: + type: object + required: + - id + - registrationNumber + - aircraftType + - operationalBase + properties: + id: + type: string + format: uuid + registrationNumber: + type: string + example: "SP-LRA" + aircraftType: + $ref: '#/components/schemas/AircraftTypeInfo' + operationalBase: + $ref: '#/components/schemas/OperationalBaseInfo' \ No newline at end of file diff --git a/skyroster-backend/src/main/resources/db/migration/V2__aircraft_schema_and_seed.sql b/skyroster-backend/src/main/resources/db/migration/V2__aircraft_schema_and_seed.sql new file mode 100644 index 0000000..03ccf67 --- /dev/null +++ b/skyroster-backend/src/main/resources/db/migration/V2__aircraft_schema_and_seed.sql @@ -0,0 +1,31 @@ +CREATE TABLE operational_bases ( + id UUID PRIMARY KEY, + icao_code VARCHAR(4) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL +); + +CREATE TABLE aircraft_types ( + id UUID PRIMARY KEY, + icao_code VARCHAR(4) UNIQUE NOT NULL, + name VARCHAR(100) NOT NULL +); + +CREATE TABLE aircraft ( + id UUID PRIMARY KEY, + registration_number VARCHAR(15) UNIQUE NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + aircraft_type_id UUID NOT NULL REFERENCES aircraft_types(id), + operational_base_id UUID NOT NULL REFERENCES operational_bases(id) +); + +-- Seed: operational bases +INSERT INTO operational_bases (id, icao_code, name) VALUES + ('a0000000-0000-0000-0000-000000000001', 'EPWA', 'Warsaw Chopin'), + ('a0000000-0000-0000-0000-000000000002', 'EPKK', 'Krakow Balice'), + ('a0000000-0000-0000-0000-000000000003', 'EPGD', 'Gdansk Lech Walesa'); + +-- Seed: aircraft types +INSERT INTO aircraft_types (id, icao_code, name) VALUES + ('b0000000-0000-0000-0000-000000000001', 'B738', 'Boeing 737-800'), + ('b0000000-0000-0000-0000-000000000002', 'A320', 'Airbus A320'), + ('b0000000-0000-0000-0000-000000000003', 'AT76', 'ATR 72-600'); diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/application/aircraft/AddAircraftUseCaseTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/application/aircraft/AddAircraftUseCaseTest.java new file mode 100644 index 0000000..55266ad --- /dev/null +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/application/aircraft/AddAircraftUseCaseTest.java @@ -0,0 +1,100 @@ +package pl.skyroster.skyroster_backend.application.aircraft; + +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 pl.skyroster.skyroster_backend.domain.exception.AircraftAlreadyExistsException; +import pl.skyroster.skyroster_backend.domain.exception.AircraftTypeNotFoundException; +import pl.skyroster.skyroster_backend.domain.exception.OperationalBaseNotFoundException; +import pl.skyroster.skyroster_backend.domain.model.Aircraft; +import pl.skyroster.skyroster_backend.domain.model.AircraftType; +import pl.skyroster.skyroster_backend.domain.model.OperationalBase; +import pl.skyroster.skyroster_backend.domain.port.AircraftRepository; +import pl.skyroster.skyroster_backend.domain.port.AircraftTypeRepository; +import pl.skyroster.skyroster_backend.domain.port.OperationalBaseRepository; + +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AddAircraftUseCaseTest { + + @Mock + private AircraftRepository aircraftRepository; + @Mock + private AircraftTypeRepository aircraftTypeRepository; + @Mock + private OperationalBaseRepository operationalBaseRepository; + + private AddAircraftUseCase useCase; + + private static final String TYPE_CODE = "B738"; + private static final String BASE_CODE = "EPWA"; + private static final String REGISTRATION = "SP-LRA"; + + private final AircraftType type = new AircraftType(UUID.randomUUID(), TYPE_CODE, "Boeing 737-800"); + private final OperationalBase base = new OperationalBase(UUID.randomUUID(), BASE_CODE, "Warsaw Chopin"); + + @BeforeEach + void setUp() { + useCase = new AddAircraftUseCase(aircraftRepository, aircraftTypeRepository, operationalBaseRepository); + } + + @Test + void shouldAddAircraft_whenValidData() { + when(aircraftRepository.existsByRegistrationNumber(REGISTRATION)).thenReturn(false); + when(aircraftTypeRepository.findByIcaoCode(TYPE_CODE)).thenReturn(Optional.of(type)); + when(operationalBaseRepository.findByIcaoCode(BASE_CODE)).thenReturn(Optional.of(base)); + when(aircraftRepository.save(any(Aircraft.class))).thenAnswer(inv -> inv.getArgument(0)); + + Aircraft result = useCase.execute(REGISTRATION, TYPE_CODE, BASE_CODE); + + assertThat(result.getRegistrationNumber()).isEqualTo(REGISTRATION); + assertThat(result.getAircraftType()).isEqualTo(type); + assertThat(result.getOperationalBase()).isEqualTo(base); + assertThat(result.getId()).isNotNull(); + assertThat(result.getCreatedAt()).isNotNull(); + } + + @Test + void shouldThrow_whenRegistrationAlreadyExists() { + when(aircraftRepository.existsByRegistrationNumber(REGISTRATION)).thenReturn(true); + + assertThatThrownBy(() -> useCase.execute(REGISTRATION, TYPE_CODE, BASE_CODE)) + .isInstanceOf(AircraftAlreadyExistsException.class) + .hasMessageContaining(REGISTRATION); + } + + @Test + void shouldThrow_whenAircraftTypeNotFound() { + when(aircraftRepository.existsByRegistrationNumber(REGISTRATION)).thenReturn(false); + when(aircraftTypeRepository.findByIcaoCode(TYPE_CODE)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> useCase.execute(REGISTRATION, TYPE_CODE, BASE_CODE)) + .isInstanceOf(AircraftTypeNotFoundException.class); + } + + @Test + void shouldThrow_whenOperationalBaseNotFound() { + when(aircraftRepository.existsByRegistrationNumber(REGISTRATION)).thenReturn(false); + when(aircraftTypeRepository.findByIcaoCode(TYPE_CODE)).thenReturn(Optional.of(type)); + when(operationalBaseRepository.findByIcaoCode(BASE_CODE)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> useCase.execute(REGISTRATION, TYPE_CODE, BASE_CODE)) + .isInstanceOf(OperationalBaseNotFoundException.class); + } + + @Test + void shouldThrow_whenRegistrationIsBlank() { + assertThatThrownBy(() -> useCase.execute("", TYPE_CODE, BASE_CODE)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Registration number"); + } +} diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java new file mode 100644 index 0000000..94aeb6d --- /dev/null +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/AircraftIntegrationTest.java @@ -0,0 +1,207 @@ +package pl.skyroster.skyroster_backend.infrastructure.adapter.in.web; + +import dasniko.testcontainers.keycloak.KeycloakContainer; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import pl.skyroster.skyroster_backend.TestcontainersConfiguration; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfiguration.class) +class AircraftIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private KeycloakContainer keycloak; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void addAircraft_shouldReturn201_whenAdminWithValidData() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "registrationNumber": "SP-LRA", + "aircraftTypeCode": "B738", + "operationalBaseCode": "EPWA" + } + """; + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.registrationNumber").value("SP-LRA")) + .andExpect(jsonPath("$.aircraftType.icaoCode").value("B738")) + .andExpect(jsonPath("$.operationalBase.icaoCode").value("EPWA")) + .andExpect(jsonPath("$.id").exists()); + } + + @Test + void addAircraft_shouldReturn409_whenDuplicateRegistration() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "registrationNumber": "SP-DUP", + "aircraftTypeCode": "B738", + "operationalBaseCode": "EPWA" + } + """; + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.status").value(409)); + } + + @Test + void addAircraft_shouldReturn400_whenAircraftTypeNotFound() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "registrationNumber": "SP-BAD", + "aircraftTypeCode": "XXXX", + "operationalBaseCode": "EPWA" + } + """; + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void addAircraft_shouldReturn400_whenOperationalBaseNotFound() throws Exception { + String token = getToken("admin", "test1234"); + String body = """ + { + "registrationNumber": "SP-BAD2", + "aircraftTypeCode": "B738", + "operationalBaseCode": "XXXX" + } + """; + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.status").value(400)); + } + + @Test + void addAircraft_shouldReturn401_whenNoToken() throws Exception { + String body = """ + { + "registrationNumber": "SP-NO", + "aircraftTypeCode": "B738", + "operationalBaseCode": "EPWA" + } + """; + + mockMvc.perform(post("/api/aircraft") + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isUnauthorized()); + } + + @Test + void addAircraft_shouldReturn403_whenNotAdmin() throws Exception { + String token = getToken("schedule_planner", "test1234"); + String body = """ + { + "registrationNumber": "SP-NOPERM", + "aircraftTypeCode": "B738", + "operationalBaseCode": "EPWA" + } + """; + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isForbidden()); + } + + @Test + void getAircraft_shouldReturnFleetList() throws Exception { + String token = getToken("admin", "test1234"); + + String body = """ + { + "registrationNumber": "SP-GET", + "aircraftTypeCode": "A320", + "operationalBaseCode": "EPKK" + } + """; + + mockMvc.perform(post("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .contentType(MediaType.APPLICATION_JSON) + .content(body)) + .andExpect(status().isCreated()); + + mockMvc.perform(get("/api/aircraft") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$[?(@.registrationNumber == 'SP-GET')]").exists()); + } + + private String getToken(String username, String password) throws Exception { + String tokenUrl = keycloak.getAuthServerUrl() + "/realms/skyroster/protocol/openid-connect/token"; + + String reqBody = "grant_type=password" + + "&client_id=skyroster-frontend" + + "&username=" + username + + "&password=" + password; + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(tokenUrl)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(reqBody)) + .build(); + + HttpResponse response = HttpClient.newHttpClient() + .send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new RuntimeException("Failed to get token: " + response.body()); + } + + JsonNode json = objectMapper.readTree(response.body()); + return json.get("access_token").asText(); + } +} diff --git a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/SecurityIntegrationTest.java b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/SecurityIntegrationTest.java index 237ea8c..1afd0c0 100644 --- a/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/SecurityIntegrationTest.java +++ b/skyroster-backend/src/test/java/pl/skyroster/skyroster_backend/infrastructure/adapter/in/web/SecurityIntegrationTest.java @@ -7,7 +7,6 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.context.annotation.Import; import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import pl.skyroster.skyroster_backend.TestcontainersConfiguration; @@ -20,7 +19,6 @@ import com.fasterxml.jackson.databind.ObjectMapper; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -44,44 +42,6 @@ void healthEndpoint_shouldBeAccessible_whenNoToken() throws Exception { .andExpect(jsonPath("$.status").value("UP")); } - @Test - void adminEndpoint_shouldReturn401_whenNoToken() throws Exception { - mockMvc.perform(get("/api/admin/users")) - .andExpect(status().isUnauthorized()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(401)) - .andExpect(jsonPath("$.error").value("Unauthorized")) - .andExpect(jsonPath("$.path").value("/api/admin/users")); - } - - @Test - void adminEndpoint_shouldReturn401_whenInvalidToken() throws Exception { - mockMvc.perform(get("/api/admin/users") - .header(HttpHeaders.AUTHORIZATION, "Bearer invalid-token")) - .andExpect(status().isUnauthorized()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(401)); - } - - @Test - void adminEndpoint_shouldReturn403_whenSchedulePlannerToken() throws Exception { - String token = getToken("schedule_planner", "test1234"); - mockMvc.perform(get("/api/admin/users") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) - .andExpect(status().isForbidden()) - .andExpect(content().contentType(MediaType.APPLICATION_JSON)) - .andExpect(jsonPath("$.status").value(403)) - .andExpect(jsonPath("$.error").value("Forbidden")); - } - - @Test - void adminEndpoint_shouldReturn200_whenAdminToken() throws Exception { - String token = getToken("admin", "test1234"); - mockMvc.perform(get("/api/admin/users") - .header(HttpHeaders.AUTHORIZATION, "Bearer " + token)) - .andExpect(status().isOk()); - } - @Test void complianceEndpoint_shouldReturn200_whenComplianceToken() throws Exception { String token = getToken("compliance", "test1234"); diff --git a/skyroster-frontend/src/security/keycloak.js b/skyroster-frontend/src/security/keycloak.js index 81f8adc..1d3609a 100644 --- a/skyroster-frontend/src/security/keycloak.js +++ b/skyroster-frontend/src/security/keycloak.js @@ -3,7 +3,7 @@ import Keycloak from 'keycloak-js'; const keycloakConfig = { url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8180', realm: import.meta.env.VITE_KEYCLOAK_REALM || 'skyroster', - clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'skyroster-skyroster-frontend', + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'skyroster-frontend', }; const keycloak = new Keycloak(keycloakConfig);