From 0ce89dda4ca768a61480c06b6867e1d21fc436bc Mon Sep 17 00:00:00 2001 From: jamiewmeldrum Date: Sun, 16 Aug 2026 19:09:37 +0100 Subject: [PATCH 1/2] Split the app into web, service, persistence, storage and foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing below the controller speaks the wire contract, and nothing above the service speaks JPA. Entities take an Entity suffix so the bare noun belongs to the type that crosses a boundary rather than the one Hibernate manages; projections keep a Projection suffix and stay inside persistence. Shared enums, exception types and helpers move to foundation, which imports nothing of ours at all. Persistence and storage are two outbound adapters at the same level, and neither imports anything above it — the one property here a compiler can check. Nothing is called domain. The name was tried and dropped: logic lives in services, so there was no rich model for it to describe. Each layer owns the types it hands out, and the service's are records that carry what is safe to expose rather than shadowing an entity. Question carries its lock version and status; QuestionResponse carries neither. QuestionAttempt carries the session token; the response drops it rather than echoing a caller-supplied, credential-shaped value back into bodies and logs. Persistence answers the query it is handed and never asks who wants it or why. StudentQuestionQueryPolicy moves up to the service as the statement of what an audience may see; QuestionAccessor is the single place it is applied, built per audience and injected by qualifier so the choice is made once in a field declaration and a holder of the student accessor cannot make an admin read. What is left in the runner is mechanism — the specification, the stable (created_at, id) order, and the two-query concept stitch — with no policy and no idea one exists. The accessor also owns the one guarantee no other tier can make, that an id query matched at most one row: the runner cannot know that, the accessor can, because it built the query. Accessors exist only where there is a policy to apply or a cardinality expectation to enforce, so there is exactly one. Paging stops coercing what it cannot use. Micronaut's binder answers 200 to page=-1 and size=abc alike, which left paging as the one place a bad request passed silently. PageableQueryBinder refuses instead and names the rule: a non-numeric value raises the conversion error a bad filter already raises, and an out-of-range one is a 422. The size ceiling is stated back rather than clamped, because a client handed 50 rows after asking for 500 cannot tell a limit from the end of the data. Sorting is refused outright — the runners impose a total order so pages cannot straddle, so no client sort could ever be honoured — and PageableConfigurationGuard refuses to start the app if the two now-inert sort properties are set, comparing bound values rather than keys because relaxed binding admits three spellings of each. Every content field now states its cap at the entity and the column. The numbers are sized against the threat they defend — an extraction bug pasting a document into one row — rather than against a verbose author, and mark scheme is never capped below the question it marks. The session token is bounded at 64 at four layers, because it is client-supplied and indexed: unbounded, an oversized token failed at insert on Postgres's btree entry limit instead of at validation. Tests follow the same structure and the same rule. QuestionServiceAccessorCT is the one that matters most: the qualifier is all that binds the service to the student policy, and getting it wrong would widen every read silently, so the test resolves the specification the service caused and asserts the restrictions it carries. It was checked against a policy with those restrictions removed, and fails. Fixtures insert their own rows rather than hiding behind helpers, and the performance pins lost the comments describing what each number was made of — after one refactor those leave a correct number with a false explanation. --- CLAUDE.md | 7 + README.md | 102 +++++++++- .../controller/QuestionAttemptController.java | 50 ----- .../controller/QuestionController.java | 38 ---- .../java/com/practiq/domain/Document.java | 56 ----- .../com/practiq/domain/QuestionAttempt.java | 45 ---- .../com/practiq/domain/identity/UserRef.java | 10 - .../domain/projection/LinkedQuestion.java | 6 - .../projection/QuestionConceptLink.java | 10 - .../QuestionAttemptSpecificationFactory.java | 30 --- .../query/question/QuestionQueryPolicy.java | 11 - .../query/question/QuestionQueryRunner.java | 84 -------- .../question/StudentQuestionQueryRunner.java | 19 -- .../dto/mapper/ConceptResponseMapper.java | 14 -- .../dto/mapper/MarkSchemeResponseMapper.java | 15 -- .../mapper/QuestionAttemptResponseMapper.java | 18 -- .../dto/mapper/QuestionResponseMapper.java | 38 ---- .../practiq/dto/response/ConceptResponse.java | 24 --- .../dto/response/MarkSchemeResponse.java | 24 --- .../dto/response/QuestionAttemptResponse.java | 24 --- .../dto/response/QuestionResponse.java | 35 ---- .../exception/ContentTooLargeException.java | 2 +- .../exception/EntityValidationError.java | 2 +- .../exception/EntityValidationException.java | 2 +- .../exception/UnauthorizedException.java | 2 +- .../types/DocumentStatus.java | 2 +- .../types/QuestionAuthorship.java | 2 +- .../types/QuestionDifficulty.java | 2 +- .../types/QuestionStatus.java | 2 +- .../types/QuestionType.java | 2 +- .../{ => foundation}/util/StringUtil.java | 2 +- .../ConceptEntity.java} | 15 +- .../practiq/persistence/DocumentEntity.java | 60 ++++++ .../MarkSchemeEntity.java} | 13 +- .../persistence/QuestionAttemptEntity.java | 54 +++++ .../QuestionConceptEntity.java} | 16 +- .../QuestionConceptEntityId.java} | 8 +- .../QuestionEntity.java} | 30 ++- .../QuestionOriginEntity.java} | 25 +-- ...onDifficultyEntityAttributeConverter.java} | 6 +- .../QuestionConceptLinkProjection.java | 10 + .../query/QueryRestriction.java | 2 +- .../query/QuerySpecificationFactory.java | 2 +- .../query/SessionTokenQueryRestriction.java} | 6 +- .../query/UserRestrictedQuery.java | 2 +- .../query/attempt/QuestionAttemptQuery.java | 4 +- .../attempt/QuestionAttemptQueryRunner.java | 21 +- .../QuestionAttemptSpecificationFactory.java | 30 +++ .../query/question/QuestionQuery.java | 8 +- .../query/question/QuestionQueryRunner.java | 86 ++++++++ .../QuestionSpecificationFactory.java | 61 +++--- .../question/QuestionWithConceptIds.java | 8 + .../repository/ConceptRepository.java | 8 +- .../repository/DocumentRepository.java | 6 +- .../repository/MarkSchemeRepository.java | 8 +- .../repository/QuestionAttemptRepository.java | 6 +- .../repository/QuestionConceptRepository.java | 22 ++ .../repository/QuestionRepository.java | 10 + .../repository/QuestionConceptRepository.java | 22 -- .../repository/QuestionOriginRepository.java | 8 - .../repository/QuestionRepository.java | 9 - .../practiq/service/MarkSchemeService.java | 37 ---- .../service/QuestionAttemptService.java | 57 ------ .../com/practiq/service/QuestionService.java | 42 ---- .../java/com/practiq/service/UserRef.java | 21 ++ .../attempt/QuestionAttemptService.java | 67 ++++++ .../dto/request/QuestionAttemptCommand.java | 25 +++ .../attempt/dto/response/QuestionAttempt.java | 5 + .../attempt/mapper/QuestionAttemptMapper.java | 16 ++ .../service/{ => concept}/ConceptService.java | 17 +- .../service/concept/dto/response/Concept.java | 5 + .../service/concept/mapper/ConceptMapper.java | 15 ++ .../{ => document}/DocumentService.java | 24 +-- .../service/document/DocumentStager.java | 9 +- .../document/StagedDocumentUpload.java | 22 +- .../DocumentPresignUploadCommand.java} | 24 ++- .../dto/response/DocumentPresignUpload.java | 5 + .../service/markscheme/MarkSchemeService.java | 37 ++++ .../markscheme/dto/response/MarkScheme.java | 5 + .../markscheme/mapper/MarkSchemeMapper.java | 15 ++ .../service/question/QuestionAccessor.java | 47 +++++ .../question/QuestionAccessorFactory.java | 23 +++ .../service/question/QuestionService.java | 37 ++++ .../dto/request/QuestionSearchCriteria.java | 13 ++ .../question/dto/response/Question.java | 17 ++ .../question/mapper/QuestionMapper.java | 22 ++ .../question/policy/QuestionQueryPolicy.java | 15 ++ .../policy}/StudentQuestionQueryPolicy.java | 15 +- .../storage/PresignedUploadRequest.java | 2 +- .../{http => web}/AdminKeyValidator.java | 8 +- .../practiq/{http => web}/HttpConstants.java | 2 +- .../binder/PageableConfigurationGuard.java | 39 ++++ .../web/binder/PageableQueryBinder.java | 108 ++++++++++ .../controller/AdminDocumentController.java | 25 ++- .../controller/ConceptController.java | 20 +- .../controller/MarkSchemeController.java | 15 +- .../controller/QuestionAttemptController.java | 65 ++++++ .../web/controller/QuestionController.java | 53 +++++ .../QuestionDifficultyTypeConverter.java | 4 +- .../web/dto/mapper/ConceptResponseMapper.java | 13 ++ .../dto/mapper/ErrorResponseMapper.java | 6 +- .../dto/mapper/MarkSchemeResponseMapper.java | 15 ++ .../mapper/QuestionAttemptResponseMapper.java | 28 +++ .../dto/mapper/QuestionResponseMapper.java | 31 +++ .../mapper/UploadDocumentResponseMapper.java | 15 ++ .../dto/request/PostDocumentRequest.java | 7 +- .../dto/request/QuestionAttemptRequest.java | 2 +- .../dto/request/QuestionRequest.java | 9 +- .../web/dto/response/ConceptResponse.java | 7 + .../{ => web}/dto/response/ErrorResponse.java | 2 +- .../web/dto/response/MarkSchemeResponse.java | 7 + .../{ => web}/dto/response/PageResponse.java | 2 +- .../dto/response/QuestionAttemptResponse.java | 7 + .../response/QuestionDifficultyResponse.java | 4 +- .../web/dto/response/QuestionResponse.java | 15 ++ .../dto/response/UploadDocumentResponse.java | 2 +- .../ConstraintViolationExceptionHandler.java | 4 +- .../handler/ContentLengthExceededHandler.java | 4 +- .../ContentTooLargeExceptionHandler.java | 8 +- .../ConversionErrorExceptionHandler.java | 4 +- .../EntityValidationExceptionHandler.java | 8 +- .../handler/GenericExceptionHandler.java | 4 +- .../handler/NotFoundExceptionHandler.java | 4 +- .../handler/UnauthorizedExceptionHandler.java | 6 +- .../UnsatisfiedArgumentExceptionHandler.java | 4 +- .../UnsatisfiedBodyRouteExceptionHandler.java | 4 +- .../UnsatisfiedRouteExceptionHandler.java | 4 +- .../V10__entity_field_restrictions.sql | 17 ++ .../migration/V9__document_field_lengths.sql | 4 + .../java/com/practiq/controller/PagingCT.java | 116 ----------- .../StudentQuestionQueryRunnerTest.java | 150 -------------- .../dto/mapper/ConceptResponseMapperTest.java | 32 --- .../mapper/MarkSchemeResponseMapperTest.java | 33 --- .../QuestionAttemptResponseMapperTest.java | 34 ---- .../mapper/QuestionResponseMapperTest.java | 73 ------- .../types/QuestionDifficultyTest.java | 2 +- .../{ => foundation}/util/StringUtilTest.java | 2 +- ...fficultyEntityAttributeConverterTest.java} | 9 +- .../SessionTokenQueryRestrictionTest.java} | 11 +- .../QuestionAttemptQueryRunnerTest.java | 45 +--- .../question/QuestionQueryRunnerTest.java | 119 +++++++++++ .../practiq/service/ConceptServiceTest.java | 119 ----------- .../service/QuestionAttemptServiceTest.java | 166 --------------- .../QuestionAttemptServiceValidationCT.java | 56 ----- .../practiq/service/QuestionServiceTest.java | 147 -------------- .../service/QuestionServiceValidationCT.java | 30 --- .../java/com/practiq/service/UserRefTest.java | 47 +++++ .../attempt/QuestionAttemptServiceTest.java | 135 ++++++++++++ .../request/QuestionAttemptCommandTest.java | 45 ++++ .../mapper/QuestionAttemptMapperTest.java | 36 ++++ .../service/concept/ConceptServiceTest.java | 136 +++++++++++++ .../concept/mapper/ConceptMapperTest.java | 38 ++++ .../DocumentPresignUploadCommandTest.java | 130 ++++++++++++ .../{ => document}/DocumentServiceTest.java | 46 ++--- .../service/document/DocumentStagerTest.java | 68 ++++--- .../document/DocumentUploadCommandTest.java | 98 --------- .../document/StagedDocumentUploadTest.java | 115 ----------- .../MarkSchemeServiceTest.java | 56 +++-- .../mapper/MarkSchemeMapperTest.java | 38 ++++ .../question/QuestionAccessorTest.java | 75 +++++++ .../question/QuestionServiceAccessorCT.java | 94 +++++++++ .../request/QuestionSearchCriteriaTest.java | 42 ++++ .../question/mapper/QuestionMapperTest.java | 46 +++++ .../StudentQuestionQueryPolicyTest.java | 9 +- .../{http => web}/AdminKeyValidatorTest.java | 4 +- .../PageableConfigurationGuardTest.java | 65 ++++++ .../web/binder/PageableTestController.java | 33 +++ .../java/com/practiq/web/binder/PagingCT.java | 192 ++++++++++++++++++ .../controller/AdminDocumentControllerCT.java | 132 +++++++++--- .../controller/ConceptControllerCT.java | 32 ++- .../controller/MarkSchemeControllerCT.java | 42 +++- .../QuestionAttemptControllerCT.java | 109 ++++++++-- .../controller/QuestionControllerCT.java | 90 +++++--- .../QuestionDifficultyTypeConverterTest.java | 4 +- .../dto/mapper/ConceptResponseMapperTest.java | 32 +++ .../dto/mapper/ErrorResponseMapperTest.java | 6 +- .../mapper/MarkSchemeResponseMapperTest.java | 32 +++ .../QuestionAttemptResponseMapperTest.java | 45 ++++ .../mapper/QuestionResponseMapperTest.java | 82 ++++++++ .../UploadDocumentResponseMapperTest.java | 28 +++ ...nstraintViolationExceptionHandlerTest.java | 4 +- .../ContentLengthExceededHandlerTest.java | 4 +- .../ContentTooLargeExceptionHandlerTest.java | 6 +- .../ConversionErrorExceptionHandlerTest.java | 6 +- .../EntityValidationExceptionHandlerTest.java | 6 +- .../handler/ErrorHandlingCT.java | 2 +- .../handler/ErrorHandlingTestController.java | 2 +- .../handler/GenericExceptionHandlerTest.java | 29 +++ .../handler/NotFoundExceptionHandlerTest.java | 4 +- .../UnauthorizedExceptionHandlerTest.java | 6 +- ...satisfiedArgumentExceptionHandlerTest.java | 4 +- ...atisfiedBodyRouteExceptionHandlerTest.java | 4 +- .../UnsatisfiedRouteExceptionHandlerTest.java | 4 +- .../integration/db/ConceptDatabaseIT.java | 30 ++- .../integration/db/DocumentDatabaseIT.java | 51 ++++- .../integration/db/MarkSchemeDatabaseIT.java | 29 +++ .../db/QuestionAttemptDatabaseIT.java | 52 +++++ .../integration/db/QuestionDatabaseIT.java | 24 ++- .../db/QuestionOriginDatabaseIT.java | 2 +- .../e2e/AdminDocumentControllerIT.java | 8 +- .../e2e/MarkSchemeControllerIT.java | 28 +-- .../e2e/QuestionAttemptControllerIT.java | 2 +- .../integration/e2e/QuestionControllerIT.java | 106 +++++++--- .../repository/ConceptRepositoryIT.java | 54 +++-- .../repository/DocumentRepositoryIT.java | 86 ++++++-- .../repository/MarkSchemeRepositoryIT.java | 44 +++- .../QuestionAttemptRepositoryIT.java | 81 +++++--- .../QuestionConceptRepositoryIT.java | 16 +- .../repository/QuestionRepositoryIT.java | 118 +++++++---- .../java/performance/AdminDocumentPostPT.java | 2 - src/test/java/performance/ConceptByIdPT.java | 2 - .../java/performance/ConceptCataloguePT.java | 20 +- src/test/java/performance/MarkSchemePT.java | 18 +- .../java/performance/QuestionAttemptPT.java | 7 +- .../performance/QuestionAttemptPostPT.java | 2 +- src/test/java/performance/QuestionByIdPT.java | 21 +- .../java/performance/QuestionCataloguePT.java | 39 ++-- src/test/java/utils/data/TestData.java | 19 +- 218 files changed, 4081 insertions(+), 2658 deletions(-) delete mode 100644 src/main/java/com/practiq/controller/QuestionAttemptController.java delete mode 100644 src/main/java/com/practiq/controller/QuestionController.java delete mode 100644 src/main/java/com/practiq/domain/Document.java delete mode 100644 src/main/java/com/practiq/domain/QuestionAttempt.java delete mode 100644 src/main/java/com/practiq/domain/identity/UserRef.java delete mode 100644 src/main/java/com/practiq/domain/projection/LinkedQuestion.java delete mode 100644 src/main/java/com/practiq/domain/projection/QuestionConceptLink.java delete mode 100644 src/main/java/com/practiq/domain/query/attempt/QuestionAttemptSpecificationFactory.java delete mode 100644 src/main/java/com/practiq/domain/query/question/QuestionQueryPolicy.java delete mode 100644 src/main/java/com/practiq/domain/query/question/QuestionQueryRunner.java delete mode 100644 src/main/java/com/practiq/domain/query/question/StudentQuestionQueryRunner.java delete mode 100644 src/main/java/com/practiq/dto/mapper/ConceptResponseMapper.java delete mode 100644 src/main/java/com/practiq/dto/mapper/MarkSchemeResponseMapper.java delete mode 100644 src/main/java/com/practiq/dto/mapper/QuestionAttemptResponseMapper.java delete mode 100644 src/main/java/com/practiq/dto/mapper/QuestionResponseMapper.java delete mode 100644 src/main/java/com/practiq/dto/response/ConceptResponse.java delete mode 100644 src/main/java/com/practiq/dto/response/MarkSchemeResponse.java delete mode 100644 src/main/java/com/practiq/dto/response/QuestionAttemptResponse.java delete mode 100644 src/main/java/com/practiq/dto/response/QuestionResponse.java rename src/main/java/com/practiq/{ => foundation}/exception/ContentTooLargeException.java (93%) rename src/main/java/com/practiq/{ => foundation}/exception/EntityValidationError.java (65%) rename src/main/java/com/practiq/{ => foundation}/exception/EntityValidationException.java (94%) rename src/main/java/com/practiq/{ => foundation}/exception/UnauthorizedException.java (82%) rename src/main/java/com/practiq/{domain => foundation}/types/DocumentStatus.java (74%) rename src/main/java/com/practiq/{domain => foundation}/types/QuestionAuthorship.java (67%) rename src/main/java/com/practiq/{domain => foundation}/types/QuestionDifficulty.java (94%) rename src/main/java/com/practiq/{domain => foundation}/types/QuestionStatus.java (65%) rename src/main/java/com/practiq/{domain => foundation}/types/QuestionType.java (65%) rename src/main/java/com/practiq/{ => foundation}/util/StringUtil.java (91%) rename src/main/java/com/practiq/{domain/Concept.java => persistence/ConceptEntity.java} (59%) create mode 100644 src/main/java/com/practiq/persistence/DocumentEntity.java rename src/main/java/com/practiq/{domain/MarkScheme.java => persistence/MarkSchemeEntity.java} (68%) create mode 100644 src/main/java/com/practiq/persistence/QuestionAttemptEntity.java rename src/main/java/com/practiq/{domain/QuestionConcept.java => persistence/QuestionConceptEntity.java} (50%) rename src/main/java/com/practiq/{domain/QuestionConceptId.java => persistence/QuestionConceptEntityId.java} (59%) rename src/main/java/com/practiq/{domain/Question.java => persistence/QuestionEntity.java} (55%) rename src/main/java/com/practiq/{domain/QuestionOrigin.java => persistence/QuestionOriginEntity.java} (52%) rename src/main/java/com/practiq/{domain/converters/QuestionDifficultyAttributeConverter.java => persistence/converter/QuestionDifficultyEntityAttributeConverter.java} (72%) create mode 100644 src/main/java/com/practiq/persistence/projection/QuestionConceptLinkProjection.java rename src/main/java/com/practiq/{domain => persistence}/query/QueryRestriction.java (92%) rename src/main/java/com/practiq/{domain => persistence}/query/QuerySpecificationFactory.java (95%) rename src/main/java/com/practiq/{domain/query/SessionTokenRestriction.java => persistence/query/SessionTokenQueryRestriction.java} (72%) rename src/main/java/com/practiq/{domain => persistence}/query/UserRestrictedQuery.java (63%) rename src/main/java/com/practiq/{domain => persistence}/query/attempt/QuestionAttemptQuery.java (50%) rename src/main/java/com/practiq/{domain => persistence}/query/attempt/QuestionAttemptQueryRunner.java (55%) create mode 100644 src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptSpecificationFactory.java rename src/main/java/com/practiq/{domain => persistence}/query/question/QuestionQuery.java (65%) create mode 100644 src/main/java/com/practiq/persistence/query/question/QuestionQueryRunner.java rename src/main/java/com/practiq/{domain => persistence}/query/question/QuestionSpecificationFactory.java (54%) create mode 100644 src/main/java/com/practiq/persistence/query/question/QuestionWithConceptIds.java rename src/main/java/com/practiq/{ => persistence}/repository/ConceptRepository.java (56%) rename src/main/java/com/practiq/{ => persistence}/repository/DocumentRepository.java (61%) rename src/main/java/com/practiq/{ => persistence}/repository/MarkSchemeRepository.java (53%) rename src/main/java/com/practiq/{ => persistence}/repository/QuestionAttemptRepository.java (51%) create mode 100644 src/main/java/com/practiq/persistence/repository/QuestionConceptRepository.java create mode 100644 src/main/java/com/practiq/persistence/repository/QuestionRepository.java delete mode 100644 src/main/java/com/practiq/repository/QuestionConceptRepository.java delete mode 100644 src/main/java/com/practiq/repository/QuestionOriginRepository.java delete mode 100644 src/main/java/com/practiq/repository/QuestionRepository.java delete mode 100644 src/main/java/com/practiq/service/MarkSchemeService.java delete mode 100644 src/main/java/com/practiq/service/QuestionAttemptService.java delete mode 100644 src/main/java/com/practiq/service/QuestionService.java create mode 100644 src/main/java/com/practiq/service/UserRef.java create mode 100644 src/main/java/com/practiq/service/attempt/QuestionAttemptService.java create mode 100644 src/main/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommand.java create mode 100644 src/main/java/com/practiq/service/attempt/dto/response/QuestionAttempt.java create mode 100644 src/main/java/com/practiq/service/attempt/mapper/QuestionAttemptMapper.java rename src/main/java/com/practiq/service/{ => concept}/ConceptService.java (58%) create mode 100644 src/main/java/com/practiq/service/concept/dto/response/Concept.java create mode 100644 src/main/java/com/practiq/service/concept/mapper/ConceptMapper.java rename src/main/java/com/practiq/service/{ => document}/DocumentService.java (62%) rename src/main/java/com/practiq/service/document/{DocumentUploadCommand.java => dto/request/DocumentPresignUploadCommand.java} (50%) create mode 100644 src/main/java/com/practiq/service/document/dto/response/DocumentPresignUpload.java create mode 100644 src/main/java/com/practiq/service/markscheme/MarkSchemeService.java create mode 100644 src/main/java/com/practiq/service/markscheme/dto/response/MarkScheme.java create mode 100644 src/main/java/com/practiq/service/markscheme/mapper/MarkSchemeMapper.java create mode 100644 src/main/java/com/practiq/service/question/QuestionAccessor.java create mode 100644 src/main/java/com/practiq/service/question/QuestionAccessorFactory.java create mode 100644 src/main/java/com/practiq/service/question/QuestionService.java create mode 100644 src/main/java/com/practiq/service/question/dto/request/QuestionSearchCriteria.java create mode 100644 src/main/java/com/practiq/service/question/dto/response/Question.java create mode 100644 src/main/java/com/practiq/service/question/mapper/QuestionMapper.java create mode 100644 src/main/java/com/practiq/service/question/policy/QuestionQueryPolicy.java rename src/main/java/com/practiq/{domain/query/question => service/question/policy}/StudentQuestionQueryPolicy.java (53%) rename src/main/java/com/practiq/{http => web}/AdminKeyValidator.java (79%) rename src/main/java/com/practiq/{http => web}/HttpConstants.java (95%) create mode 100644 src/main/java/com/practiq/web/binder/PageableConfigurationGuard.java create mode 100644 src/main/java/com/practiq/web/binder/PageableQueryBinder.java rename src/main/java/com/practiq/{ => web}/controller/AdminDocumentController.java (54%) rename src/main/java/com/practiq/{ => web}/controller/ConceptController.java (54%) rename src/main/java/com/practiq/{ => web}/controller/MarkSchemeController.java (57%) create mode 100644 src/main/java/com/practiq/web/controller/QuestionAttemptController.java create mode 100644 src/main/java/com/practiq/web/controller/QuestionController.java rename src/main/java/com/practiq/{ => web}/dto/converter/QuestionDifficultyTypeConverter.java (90%) create mode 100644 src/main/java/com/practiq/web/dto/mapper/ConceptResponseMapper.java rename src/main/java/com/practiq/{ => web}/dto/mapper/ErrorResponseMapper.java (66%) create mode 100644 src/main/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapper.java create mode 100644 src/main/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapper.java create mode 100644 src/main/java/com/practiq/web/dto/mapper/QuestionResponseMapper.java create mode 100644 src/main/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapper.java rename src/main/java/com/practiq/{ => web}/dto/request/PostDocumentRequest.java (61%) rename src/main/java/com/practiq/{ => web}/dto/request/QuestionAttemptRequest.java (86%) rename src/main/java/com/practiq/{ => web}/dto/request/QuestionRequest.java (60%) create mode 100644 src/main/java/com/practiq/web/dto/response/ConceptResponse.java rename src/main/java/com/practiq/{ => web}/dto/response/ErrorResponse.java (75%) create mode 100644 src/main/java/com/practiq/web/dto/response/MarkSchemeResponse.java rename src/main/java/com/practiq/{ => web}/dto/response/PageResponse.java (96%) create mode 100644 src/main/java/com/practiq/web/dto/response/QuestionAttemptResponse.java rename src/main/java/com/practiq/{ => web}/dto/response/QuestionDifficultyResponse.java (80%) create mode 100644 src/main/java/com/practiq/web/dto/response/QuestionResponse.java rename src/main/java/com/practiq/{ => web}/dto/response/UploadDocumentResponse.java (81%) rename src/main/java/com/practiq/{exception => web}/handler/ConstraintViolationExceptionHandler.java (95%) rename src/main/java/com/practiq/{exception => web}/handler/ContentLengthExceededHandler.java (94%) rename src/main/java/com/practiq/{exception => web}/handler/ContentTooLargeExceptionHandler.java (82%) rename src/main/java/com/practiq/{exception => web}/handler/ConversionErrorExceptionHandler.java (96%) rename src/main/java/com/practiq/{exception => web}/handler/EntityValidationExceptionHandler.java (82%) rename src/main/java/com/practiq/{exception => web}/handler/GenericExceptionHandler.java (91%) rename src/main/java/com/practiq/{exception => web}/handler/NotFoundExceptionHandler.java (92%) rename src/main/java/com/practiq/{exception => web}/handler/UnauthorizedExceptionHandler.java (88%) rename src/main/java/com/practiq/{exception => web}/handler/UnsatisfiedArgumentExceptionHandler.java (93%) rename src/main/java/com/practiq/{exception => web}/handler/UnsatisfiedBodyRouteExceptionHandler.java (92%) rename src/main/java/com/practiq/{exception => web}/handler/UnsatisfiedRouteExceptionHandler.java (93%) create mode 100644 src/main/resources/db/migration/V10__entity_field_restrictions.sql create mode 100644 src/main/resources/db/migration/V9__document_field_lengths.sql delete mode 100644 src/test/java/com/practiq/controller/PagingCT.java delete mode 100644 src/test/java/com/practiq/domain/query/question/StudentQuestionQueryRunnerTest.java delete mode 100644 src/test/java/com/practiq/dto/mapper/ConceptResponseMapperTest.java delete mode 100644 src/test/java/com/practiq/dto/mapper/MarkSchemeResponseMapperTest.java delete mode 100644 src/test/java/com/practiq/dto/mapper/QuestionAttemptResponseMapperTest.java delete mode 100644 src/test/java/com/practiq/dto/mapper/QuestionResponseMapperTest.java rename src/test/java/com/practiq/{domain => foundation}/types/QuestionDifficultyTest.java (97%) rename src/test/java/com/practiq/{ => foundation}/util/StringUtilTest.java (94%) rename src/test/java/com/practiq/{domain/converters/QuestionDifficultyAttributeConverterTest.java => persistence/converter/QuestionDifficultyEntityAttributeConverterTest.java} (82%) rename src/test/java/com/practiq/{domain/query/SessionTokenRestrictionTest.java => persistence/query/SessionTokenQueryRestrictionTest.java} (86%) rename src/test/java/com/practiq/{domain => persistence}/query/attempt/QuestionAttemptQueryRunnerTest.java (55%) create mode 100644 src/test/java/com/practiq/persistence/query/question/QuestionQueryRunnerTest.java delete mode 100644 src/test/java/com/practiq/service/ConceptServiceTest.java delete mode 100644 src/test/java/com/practiq/service/QuestionAttemptServiceTest.java delete mode 100644 src/test/java/com/practiq/service/QuestionAttemptServiceValidationCT.java delete mode 100644 src/test/java/com/practiq/service/QuestionServiceTest.java delete mode 100644 src/test/java/com/practiq/service/QuestionServiceValidationCT.java create mode 100644 src/test/java/com/practiq/service/UserRefTest.java create mode 100644 src/test/java/com/practiq/service/attempt/QuestionAttemptServiceTest.java create mode 100644 src/test/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommandTest.java create mode 100644 src/test/java/com/practiq/service/attempt/mapper/QuestionAttemptMapperTest.java create mode 100644 src/test/java/com/practiq/service/concept/ConceptServiceTest.java create mode 100644 src/test/java/com/practiq/service/concept/mapper/ConceptMapperTest.java create mode 100644 src/test/java/com/practiq/service/document/DocumentPresignUploadCommandTest.java rename src/test/java/com/practiq/service/{ => document}/DocumentServiceTest.java (61%) delete mode 100644 src/test/java/com/practiq/service/document/DocumentUploadCommandTest.java delete mode 100644 src/test/java/com/practiq/service/document/StagedDocumentUploadTest.java rename src/test/java/com/practiq/service/{ => markscheme}/MarkSchemeServiceTest.java (50%) create mode 100644 src/test/java/com/practiq/service/markscheme/mapper/MarkSchemeMapperTest.java create mode 100644 src/test/java/com/practiq/service/question/QuestionAccessorTest.java create mode 100644 src/test/java/com/practiq/service/question/QuestionServiceAccessorCT.java create mode 100644 src/test/java/com/practiq/service/question/dto/request/QuestionSearchCriteriaTest.java create mode 100644 src/test/java/com/practiq/service/question/mapper/QuestionMapperTest.java rename src/test/java/com/practiq/{domain/query/question => service/question/policy}/StudentQuestionQueryPolicyTest.java (85%) rename src/test/java/com/practiq/{http => web}/AdminKeyValidatorTest.java (96%) create mode 100644 src/test/java/com/practiq/web/binder/PageableConfigurationGuardTest.java create mode 100644 src/test/java/com/practiq/web/binder/PageableTestController.java create mode 100644 src/test/java/com/practiq/web/binder/PagingCT.java rename src/test/java/com/practiq/{ => web}/controller/AdminDocumentControllerCT.java (76%) rename src/test/java/com/practiq/{ => web}/controller/ConceptControllerCT.java (80%) rename src/test/java/com/practiq/{ => web}/controller/MarkSchemeControllerCT.java (72%) rename src/test/java/com/practiq/{ => web}/controller/QuestionAttemptControllerCT.java (77%) rename src/test/java/com/practiq/{ => web}/controller/QuestionControllerCT.java (82%) rename src/test/java/com/practiq/{ => web}/dto/converter/QuestionDifficultyTypeConverterTest.java (94%) create mode 100644 src/test/java/com/practiq/web/dto/mapper/ConceptResponseMapperTest.java rename src/test/java/com/practiq/{ => web}/dto/mapper/ErrorResponseMapperTest.java (88%) create mode 100644 src/test/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapperTest.java create mode 100644 src/test/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapperTest.java create mode 100644 src/test/java/com/practiq/web/dto/mapper/QuestionResponseMapperTest.java create mode 100644 src/test/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapperTest.java rename src/test/java/com/practiq/{exception => web}/handler/ConstraintViolationExceptionHandlerTest.java (97%) rename src/test/java/com/practiq/{exception => web}/handler/ContentLengthExceededHandlerTest.java (92%) rename src/test/java/com/practiq/{exception => web}/handler/ContentTooLargeExceptionHandlerTest.java (88%) rename src/test/java/com/practiq/{exception => web}/handler/ConversionErrorExceptionHandlerTest.java (95%) rename src/test/java/com/practiq/{exception => web}/handler/EntityValidationExceptionHandlerTest.java (88%) rename src/test/java/com/practiq/{exception => web}/handler/ErrorHandlingCT.java (99%) rename src/test/java/com/practiq/{exception => web}/handler/ErrorHandlingTestController.java (97%) create mode 100644 src/test/java/com/practiq/web/handler/GenericExceptionHandlerTest.java rename src/test/java/com/practiq/{exception => web}/handler/NotFoundExceptionHandlerTest.java (92%) rename src/test/java/com/practiq/{exception => web}/handler/UnauthorizedExceptionHandlerTest.java (87%) rename src/test/java/com/practiq/{exception => web}/handler/UnsatisfiedArgumentExceptionHandlerTest.java (93%) rename src/test/java/com/practiq/{exception => web}/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java (93%) rename src/test/java/com/practiq/{exception => web}/handler/UnsatisfiedRouteExceptionHandlerTest.java (96%) diff --git a/CLAUDE.md b/CLAUDE.md index 27c594e..5a00430 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,6 +122,7 @@ com.practiq ai/ AIService interface + StubAIService + ClaudeAIService ingestion/ async ingestion job + extractor HTTP client (Sprint 1.2+) config/ configuration + util/ cross-cutting helpers used across layers (e.g. formatting, ids) dto/ request/response records — never expose entities dto/mapper/ static mapper methods (ConceptResponseMapper, QuestionResponseMapper, …) ``` @@ -372,6 +373,12 @@ which is true of all three; "integration" is a conceptual promise the suffix nev it depends on — globally-seeded data hides what a test actually relies on. - **Statics only for values that must change together** (endpoint paths, pinned statement counts, stub sentinels). Test data values are per-test locals. +- **Tests never import an application constant.** A test asserting against a production constant (e.g. + `MAX_CONTENT_LENGTH`) moves in lockstep with it — change the constant and every test still passes, having proved + nothing; the value is untested by construction. Restate the expected value test-side, so production and test are two + independent statements of the same rule and a change on either is a failure someone has to look at. (It earned this + immediately: raising the cap to 25MB broke three tests that would otherwise have gone green in silence.) The one + exception is a static import of the *method under test* — that's the subject, not an expected value. - **Never mock entities.** Build real instances + `TestReflection.setField` for DB-assigned fields (`id`, `createdAt`, `version`) — a mocked entity answers whatever you stubbed and can't catch a mapper reading the wrong field. Reflection for anything a constructor *could* set is a missing constructor, not a convention. diff --git a/README.md b/README.md index 5e40b4f..7f08e95 100644 --- a/README.md +++ b/README.md @@ -279,6 +279,21 @@ change, not a data artefact. | `GET /api/v1/questions` | paginated, filterable question catalogue — see below | | `POST /api/v1/admin/documents` | registers a document and returns a presigned upload URL — see below | +### Validation applied to every route + +| Sent | Answer | +|---|---| +| a path id below `1` | **422** `id: must be greater than or equal to 1` — an id that can never name a row is a bad value, not an absent one | +| a path id that is not a number | **400** `id: invalid value` | +| `page`/`size` that are not numbers | **400** — the same conversion failure a bad filter gives | +| `page` below `0`, `size` below `1` | **422**, naming the rule | +| `size` above `micronaut.data.pageable.max-page-size` | **422** `size: must not be greater than 50` — stated back rather than silently clamped, because a client handed 50 rows after asking for 500 cannot tell a ceiling from the end of the data | +| `sort` on any endpoint | **422** `sort: is not supported` — the query runners impose a total order so pages cannot straddle, so no client sort could be honoured | +| a blank or over-64-character `X-Session-Token` | **422**, before any lookup runs | + +Paging past the end is **not** an error: the envelope echoes the requested position with no rows, which is +what lets a client walk pages without knowing the total in advance. + ### `GET /api/v1/questions` Serves the **student catalogue**: only `APPROVED` questions that are linked to at least @@ -421,6 +436,60 @@ The hook is a convenience, not a gate — it can be skipped, unset, or never ena enforcement**: the pull request pipeline runs `spotlessCheck` as its own gate, so badly formatted code fails regardless of anyone's local setup. +## Layering + +Three layers and two leaf tiers. Imports run one way only, and that direction is the one thing here a +compiler can check. + +``` +web/ inbound adapter — controllers, request/response DTOs, mappers, exception handlers, binders +service/ business logic — models, commands, policies, accessors, mappers +persistence/ outbound adapter — entities, projections, repositories, query runners, specification factories +storage/ outbound adapter — S3 +foundation/ shared vocabulary — enums, exception types, pure helpers +``` + +`web → service → persistence`. `persistence` and `storage` import nothing of ours but `foundation`; +`foundation` imports nothing of ours at all. A grep for `com.practiq.service` under `persistence/` should +return nothing, and the same for `com.practiq.web` under `service/`. + +**Nothing is called `domain`.** The name was tried and dropped: logic lives in services, so there is no rich +domain model for it to describe. Each layer owns the types it hands out, and the service's are records under +`service//dto/`. + +**The service never exposes a persistence type.** `ConceptEntity` becomes `Concept`, `QuestionEntity` becomes +`Question`, and so on — the bare noun belongs to the type that crosses a boundary, the `Entity` suffix to the +one Hibernate manages. Entities and projections may reach the service; they may not reach the web layer. That +boundary is held by the service's return signatures, not by naming. + +**Service models are not entity shadows.** They carry what is safe to expose beyond the service — including +things the wire never sees. `Question` carries `version` and `status`; `QuestionResponse` carries neither. +`QuestionAttempt` carries the session token; the response drops it rather than echoing a caller-supplied, +credential-shaped value back into bodies and logs. + +### Where a query gets built + +Persistence answers the query it is handed. It does not know who is asking or why. + +``` +QuestionService → QuestionAccessor → QuestionQueryRunner → QuestionRepository + ↑ holds the policy +``` + +- **`StudentQuestionQueryPolicy`** (service) decides what an audience may see: `APPROVED`, concept-linked. +- **`QuestionAccessor`** (service) is the only place that policy is applied. It is built per audience by + `QuestionAccessorFactory` and injected by qualifier — `@Named("student")` — so the choice is made once in a + field declaration and a holder of the student accessor cannot make an admin read. +- **`QuestionQueryRunner`** (persistence) executes: the specification, the stable `(created_at, id)` order, + and the two-query concept-link stitch. It has no policy and no idea one exists. + +The accessor also owns the one guarantee no other tier can make — that an id query matched at most one row. +The runner cannot know that; the accessor can, because it built the query. + +**Accessors exist only where there is a query to build** — a policy to apply or a cardinality expectation to +enforce. Concept, mark scheme and attempts have neither, so their services use named repository finders +directly. There is exactly one accessor in the codebase, and that is deliberate. + ## Testing Four tiers. The guiding rule: **put each test where it can actually observe the behaviour it claims to @@ -459,7 +528,7 @@ having — so that's what the name encodes: | `*Test` | Does this logic do what it should? | | `*CT` | Does the web layer bind, serialise and map correctly? | | `*ControllerIT` | Does the definition of done actually hold, end to end? | -| `*RepositoryIT` / `*SpecificationFactoryIT` | Do I understand the method I'm calling? | +| `*RepositoryIT` | Do I understand the method I'm calling? | | `*DatabaseIT` | Does the migration say what I think it says? | | `*PT` | Is the query plan the shape I think it is? | @@ -467,9 +536,10 @@ having — so that's what the name encodes: A test that asserts against `MAX_CONTENT_LENGTH` imported from production moves in lockstep with it: change the value and the test still passes, having proved nothing. Product values are therefore -restated as **test-side** constants — `TestData.MAX_UPLOAD_CONTENT_LENGTH`, `TestData.UPLOAD_URL_EXPIRY` -— so the two are independent statements of the same rule and a change on either side is a failure you -have to look at. That is the pin. +restated as **test-side** constants — the block at the top of `TestData`, named for what they pin +(`DOCUMENT_FILENAME_MAX_LENGTH`, `QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH`, and so on) — so the two are +independent statements of the same rule and a change on either side is a failure you have to look at. That +is the pin. The exception is a static import of the method under test (`ConceptResponseMapperTest` importing `toConceptResponse`): that is the subject of the test, not a value it is checking against. @@ -484,6 +554,25 @@ app calls them. Swap `findOne(spec)` for a hand-rolled query and the repository tests a method production abandoned, and it goes stale silently. Only the controller IT's meaning survives that refactor. +### Proving the right collaborator was wired in + +`QuestionService` is bound to a policy-bound accessor by qualifier alone. Get that wrong and every read +silently widens — students receive `PENDING` questions, with a 200 and a plausible body. + +No unit test can catch it: a unit test constructs the accessor itself, so it proves the accessor works, not +that the right one arrived. `QuestionServiceAccessorCT` starts the real context, mocks only the repository, +and resolves the specification the service caused through `utils.CriteriaProbe` — asserting it carries +`status = APPROVED` and the concept-link `EXISTS`. + +That test is worth having only if it fails when the wiring breaks, so it was checked against a policy with +its restrictions removed. Both cases failed. + +**`CriteriaProbe` proves which criteria calls were made, never which rows come back.** That is why the +specification factories are proven in `QuestionRepositoryIT` and `QuestionAttemptRepositoryIT` instead: the +concept filter has to be a correlated `EXISTS` rather than a join, because a join multiplies question rows +per link and corrupts `totalCount` — and a probe sees a subquery either way. Only real rows can tell those +apart. + ### Why there's a performance tier `*PT` asserts **how** the data was fetched, not **what** came back. Each endpoint gets a happy-path pin on @@ -497,8 +586,9 @@ invariance check also passes, because the statement count stays constant. The on absolute number of statements. Tests that assert what came back structurally cannot see this. That's the whole reason the tier exists. -The counts are deliberate magic numbers, each with a comment saying what it's made of. Update the pin -consciously when you legitimately add a query — the friction is the feature. +The counts are deliberate magic numbers. Update the pin consciously when you legitimately add a query — +the friction is the feature. They are not annotated with what they are made of: such a comment restates the +code, and the first refactor leaves a correct number with a false explanation. ### Calibration diff --git a/src/main/java/com/practiq/controller/QuestionAttemptController.java b/src/main/java/com/practiq/controller/QuestionAttemptController.java deleted file mode 100644 index 08ff484..0000000 --- a/src/main/java/com/practiq/controller/QuestionAttemptController.java +++ /dev/null @@ -1,50 +0,0 @@ -package com.practiq.controller; - -import com.practiq.domain.identity.UserRef; -import com.practiq.dto.request.QuestionAttemptRequest; -import com.practiq.dto.response.QuestionAttemptResponse; -import com.practiq.http.HttpConstants; -import com.practiq.service.QuestionAttemptService; -import io.micronaut.http.HttpStatus; -import io.micronaut.http.annotation.*; -import io.micronaut.http.server.exceptions.NotFoundException; -import io.micronaut.scheduling.TaskExecutors; -import io.micronaut.scheduling.annotation.ExecuteOn; -import java.util.List; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@ExecuteOn(TaskExecutors.BLOCKING) -@Controller("api/v1/questions") -public class QuestionAttemptController { - - private final QuestionAttemptService questionAttemptService; - - public QuestionAttemptController(QuestionAttemptService questionAttemptService) { - this.questionAttemptService = questionAttemptService; - } - - @Get("/{questionId}/attempts") - public List getForQuestionId( - @Header(HttpConstants.SESSION_TOKEN_HEADER) String sessionToken, long questionId) { - log.debug("Requested to GET question attempts for question id: {}", questionId); - - return questionAttemptService - .getForQuestionId(new UserRef(sessionToken), questionId) - .orElseThrow(NotFoundException::new); - } - - @Post("/{questionId}/attempts") - @Status(HttpStatus.CREATED) - public QuestionAttemptResponse postForQuestionId( - @Header(HttpConstants.SESSION_TOKEN_HEADER) String sessionToken, - @Body QuestionAttemptRequest request, - long questionId) { - log.debug("Requested to POST question attempt for question id: {}", questionId); - log.trace("POST body: {}", request.body()); - - return questionAttemptService - .postForQuestionId(new UserRef(sessionToken), request, questionId) - .orElseThrow(NotFoundException::new); - } -} diff --git a/src/main/java/com/practiq/controller/QuestionController.java b/src/main/java/com/practiq/controller/QuestionController.java deleted file mode 100644 index aec0c81..0000000 --- a/src/main/java/com/practiq/controller/QuestionController.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.practiq.controller; - -import com.practiq.dto.request.QuestionRequest; -import com.practiq.dto.response.PageResponse; -import com.practiq.dto.response.QuestionResponse; -import com.practiq.service.QuestionService; -import io.micronaut.data.model.Pageable; -import io.micronaut.http.annotation.Controller; -import io.micronaut.http.annotation.Get; -import io.micronaut.http.annotation.RequestBean; -import io.micronaut.http.server.exceptions.NotFoundException; -import io.micronaut.scheduling.TaskExecutors; -import io.micronaut.scheduling.annotation.ExecuteOn; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@ExecuteOn(TaskExecutors.BLOCKING) -@Controller("api/v1/questions") -public class QuestionController { - - private final QuestionService questionService; - - public QuestionController(QuestionService questionService) { - this.questionService = questionService; - } - - @Get - public PageResponse get(@RequestBean QuestionRequest request, Pageable pageable) { - log.debug("Requested to GET approved questions"); - return questionService.get(request, pageable); - } - - @Get("/{id}") - public QuestionResponse getById(long id) { - log.debug("Requested to GET question by id: {}", id); - return questionService.get(id).orElseThrow(NotFoundException::new); - } -} diff --git a/src/main/java/com/practiq/domain/Document.java b/src/main/java/com/practiq/domain/Document.java deleted file mode 100644 index 4a832b1..0000000 --- a/src/main/java/com/practiq/domain/Document.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.practiq.domain; - -import static jakarta.persistence.GenerationType.IDENTITY; - -import com.practiq.domain.types.DocumentStatus; -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.time.Instant; -import lombok.Getter; -import lombok.ToString; -import org.hibernate.annotations.Generated; - -@Entity -@Table(name = "document") -@Getter -@ToString -public class Document { - - @Id - @GeneratedValue(strategy = IDENTITY) - private long id; - - @Version - private int version; - - @NotNull @Column(name = "s3_key", nullable = false, unique = true) - private String s3Key; - - @NotNull @Column(name = "filename", nullable = false) - private String filename; - - @Size(max = 255) @Column(name = "source_spec") - private String sourceSpec; - - @Enumerated(EnumType.STRING) - @Column(name = "status", nullable = false) - private DocumentStatus status; - - @Column(name = "created_at", insertable = false, updatable = false) - @Generated - private Instant createdAt; - - protected Document() {} - - private Document(String s3Key, String filename, String sourceSpec, DocumentStatus status) { - this.s3Key = s3Key; - this.filename = filename; - this.sourceSpec = sourceSpec; - this.status = status; - } - - public static Document newUpload(String s3Key, String filename, String sourceSpec) { - return new Document(s3Key, filename, sourceSpec, DocumentStatus.AWAITING_UPLOAD); - } -} diff --git a/src/main/java/com/practiq/domain/QuestionAttempt.java b/src/main/java/com/practiq/domain/QuestionAttempt.java deleted file mode 100644 index 7d56539..0000000 --- a/src/main/java/com/practiq/domain/QuestionAttempt.java +++ /dev/null @@ -1,45 +0,0 @@ -package com.practiq.domain; - -import static jakarta.persistence.GenerationType.IDENTITY; - -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.time.Instant; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; -import org.hibernate.annotations.Generated; - -@Entity -@Table(name = "question_attempt") -@Getter -@ToString -@EqualsAndHashCode -public class QuestionAttempt { - - @Id - @GeneratedValue(strategy = IDENTITY) - private long id; - - @Column(name = "question_id", nullable = false, updatable = false) - private long questionId; - - @NotNull @Column(name = "session_token", nullable = false) - private String sessionToken; - - @NotNull @Column(name = "body", nullable = false) - @Size(min = 1, max = 100000) private String body; - - @Column(name = "created_at", insertable = false, updatable = false) - @Generated - private Instant createdAt; - - protected QuestionAttempt() {} - - public QuestionAttempt(long questionId, String sessionToken, String body) { - this.questionId = questionId; - this.sessionToken = sessionToken; - this.body = body; - } -} diff --git a/src/main/java/com/practiq/domain/identity/UserRef.java b/src/main/java/com/practiq/domain/identity/UserRef.java deleted file mode 100644 index 3bf61b5..0000000 --- a/src/main/java/com/practiq/domain/identity/UserRef.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.practiq.domain.identity; - -import io.micronaut.core.annotation.Introspected; -import jakarta.validation.constraints.NotBlank; - -// The user an operation is about — not necessarily the caller. @Introspected is load bearing: -// the validator is reflection-free, so a cascaded @Valid on a non-introspected type silently -// validates nothing. -@Introspected -public record UserRef(@NotBlank String sessionToken) {} diff --git a/src/main/java/com/practiq/domain/projection/LinkedQuestion.java b/src/main/java/com/practiq/domain/projection/LinkedQuestion.java deleted file mode 100644 index 8ce5929..0000000 --- a/src/main/java/com/practiq/domain/projection/LinkedQuestion.java +++ /dev/null @@ -1,6 +0,0 @@ -package com.practiq.domain.projection; - -import com.practiq.domain.Question; -import java.util.Set; - -public record LinkedQuestion(Question question, Set conceptLinks) {} diff --git a/src/main/java/com/practiq/domain/projection/QuestionConceptLink.java b/src/main/java/com/practiq/domain/projection/QuestionConceptLink.java deleted file mode 100644 index e09bd1a..0000000 --- a/src/main/java/com/practiq/domain/projection/QuestionConceptLink.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.practiq.domain.projection; - -import io.micronaut.core.annotation.Introspected; - -// Lightweight projection of a question_concept row — just the id pair. Lets a page of concept links be -// loaded and stitched onto questions without materialising QuestionConcept entities or their lazy -// question/concept associations. A dedicated projection is required because Micronaut Data can't map a -// selected @EmbeddedId (SELECT qc.id) back to QuestionConceptId — it NPEs in its tuple collector. -@Introspected -public record QuestionConceptLink(long questionId, long conceptId) {} diff --git a/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptSpecificationFactory.java b/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptSpecificationFactory.java deleted file mode 100644 index 614d40b..0000000 --- a/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptSpecificationFactory.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.practiq.domain.query.attempt; - -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.QuestionAttempt_; -import com.practiq.domain.query.QueryRestriction; -import com.practiq.domain.query.QuerySpecificationFactory; -import com.practiq.domain.query.SessionTokenRestriction; -import io.micronaut.data.repository.jpa.criteria.QuerySpecification; -import jakarta.inject.Singleton; -import java.util.List; - -@Singleton -public class QuestionAttemptSpecificationFactory - extends QuerySpecificationFactory { - - @Override - protected QuerySpecification applyDomain( - QuerySpecification specification, QuestionAttemptQuery query) { - return specification.and(hasQuestionId(query.questionId())); - } - - @Override - protected List> restrictions() { - return List.of(new SessionTokenRestriction<>(QuestionAttempt_.sessionToken)); - } - - private QuerySpecification hasQuestionId(long questionId) { - return (root, criteriaQuery, cb) -> cb.equal(root.get(QuestionAttempt_.questionId), questionId); - } -} diff --git a/src/main/java/com/practiq/domain/query/question/QuestionQueryPolicy.java b/src/main/java/com/practiq/domain/query/question/QuestionQueryPolicy.java deleted file mode 100644 index adcc260..0000000 --- a/src/main/java/com/practiq/domain/query/question/QuestionQueryPolicy.java +++ /dev/null @@ -1,11 +0,0 @@ -package com.practiq.domain.query.question; - -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionType; -import java.util.List; - -interface QuestionQueryPolicy { - QuestionQuery forId(long questionId); - - QuestionQuery catalogue(List types, List difficulties, Long conceptId); -} diff --git a/src/main/java/com/practiq/domain/query/question/QuestionQueryRunner.java b/src/main/java/com/practiq/domain/query/question/QuestionQueryRunner.java deleted file mode 100644 index 2d6c985..0000000 --- a/src/main/java/com/practiq/domain/query/question/QuestionQueryRunner.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.practiq.domain.query.question; - -import static java.util.stream.Collectors.groupingBy; -import static java.util.stream.Collectors.toSet; - -import com.practiq.domain.Question; -import com.practiq.domain.projection.LinkedQuestion; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionType; -import com.practiq.repository.QuestionConceptRepository; -import com.practiq.repository.QuestionRepository; -import io.micronaut.data.model.Page; -import io.micronaut.data.model.Pageable; -import io.micronaut.data.model.Sort; -import io.micronaut.data.repository.jpa.criteria.QuerySpecification; -import java.util.*; - -abstract class QuestionQueryRunner

{ - - // Stable total order for pagination: created_at, then id as a tiebreak so rows can't straddle a page - // boundary ambiguously. Enforced here because it's a correctness invariant of paging, not a caller choice. - private static final Sort STABLE_ORDER = Sort.of(Sort.Order.asc("createdAt"), Sort.Order.asc("id")); - - protected final P policy; - - private final QuestionRepository questionRepository; - private final QuestionConceptRepository questionConceptRepository; - private final QuestionSpecificationFactory questionSpecificationFactory; - - protected QuestionQueryRunner( - QuestionRepository questionRepository, - QuestionConceptRepository questionConceptRepository, - QuestionSpecificationFactory questionSpecificationFactory, - P policy) { - this.questionRepository = questionRepository; - this.questionConceptRepository = questionConceptRepository; - this.questionSpecificationFactory = questionSpecificationFactory; - this.policy = policy; - } - - public boolean doesQuestionExistForId(long id) { - QuestionQuery query = policy.forId(id); - QuerySpecification spec = questionSpecificationFactory.forQuery(query); - return questionRepository.exists(spec); - } - - public Optional findQuestionById(long id) { - QuestionQuery query = policy.forId(id); - QuerySpecification spec = questionSpecificationFactory.forQuery(query); - return questionRepository - .findOne(spec) - .map(question -> toLinkedQuestion(question, linksByQuestionId(List.of(question)))); - } - - // Takes the already-unpacked filter values, not a QuestionRequest: keeps this query package free of any - // dependency on the web-request DTO (dto/request). The service does the one-line unpack. - public Page findQuestionsPagedAndFiltered( - List types, List difficulties, Long conceptId, Pageable pageable) { - QuestionQuery query = policy.catalogue(types, difficulties, conceptId); - QuerySpecification spec = questionSpecificationFactory.forQuery(query); - - Pageable ordered = Pageable.from(pageable.getNumber(), pageable.getSize(), STABLE_ORDER); - - Page page = questionRepository.findAll(spec, ordered); - List questions = page.getContent(); - Map> linksByQuestionId = linksByQuestionId(questions); - - return page.map(question -> toLinkedQuestion(question, linksByQuestionId)); - } - - private Map> linksByQuestionId(Collection questions) { - Set questionIds = questions.stream().map(Question::getId).collect(toSet()); - if (questionIds.isEmpty()) { - return Map.of(); - } - return questionConceptRepository.findLinksByQuestionIds(questionIds).stream() - .collect(groupingBy(QuestionConceptLink::questionId, toSet())); - } - - private LinkedQuestion toLinkedQuestion(Question question, Map> linksByQuestionId) { - return new LinkedQuestion(question, linksByQuestionId.getOrDefault(question.getId(), Set.of())); - } -} diff --git a/src/main/java/com/practiq/domain/query/question/StudentQuestionQueryRunner.java b/src/main/java/com/practiq/domain/query/question/StudentQuestionQueryRunner.java deleted file mode 100644 index 40b6d22..0000000 --- a/src/main/java/com/practiq/domain/query/question/StudentQuestionQueryRunner.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.practiq.domain.query.question; - -import com.practiq.repository.QuestionConceptRepository; -import com.practiq.repository.QuestionRepository; -import jakarta.inject.Singleton; - -@Singleton -public class StudentQuestionQueryRunner extends QuestionQueryRunner { - public StudentQuestionQueryRunner( - QuestionRepository questionRepository, - QuestionConceptRepository questionConceptRepository, - QuestionSpecificationFactory questionSpecificationFactory) { - super( - questionRepository, - questionConceptRepository, - questionSpecificationFactory, - new StudentQuestionQueryPolicy()); - } -} diff --git a/src/main/java/com/practiq/dto/mapper/ConceptResponseMapper.java b/src/main/java/com/practiq/dto/mapper/ConceptResponseMapper.java deleted file mode 100644 index 73e96db..0000000 --- a/src/main/java/com/practiq/dto/mapper/ConceptResponseMapper.java +++ /dev/null @@ -1,14 +0,0 @@ -package com.practiq.dto.mapper; - -import com.practiq.domain.Concept; -import com.practiq.dto.response.ConceptResponse; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class ConceptResponseMapper { - public static ConceptResponse toConceptResponse(Concept concept) { - log.trace("Converting concept to ConceptResponse: {}", concept.getId()); - return new ConceptResponse( - concept.getId(), concept.getName(), concept.getDescription(), concept.getCreatedAt()); - } -} diff --git a/src/main/java/com/practiq/dto/mapper/MarkSchemeResponseMapper.java b/src/main/java/com/practiq/dto/mapper/MarkSchemeResponseMapper.java deleted file mode 100644 index 4d47aac..0000000 --- a/src/main/java/com/practiq/dto/mapper/MarkSchemeResponseMapper.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.practiq.dto.mapper; - -import com.practiq.domain.MarkScheme; -import com.practiq.dto.response.MarkSchemeResponse; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class MarkSchemeResponseMapper { - public static MarkSchemeResponse toMarkSchemeResponse(MarkScheme markScheme) { - log.trace("Converting MarkScheme to MarkSchemeResponse: {}", markScheme.getId()); - - return new MarkSchemeResponse( - markScheme.getId(), markScheme.getQuestionId(), markScheme.getBody(), markScheme.getCreatedAt()); - } -} diff --git a/src/main/java/com/practiq/dto/mapper/QuestionAttemptResponseMapper.java b/src/main/java/com/practiq/dto/mapper/QuestionAttemptResponseMapper.java deleted file mode 100644 index f88e39f..0000000 --- a/src/main/java/com/practiq/dto/mapper/QuestionAttemptResponseMapper.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.practiq.dto.mapper; - -import com.practiq.domain.QuestionAttempt; -import com.practiq.dto.response.QuestionAttemptResponse; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class QuestionAttemptResponseMapper { - public static QuestionAttemptResponse toQuestionAttemptResponse(QuestionAttempt questionAttempt) { - log.trace("Converting QuestionAttempt to QuestionAttemptResponse: {}", questionAttempt.getId()); - - return new QuestionAttemptResponse( - questionAttempt.getId(), - questionAttempt.getQuestionId(), - questionAttempt.getBody(), - questionAttempt.getCreatedAt()); - } -} diff --git a/src/main/java/com/practiq/dto/mapper/QuestionResponseMapper.java b/src/main/java/com/practiq/dto/mapper/QuestionResponseMapper.java deleted file mode 100644 index d3841c3..0000000 --- a/src/main/java/com/practiq/dto/mapper/QuestionResponseMapper.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.practiq.dto.mapper; - -import static java.util.stream.Collectors.toSet; - -import com.practiq.domain.Question; -import com.practiq.domain.projection.LinkedQuestion; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.dto.response.QuestionDifficultyResponse; -import com.practiq.dto.response.QuestionResponse; -import java.util.List; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -public class QuestionResponseMapper { - - public static List toQuestionResponses(List linkedQuestions) { - return linkedQuestions.stream() - .map(QuestionResponseMapper::toQuestionResponse) - .toList(); - } - - public static QuestionResponse toQuestionResponse(LinkedQuestion linkedQuestion) { - Question question = linkedQuestion.question(); - log.trace("Converting question to QuestionResponse: {}", question.getId()); - - QuestionDifficulty difficulty = question.getDifficulty(); - return new QuestionResponse( - question.getId(), - question.getBody(), - difficulty == null ? null : new QuestionDifficultyResponse(difficulty), - question.getType(), - question.getCreatedAt(), - linkedQuestion.conceptLinks().stream() - .map(QuestionConceptLink::conceptId) - .collect(toSet())); - } -} diff --git a/src/main/java/com/practiq/dto/response/ConceptResponse.java b/src/main/java/com/practiq/dto/response/ConceptResponse.java deleted file mode 100644 index cb7769f..0000000 --- a/src/main/java/com/practiq/dto/response/ConceptResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.practiq.dto.response; - -import io.micronaut.serde.annotation.Serdeable; -import java.time.Instant; -import lombok.Getter; -import lombok.ToString; - -@Serdeable -@Getter -@ToString -public class ConceptResponse { - - private final long id; - private final String name; - private final String description; - private final Instant createdAt; - - public ConceptResponse(long id, String name, String description, Instant createdAt) { - this.id = id; - this.name = name; - this.description = description; - this.createdAt = createdAt; - } -} diff --git a/src/main/java/com/practiq/dto/response/MarkSchemeResponse.java b/src/main/java/com/practiq/dto/response/MarkSchemeResponse.java deleted file mode 100644 index 10a7ee0..0000000 --- a/src/main/java/com/practiq/dto/response/MarkSchemeResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.practiq.dto.response; - -import io.micronaut.serde.annotation.Serdeable; -import java.time.Instant; -import lombok.Getter; -import lombok.ToString; - -@Serdeable -@Getter -@ToString -public class MarkSchemeResponse { - - private final long id; - private final long questionId; - private final String body; - private final Instant createdAt; - - public MarkSchemeResponse(long id, long questionId, String body, Instant createdAt) { - this.id = id; - this.questionId = questionId; - this.body = body; - this.createdAt = createdAt; - } -} diff --git a/src/main/java/com/practiq/dto/response/QuestionAttemptResponse.java b/src/main/java/com/practiq/dto/response/QuestionAttemptResponse.java deleted file mode 100644 index d959c68..0000000 --- a/src/main/java/com/practiq/dto/response/QuestionAttemptResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.practiq.dto.response; - -import io.micronaut.serde.annotation.Serdeable; -import java.time.Instant; -import lombok.Getter; -import lombok.ToString; - -@Serdeable -@Getter -@ToString -public class QuestionAttemptResponse { - - private final long id; - private final long questionId; - private final String body; - private final Instant createdAt; - - public QuestionAttemptResponse(long id, long questionId, String body, Instant createdAt) { - this.id = id; - this.questionId = questionId; - this.body = body; - this.createdAt = createdAt; - } -} diff --git a/src/main/java/com/practiq/dto/response/QuestionResponse.java b/src/main/java/com/practiq/dto/response/QuestionResponse.java deleted file mode 100644 index 492d7f2..0000000 --- a/src/main/java/com/practiq/dto/response/QuestionResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.practiq.dto.response; - -import com.practiq.domain.types.QuestionType; -import io.micronaut.serde.annotation.Serdeable; -import java.time.Instant; -import java.util.Set; -import lombok.Getter; -import lombok.ToString; - -@Serdeable -@Getter -@ToString -public class QuestionResponse { - private final long id; - private final String body; - private final QuestionDifficultyResponse difficulty; - private final QuestionType type; - private final Instant createdAt; - private final Set linkedConceptIds; - - public QuestionResponse( - long id, - String body, - QuestionDifficultyResponse difficulty, - QuestionType type, - Instant createdAt, - Set linkedConceptIds) { - this.id = id; - this.body = body; - this.difficulty = difficulty; - this.type = type; - this.createdAt = createdAt; - this.linkedConceptIds = linkedConceptIds; - } -} diff --git a/src/main/java/com/practiq/exception/ContentTooLargeException.java b/src/main/java/com/practiq/foundation/exception/ContentTooLargeException.java similarity index 93% rename from src/main/java/com/practiq/exception/ContentTooLargeException.java rename to src/main/java/com/practiq/foundation/exception/ContentTooLargeException.java index d9e2847..f32119a 100644 --- a/src/main/java/com/practiq/exception/ContentTooLargeException.java +++ b/src/main/java/com/practiq/foundation/exception/ContentTooLargeException.java @@ -1,4 +1,4 @@ -package com.practiq.exception; +package com.practiq.foundation.exception; // Sibling of EntityValidationException for size limits, so a product rule about a value inside the // payload stays distinguishable from Micronaut rejecting the HTTP body itself — both answer 413. diff --git a/src/main/java/com/practiq/exception/EntityValidationError.java b/src/main/java/com/practiq/foundation/exception/EntityValidationError.java similarity index 65% rename from src/main/java/com/practiq/exception/EntityValidationError.java rename to src/main/java/com/practiq/foundation/exception/EntityValidationError.java index c983d88..9c073cb 100644 --- a/src/main/java/com/practiq/exception/EntityValidationError.java +++ b/src/main/java/com/practiq/foundation/exception/EntityValidationError.java @@ -1,3 +1,3 @@ -package com.practiq.exception; +package com.practiq.foundation.exception; public record EntityValidationError(String errorField, String failureReason) {} diff --git a/src/main/java/com/practiq/exception/EntityValidationException.java b/src/main/java/com/practiq/foundation/exception/EntityValidationException.java similarity index 94% rename from src/main/java/com/practiq/exception/EntityValidationException.java rename to src/main/java/com/practiq/foundation/exception/EntityValidationException.java index 4264104..01156cf 100644 --- a/src/main/java/com/practiq/exception/EntityValidationException.java +++ b/src/main/java/com/practiq/foundation/exception/EntityValidationException.java @@ -1,4 +1,4 @@ -package com.practiq.exception; +package com.practiq.foundation.exception; // Thrown where the state of data is validated by hand before an action is performed, to say that the // incoming types do not meet expectations and the action cannot proceed. diff --git a/src/main/java/com/practiq/exception/UnauthorizedException.java b/src/main/java/com/practiq/foundation/exception/UnauthorizedException.java similarity index 82% rename from src/main/java/com/practiq/exception/UnauthorizedException.java rename to src/main/java/com/practiq/foundation/exception/UnauthorizedException.java index 4f8e000..1ff3f20 100644 --- a/src/main/java/com/practiq/exception/UnauthorizedException.java +++ b/src/main/java/com/practiq/foundation/exception/UnauthorizedException.java @@ -1,4 +1,4 @@ -package com.practiq.exception; +package com.practiq.foundation.exception; public class UnauthorizedException extends RuntimeException { public UnauthorizedException(String header) { diff --git a/src/main/java/com/practiq/domain/types/DocumentStatus.java b/src/main/java/com/practiq/foundation/types/DocumentStatus.java similarity index 74% rename from src/main/java/com/practiq/domain/types/DocumentStatus.java rename to src/main/java/com/practiq/foundation/types/DocumentStatus.java index 0f35d36..2e0950b 100644 --- a/src/main/java/com/practiq/domain/types/DocumentStatus.java +++ b/src/main/java/com/practiq/foundation/types/DocumentStatus.java @@ -1,4 +1,4 @@ -package com.practiq.domain.types; +package com.practiq.foundation.types; public enum DocumentStatus { AWAITING_UPLOAD, diff --git a/src/main/java/com/practiq/domain/types/QuestionAuthorship.java b/src/main/java/com/practiq/foundation/types/QuestionAuthorship.java similarity index 67% rename from src/main/java/com/practiq/domain/types/QuestionAuthorship.java rename to src/main/java/com/practiq/foundation/types/QuestionAuthorship.java index 44c3ced..c2bac73 100644 --- a/src/main/java/com/practiq/domain/types/QuestionAuthorship.java +++ b/src/main/java/com/practiq/foundation/types/QuestionAuthorship.java @@ -1,4 +1,4 @@ -package com.practiq.domain.types; +package com.practiq.foundation.types; public enum QuestionAuthorship { AUTHORED, diff --git a/src/main/java/com/practiq/domain/types/QuestionDifficulty.java b/src/main/java/com/practiq/foundation/types/QuestionDifficulty.java similarity index 94% rename from src/main/java/com/practiq/domain/types/QuestionDifficulty.java rename to src/main/java/com/practiq/foundation/types/QuestionDifficulty.java index 65914cc..1188b16 100644 --- a/src/main/java/com/practiq/domain/types/QuestionDifficulty.java +++ b/src/main/java/com/practiq/foundation/types/QuestionDifficulty.java @@ -1,4 +1,4 @@ -package com.practiq.domain.types; +package com.practiq.foundation.types; public enum QuestionDifficulty { TRIVIAL(1), diff --git a/src/main/java/com/practiq/domain/types/QuestionStatus.java b/src/main/java/com/practiq/foundation/types/QuestionStatus.java similarity index 65% rename from src/main/java/com/practiq/domain/types/QuestionStatus.java rename to src/main/java/com/practiq/foundation/types/QuestionStatus.java index c86861d..5b34518 100644 --- a/src/main/java/com/practiq/domain/types/QuestionStatus.java +++ b/src/main/java/com/practiq/foundation/types/QuestionStatus.java @@ -1,4 +1,4 @@ -package com.practiq.domain.types; +package com.practiq.foundation.types; public enum QuestionStatus { PENDING, diff --git a/src/main/java/com/practiq/domain/types/QuestionType.java b/src/main/java/com/practiq/foundation/types/QuestionType.java similarity index 65% rename from src/main/java/com/practiq/domain/types/QuestionType.java rename to src/main/java/com/practiq/foundation/types/QuestionType.java index caed695..a2891fa 100644 --- a/src/main/java/com/practiq/domain/types/QuestionType.java +++ b/src/main/java/com/practiq/foundation/types/QuestionType.java @@ -1,4 +1,4 @@ -package com.practiq.domain.types; +package com.practiq.foundation.types; public enum QuestionType { SHORT_ANSWER, diff --git a/src/main/java/com/practiq/util/StringUtil.java b/src/main/java/com/practiq/foundation/util/StringUtil.java similarity index 91% rename from src/main/java/com/practiq/util/StringUtil.java rename to src/main/java/com/practiq/foundation/util/StringUtil.java index e39c30a..8537531 100644 --- a/src/main/java/com/practiq/util/StringUtil.java +++ b/src/main/java/com/practiq/foundation/util/StringUtil.java @@ -1,4 +1,4 @@ -package com.practiq.util; +package com.practiq.foundation.util; // Null-safe blank check. The JDK's String.isBlank() is an instance method, so it cannot answer for a // null, and Micronaut's StringUtils only offers isEmpty — hence three lines here rather than a diff --git a/src/main/java/com/practiq/domain/Concept.java b/src/main/java/com/practiq/persistence/ConceptEntity.java similarity index 59% rename from src/main/java/com/practiq/domain/Concept.java rename to src/main/java/com/practiq/persistence/ConceptEntity.java index 40a7fe6..08bfe20 100644 --- a/src/main/java/com/practiq/domain/Concept.java +++ b/src/main/java/com/practiq/persistence/ConceptEntity.java @@ -1,4 +1,4 @@ -package com.practiq.domain; +package com.practiq.persistence; import static jakarta.persistence.GenerationType.IDENTITY; @@ -14,7 +14,10 @@ @Table(name = "concept") @Getter @ToString -public class Concept { +public class ConceptEntity { + + public static final int NAME_MAX_LENGTH = 200; + public static final int DESCRIPTION_MAX_LENGTH = 500; @Id @GeneratedValue(strategy = IDENTITY) @@ -23,19 +26,19 @@ public class Concept { @Version private int version; - @NotNull @Size(max = 200) @Column(name = "name", nullable = false, unique = true) + @NotNull @Size(max = NAME_MAX_LENGTH) @Column(name = "name", nullable = false, unique = true) private String name; - @NotNull @Column(name = "description", nullable = false) + @NotNull @Size(max = DESCRIPTION_MAX_LENGTH) @Column(name = "description", nullable = false) private String description; @Column(name = "created_at", insertable = false, updatable = false) @Generated private Instant createdAt; - protected Concept() {} + protected ConceptEntity() {} - public Concept(@NotNull String name, @NotNull String description) { + public ConceptEntity(@NotNull String name, @NotNull String description) { this.name = name; this.description = description; } diff --git a/src/main/java/com/practiq/persistence/DocumentEntity.java b/src/main/java/com/practiq/persistence/DocumentEntity.java new file mode 100644 index 0000000..cbd78ff --- /dev/null +++ b/src/main/java/com/practiq/persistence/DocumentEntity.java @@ -0,0 +1,60 @@ +package com.practiq.persistence; + +import static jakarta.persistence.GenerationType.IDENTITY; + +import com.practiq.foundation.types.DocumentStatus; +import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import lombok.Getter; +import lombok.ToString; +import org.hibernate.annotations.Generated; + +@Entity +@Table(name = "document") +@Getter +@ToString +public class DocumentEntity { + + public static final int S3_KEY_MAX_LENGTH = 255; + public static final int FILENAME_MAX_LENGTH = 255; + public static final int SOURCE_SPEC_MAX_LENGTH = 255; + + @Id + @GeneratedValue(strategy = IDENTITY) + private long id; + + @Version + private int version; + + @NotNull @Size(max = S3_KEY_MAX_LENGTH) @Column(name = "s3_key", nullable = false, unique = true) + private String s3Key; + + @NotNull @Size(max = FILENAME_MAX_LENGTH) @Column(name = "filename", nullable = false) + private String filename; + + @Size(max = SOURCE_SPEC_MAX_LENGTH) @Column(name = "source_spec") + private String sourceSpec; + + @NotNull @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private DocumentStatus status; + + @Column(name = "created_at", insertable = false, updatable = false) + @Generated + private Instant createdAt; + + protected DocumentEntity() {} + + private DocumentEntity(String s3Key, String filename, String sourceSpec, DocumentStatus status) { + this.s3Key = s3Key; + this.filename = filename; + this.sourceSpec = sourceSpec; + this.status = status; + } + + public static DocumentEntity newUpload(String s3Key, String filename, String sourceSpec) { + return new DocumentEntity(s3Key, filename, sourceSpec, DocumentStatus.AWAITING_UPLOAD); + } +} diff --git a/src/main/java/com/practiq/domain/MarkScheme.java b/src/main/java/com/practiq/persistence/MarkSchemeEntity.java similarity index 68% rename from src/main/java/com/practiq/domain/MarkScheme.java rename to src/main/java/com/practiq/persistence/MarkSchemeEntity.java index a57cbfe..edf5ff5 100644 --- a/src/main/java/com/practiq/domain/MarkScheme.java +++ b/src/main/java/com/practiq/persistence/MarkSchemeEntity.java @@ -1,9 +1,10 @@ -package com.practiq.domain; +package com.practiq.persistence; import static jakarta.persistence.GenerationType.IDENTITY; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.Instant; import lombok.Getter; import lombok.ToString; @@ -13,7 +14,9 @@ @Table(name = "mark_scheme") @Getter @ToString -public class MarkScheme { +public class MarkSchemeEntity { + + public static final int BODY_MAX_LENGTH = 10000; @Id @GeneratedValue(strategy = IDENTITY) @@ -25,16 +28,16 @@ public class MarkScheme { @Version private int version; - @NotNull @Column(name = "body", nullable = false) + @NotNull @Size(max = BODY_MAX_LENGTH) @Column(name = "body", nullable = false) private String body; @Column(name = "created_at", insertable = false, updatable = false) @Generated private Instant createdAt; - protected MarkScheme() {} + protected MarkSchemeEntity() {} - public MarkScheme(long questionId, String body) { + public MarkSchemeEntity(long questionId, @NotNull String body) { this.questionId = questionId; this.body = body; } diff --git a/src/main/java/com/practiq/persistence/QuestionAttemptEntity.java b/src/main/java/com/practiq/persistence/QuestionAttemptEntity.java new file mode 100644 index 0000000..7884639 --- /dev/null +++ b/src/main/java/com/practiq/persistence/QuestionAttemptEntity.java @@ -0,0 +1,54 @@ +package com.practiq.persistence; + +import static jakarta.persistence.GenerationType.IDENTITY; + +import jakarta.persistence.*; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.ToString; +import org.hibernate.annotations.Generated; + +@Entity +@Table(name = "question_attempt") +@Getter +@ToString +@EqualsAndHashCode +public class QuestionAttemptEntity { + + // A client-generated UUID is 36 characters; 64 leaves room for a different token format. Bounded + // because the value is client-supplied and indexed — an oversized token would fail at insert on + // Postgres's btree entry limit rather than at validation. + public static final int SESSION_TOKEN_MAX_LENGTH = 64; + + // The storage guard, sitting above the product rule on the request DTO so that raising what a client + // may send does not immediately owe a migration. + public static final int BODY_MAX_LENGTH = 25000; + + @Id + @GeneratedValue(strategy = IDENTITY) + private long id; + + @Column(name = "question_id", nullable = false, updatable = false) + private long questionId; + + @NotBlank @Size(max = SESSION_TOKEN_MAX_LENGTH) @Column(name = "session_token", nullable = false) + private String sessionToken; + + @NotBlank @Size(max = BODY_MAX_LENGTH) @Column(name = "body", nullable = false) + private String body; + + @Column(name = "created_at", insertable = false, updatable = false) + @Generated + private Instant createdAt; + + protected QuestionAttemptEntity() {} + + public QuestionAttemptEntity(long questionId, String sessionToken, String body) { + this.questionId = questionId; + this.sessionToken = sessionToken; + this.body = body; + } +} diff --git a/src/main/java/com/practiq/domain/QuestionConcept.java b/src/main/java/com/practiq/persistence/QuestionConceptEntity.java similarity index 50% rename from src/main/java/com/practiq/domain/QuestionConcept.java rename to src/main/java/com/practiq/persistence/QuestionConceptEntity.java index 4991001..afc7fb0 100644 --- a/src/main/java/com/practiq/domain/QuestionConcept.java +++ b/src/main/java/com/practiq/persistence/QuestionConceptEntity.java @@ -1,4 +1,4 @@ -package com.practiq.domain; +package com.practiq.persistence; import jakarta.persistence.*; import lombok.Getter; @@ -6,26 +6,26 @@ @Entity @Table(name = "question_concept") @Getter -public class QuestionConcept { +public class QuestionConceptEntity { @EmbeddedId - private QuestionConceptId id; + private QuestionConceptEntityId id; @MapsId("questionId") @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "question_id") - private Question question; + private QuestionEntity question; @MapsId("conceptId") @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "concept_id") - private Concept concept; + private ConceptEntity concept; - protected QuestionConcept() {} + protected QuestionConceptEntity() {} - public QuestionConcept(Question question, Concept concept) { + public QuestionConceptEntity(QuestionEntity question, ConceptEntity concept) { this.question = question; this.concept = concept; - this.id = new QuestionConceptId(question.getId(), concept.getId()); + this.id = new QuestionConceptEntityId(question.getId(), concept.getId()); } } diff --git a/src/main/java/com/practiq/domain/QuestionConceptId.java b/src/main/java/com/practiq/persistence/QuestionConceptEntityId.java similarity index 59% rename from src/main/java/com/practiq/domain/QuestionConceptId.java rename to src/main/java/com/practiq/persistence/QuestionConceptEntityId.java index e44e973..a5df5a6 100644 --- a/src/main/java/com/practiq/domain/QuestionConceptId.java +++ b/src/main/java/com/practiq/persistence/QuestionConceptEntityId.java @@ -1,4 +1,4 @@ -package com.practiq.domain; +package com.practiq.persistence; import jakarta.persistence.Embeddable; import java.io.Serializable; @@ -8,14 +8,14 @@ @Embeddable @Getter @EqualsAndHashCode -public class QuestionConceptId implements Serializable { +public class QuestionConceptEntityId implements Serializable { private long questionId; private long conceptId; - protected QuestionConceptId() {} + protected QuestionConceptEntityId() {} - public QuestionConceptId(long questionId, long conceptId) { + public QuestionConceptEntityId(long questionId, long conceptId) { this.questionId = questionId; this.conceptId = conceptId; } diff --git a/src/main/java/com/practiq/domain/Question.java b/src/main/java/com/practiq/persistence/QuestionEntity.java similarity index 55% rename from src/main/java/com/practiq/domain/Question.java rename to src/main/java/com/practiq/persistence/QuestionEntity.java index 7b8dc75..079babc 100644 --- a/src/main/java/com/practiq/domain/Question.java +++ b/src/main/java/com/practiq/persistence/QuestionEntity.java @@ -1,16 +1,15 @@ -package com.practiq.domain; +package com.practiq.persistence; import static jakarta.persistence.GenerationType.IDENTITY; -import com.practiq.domain.converters.QuestionDifficultyAttributeConverter; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.converter.QuestionDifficultyEntityAttributeConverter; import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import java.time.Instant; -import java.util.HashSet; -import java.util.Set; import lombok.Getter; import lombok.ToString; import org.hibernate.annotations.Generated; @@ -19,7 +18,9 @@ @Table(name = "question") @Getter @ToString -public class Question { +public class QuestionEntity { + + public static final int BODY_MAX_LENGTH = 10000; @Id @GeneratedValue(strategy = IDENTITY) @@ -28,10 +29,10 @@ public class Question { @Version private int version; - @NotNull @Column(name = "body", nullable = false) + @NotNull @Size(max = BODY_MAX_LENGTH) @Column(name = "body", nullable = false) private String body; - @Convert(converter = QuestionDifficultyAttributeConverter.class) + @Convert(converter = QuestionDifficultyEntityAttributeConverter.class) @Column(name = "difficulty") private QuestionDifficulty difficulty; @@ -39,7 +40,7 @@ public class Question { @Column(name = "type") private QuestionType type; - @Enumerated(EnumType.STRING) + @NotNull @Enumerated(EnumType.STRING) @Column(name = "status", nullable = false) private QuestionStatus status; @@ -47,12 +48,9 @@ public class Question { @Generated private Instant createdAt; - @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true) - private Set conceptLinks = new HashSet<>(); - - protected Question() {} + protected QuestionEntity() {} - public Question(String body, QuestionDifficulty difficulty, QuestionType type, QuestionStatus status) { + public QuestionEntity(String body, QuestionDifficulty difficulty, QuestionType type, QuestionStatus status) { this.body = body; this.difficulty = difficulty; this.type = type; diff --git a/src/main/java/com/practiq/domain/QuestionOrigin.java b/src/main/java/com/practiq/persistence/QuestionOriginEntity.java similarity index 52% rename from src/main/java/com/practiq/domain/QuestionOrigin.java rename to src/main/java/com/practiq/persistence/QuestionOriginEntity.java index 44f2262..79930a2 100644 --- a/src/main/java/com/practiq/domain/QuestionOrigin.java +++ b/src/main/java/com/practiq/persistence/QuestionOriginEntity.java @@ -1,9 +1,10 @@ -package com.practiq.domain; +package com.practiq.persistence; import static jakarta.persistence.GenerationType.IDENTITY; -import com.practiq.domain.types.QuestionAuthorship; +import com.practiq.foundation.types.QuestionAuthorship; import jakarta.persistence.*; +import jakarta.validation.constraints.NotNull; import java.time.Instant; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -15,7 +16,7 @@ @Getter @ToString @EqualsAndHashCode -public class QuestionOrigin { +public class QuestionOriginEntity { @Id @GeneratedValue(strategy = IDENTITY) @@ -24,7 +25,7 @@ public class QuestionOrigin { @Column(name = "question_id", nullable = false, updatable = false, unique = true) private long questionId; - @Enumerated(EnumType.STRING) + @NotNull @Enumerated(EnumType.STRING) @Column(name = "authorship", nullable = false) private QuestionAuthorship authorship; @@ -35,23 +36,23 @@ public class QuestionOrigin { @Generated private Instant createdAt; - protected QuestionOrigin() {} + protected QuestionOriginEntity() {} - private QuestionOrigin(long questionId, QuestionAuthorship authorship, Long documentId) { + private QuestionOriginEntity(long questionId, QuestionAuthorship authorship, Long documentId) { this.questionId = questionId; this.authorship = authorship; this.documentId = documentId; } - public static QuestionOrigin authored(long questionId) { - return new QuestionOrigin(questionId, QuestionAuthorship.AUTHORED, null); + public static QuestionOriginEntity authored(long questionId) { + return new QuestionOriginEntity(questionId, QuestionAuthorship.AUTHORED, null); } - public static QuestionOrigin extracted(long questionId, long documentId) { - return new QuestionOrigin(questionId, QuestionAuthorship.EXTRACTED, documentId); + public static QuestionOriginEntity extracted(long questionId, long documentId) { + return new QuestionOriginEntity(questionId, QuestionAuthorship.EXTRACTED, documentId); } - public static QuestionOrigin generated(long questionId) { - return new QuestionOrigin(questionId, QuestionAuthorship.GENERATED, null); + public static QuestionOriginEntity generated(long questionId) { + return new QuestionOriginEntity(questionId, QuestionAuthorship.GENERATED, null); } } diff --git a/src/main/java/com/practiq/domain/converters/QuestionDifficultyAttributeConverter.java b/src/main/java/com/practiq/persistence/converter/QuestionDifficultyEntityAttributeConverter.java similarity index 72% rename from src/main/java/com/practiq/domain/converters/QuestionDifficultyAttributeConverter.java rename to src/main/java/com/practiq/persistence/converter/QuestionDifficultyEntityAttributeConverter.java index fa1bc24..728113e 100644 --- a/src/main/java/com/practiq/domain/converters/QuestionDifficultyAttributeConverter.java +++ b/src/main/java/com/practiq/persistence/converter/QuestionDifficultyEntityAttributeConverter.java @@ -1,12 +1,12 @@ -package com.practiq.domain.converters; +package com.practiq.persistence.converter; -import com.practiq.domain.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionDifficulty; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; import java.util.Optional; @Converter -public class QuestionDifficultyAttributeConverter implements AttributeConverter { +public class QuestionDifficultyEntityAttributeConverter implements AttributeConverter { @Override public Integer convertToDatabaseColumn(QuestionDifficulty attribute) { diff --git a/src/main/java/com/practiq/persistence/projection/QuestionConceptLinkProjection.java b/src/main/java/com/practiq/persistence/projection/QuestionConceptLinkProjection.java new file mode 100644 index 0000000..640a7d1 --- /dev/null +++ b/src/main/java/com/practiq/persistence/projection/QuestionConceptLinkProjection.java @@ -0,0 +1,10 @@ +package com.practiq.persistence.projection; + +import io.micronaut.core.annotation.Introspected; + +// Lightweight projection of a question_concept row — just the id pair. Lets a page of concept links be +// loaded and attached to questions without materialising QuestionConceptEntity or its lazy question and +// concept associations. A dedicated projection is required because Micronaut Data can't map a selected +// @EmbeddedId (SELECT qc.id) back to QuestionConceptEntityId — it NPEs in its tuple collector. +@Introspected +public record QuestionConceptLinkProjection(long questionId, long conceptId) {} diff --git a/src/main/java/com/practiq/domain/query/QueryRestriction.java b/src/main/java/com/practiq/persistence/query/QueryRestriction.java similarity index 92% rename from src/main/java/com/practiq/domain/query/QueryRestriction.java rename to src/main/java/com/practiq/persistence/query/QueryRestriction.java index 5d17e57..a85878d 100644 --- a/src/main/java/com/practiq/domain/query/QueryRestriction.java +++ b/src/main/java/com/practiq/persistence/query/QueryRestriction.java @@ -1,4 +1,4 @@ -package com.practiq.domain.query; +package com.practiq.persistence.query; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; diff --git a/src/main/java/com/practiq/domain/query/QuerySpecificationFactory.java b/src/main/java/com/practiq/persistence/query/QuerySpecificationFactory.java similarity index 95% rename from src/main/java/com/practiq/domain/query/QuerySpecificationFactory.java rename to src/main/java/com/practiq/persistence/query/QuerySpecificationFactory.java index 2f429ca..630a2b9 100644 --- a/src/main/java/com/practiq/domain/query/QuerySpecificationFactory.java +++ b/src/main/java/com/practiq/persistence/query/QuerySpecificationFactory.java @@ -1,4 +1,4 @@ -package com.practiq.domain.query; +package com.practiq.persistence.query; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import java.util.List; diff --git a/src/main/java/com/practiq/domain/query/SessionTokenRestriction.java b/src/main/java/com/practiq/persistence/query/SessionTokenQueryRestriction.java similarity index 72% rename from src/main/java/com/practiq/domain/query/SessionTokenRestriction.java rename to src/main/java/com/practiq/persistence/query/SessionTokenQueryRestriction.java index 9f8d8b5..05201e7 100644 --- a/src/main/java/com/practiq/domain/query/SessionTokenRestriction.java +++ b/src/main/java/com/practiq/persistence/query/SessionTokenQueryRestriction.java @@ -1,13 +1,13 @@ -package com.practiq.domain.query; +package com.practiq.persistence.query; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import jakarta.persistence.metamodel.SingularAttribute; -public final class SessionTokenRestriction implements QueryRestriction { +public final class SessionTokenQueryRestriction implements QueryRestriction { private final SingularAttribute sessionTokenAttribute; - public SessionTokenRestriction(SingularAttribute sessionTokenAttribute) { + public SessionTokenQueryRestriction(SingularAttribute sessionTokenAttribute) { this.sessionTokenAttribute = sessionTokenAttribute; } diff --git a/src/main/java/com/practiq/domain/query/UserRestrictedQuery.java b/src/main/java/com/practiq/persistence/query/UserRestrictedQuery.java similarity index 63% rename from src/main/java/com/practiq/domain/query/UserRestrictedQuery.java rename to src/main/java/com/practiq/persistence/query/UserRestrictedQuery.java index 869d198..671cc1e 100644 --- a/src/main/java/com/practiq/domain/query/UserRestrictedQuery.java +++ b/src/main/java/com/practiq/persistence/query/UserRestrictedQuery.java @@ -1,4 +1,4 @@ -package com.practiq.domain.query; +package com.practiq.persistence.query; public interface UserRestrictedQuery { String sessionToken(); diff --git a/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptQuery.java b/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptQuery.java similarity index 50% rename from src/main/java/com/practiq/domain/query/attempt/QuestionAttemptQuery.java rename to src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptQuery.java index df0d2a6..fa4b62c 100644 --- a/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptQuery.java +++ b/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptQuery.java @@ -1,5 +1,5 @@ -package com.practiq.domain.query.attempt; +package com.practiq.persistence.query.attempt; -import com.practiq.domain.query.UserRestrictedQuery; +import com.practiq.persistence.query.UserRestrictedQuery; public record QuestionAttemptQuery(long questionId, String sessionToken) implements UserRestrictedQuery {} diff --git a/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptQueryRunner.java b/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptQueryRunner.java similarity index 55% rename from src/main/java/com/practiq/domain/query/attempt/QuestionAttemptQueryRunner.java rename to src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptQueryRunner.java index 3766c27..55490d3 100644 --- a/src/main/java/com/practiq/domain/query/attempt/QuestionAttemptQueryRunner.java +++ b/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptQueryRunner.java @@ -1,16 +1,19 @@ -package com.practiq.domain.query.attempt; +package com.practiq.persistence.query.attempt; -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.identity.UserRef; -import com.practiq.repository.QuestionAttemptRepository; +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.repository.QuestionAttemptRepository; import io.micronaut.data.model.Sort; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import jakarta.inject.Singleton; import java.util.List; +// Runs the query it is handed. The session-token restriction is not applied here by choice — it rides on +// QuestionAttemptQuery implementing UserRestrictedQuery, which the specification factory acts on +// unconditionally, so an attempt query that escapes it cannot be constructed. @Singleton public class QuestionAttemptQueryRunner { + // Newest first, with id as a tiebreak so attempts made in the same instant have a defined order. private static final Sort STABLE_ORDER = Sort.of(Sort.Order.desc("createdAt"), Sort.Order.asc("id")); private final QuestionAttemptSpecificationFactory questionAttemptSpecificationFactory; @@ -23,14 +26,8 @@ public QuestionAttemptQueryRunner( this.questionAttemptSpecificationFactory = questionAttemptSpecificationFactory; } - public List getQuestionAttempts(UserRef userRef, long questionId) { - QuestionAttemptQuery questionAttemptQuery = new QuestionAttemptQuery(questionId, userRef.sessionToken()); - QuerySpecification spec = questionAttemptSpecificationFactory.forQuery(questionAttemptQuery); - + public List findAll(QuestionAttemptQuery query) { + QuerySpecification spec = questionAttemptSpecificationFactory.forQuery(query); return questionAttemptRepository.findAll(spec, STABLE_ORDER); } - - public QuestionAttempt createQuestionAttempt(QuestionAttempt questionAttempt) { - return questionAttemptRepository.save(questionAttempt); - } } diff --git a/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptSpecificationFactory.java b/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptSpecificationFactory.java new file mode 100644 index 0000000..f8f7de6 --- /dev/null +++ b/src/main/java/com/practiq/persistence/query/attempt/QuestionAttemptSpecificationFactory.java @@ -0,0 +1,30 @@ +package com.practiq.persistence.query.attempt; + +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.QuestionAttemptEntity_; +import com.practiq.persistence.query.QueryRestriction; +import com.practiq.persistence.query.QuerySpecificationFactory; +import com.practiq.persistence.query.SessionTokenQueryRestriction; +import io.micronaut.data.repository.jpa.criteria.QuerySpecification; +import jakarta.inject.Singleton; +import java.util.List; + +@Singleton +public class QuestionAttemptSpecificationFactory + extends QuerySpecificationFactory { + + @Override + protected QuerySpecification applyDomain( + QuerySpecification specification, QuestionAttemptQuery query) { + return specification.and(hasQuestionId(query.questionId())); + } + + @Override + protected List> restrictions() { + return List.of(new SessionTokenQueryRestriction<>(QuestionAttemptEntity_.sessionToken)); + } + + private QuerySpecification hasQuestionId(long questionId) { + return (root, criteriaQuery, cb) -> cb.equal(root.get(QuestionAttemptEntity_.questionId), questionId); + } +} diff --git a/src/main/java/com/practiq/domain/query/question/QuestionQuery.java b/src/main/java/com/practiq/persistence/query/question/QuestionQuery.java similarity index 65% rename from src/main/java/com/practiq/domain/query/question/QuestionQuery.java rename to src/main/java/com/practiq/persistence/query/question/QuestionQuery.java index 032ed16..75faafb 100644 --- a/src/main/java/com/practiq/domain/query/question/QuestionQuery.java +++ b/src/main/java/com/practiq/persistence/query/question/QuestionQuery.java @@ -1,8 +1,8 @@ -package com.practiq.domain.query.question; +package com.practiq.persistence.query.question; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; import java.util.List; import lombok.Builder; import lombok.EqualsAndHashCode; diff --git a/src/main/java/com/practiq/persistence/query/question/QuestionQueryRunner.java b/src/main/java/com/practiq/persistence/query/question/QuestionQueryRunner.java new file mode 100644 index 0000000..5489aad --- /dev/null +++ b/src/main/java/com/practiq/persistence/query/question/QuestionQueryRunner.java @@ -0,0 +1,86 @@ +package com.practiq.persistence.query.question; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toSet; + +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.projection.QuestionConceptLinkProjection; +import com.practiq.persistence.repository.QuestionConceptRepository; +import com.practiq.persistence.repository.QuestionRepository; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.data.model.Sort; +import io.micronaut.data.repository.jpa.criteria.QuerySpecification; +import jakarta.inject.Singleton; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +// Runs the query it is handed. It does not know who is asking or why — the restrictions arrive already set +// on the QuestionQuery, so a caller wanting the student catalogue must have built one that says so. +@Singleton +public class QuestionQueryRunner { + + // Stable total order for pagination: created_at, then id as a tiebreak so rows can't straddle a page + // boundary ambiguously. Imposed here because it's a correctness invariant of paging, not a caller choice. + private static final Sort STABLE_ORDER = Sort.of(Sort.Order.asc("createdAt"), Sort.Order.asc("id")); + + private final QuestionRepository questionRepository; + private final QuestionConceptRepository questionConceptRepository; + private final QuestionSpecificationFactory questionSpecificationFactory; + + public QuestionQueryRunner( + QuestionRepository questionRepository, + QuestionConceptRepository questionConceptRepository, + QuestionSpecificationFactory questionSpecificationFactory) { + this.questionRepository = questionRepository; + this.questionConceptRepository = questionConceptRepository; + this.questionSpecificationFactory = questionSpecificationFactory; + } + + public boolean exists(QuestionQuery query) { + return questionRepository.exists(specificationFor(query)); + } + + public List findAll(QuestionQuery query) { + List questions = questionRepository.findAll(specificationFor(query)); + Map> conceptIds = conceptIdsByQuestionId(questions); + + return questions.stream() + .map(question -> withConceptIds(question, conceptIds)) + .toList(); + } + + public Page findPage(QuestionQuery query, Pageable pageable) { + Pageable ordered = Pageable.from(pageable.getNumber(), pageable.getSize(), STABLE_ORDER); + + Page page = questionRepository.findAll(specificationFor(query), ordered); + Map> conceptIds = conceptIdsByQuestionId(page.getContent()); + + return page.map(question -> withConceptIds(question, conceptIds)); + } + + private QuerySpecification specificationFor(QuestionQuery query) { + return questionSpecificationFactory.forQuery(query); + } + + private QuestionWithConceptIds withConceptIds(QuestionEntity question, Map> conceptIds) { + return new QuestionWithConceptIds(question, conceptIds.getOrDefault(question.getId(), Set.of())); + } + + // The second half of the two-query stitch: fetch-joining the concept collection into a paged query makes + // Hibernate page in memory, so the links are read separately and matched up here. + private Map> conceptIdsByQuestionId(Collection questions) { + Set questionIds = questions.stream().map(QuestionEntity::getId).collect(toSet()); + if (questionIds.isEmpty()) { + return Map.of(); + } + + return questionConceptRepository.findLinksByQuestionIds(questionIds).stream() + .collect(groupingBy( + QuestionConceptLinkProjection::questionId, + mapping(QuestionConceptLinkProjection::conceptId, toSet()))); + } +} diff --git a/src/main/java/com/practiq/domain/query/question/QuestionSpecificationFactory.java b/src/main/java/com/practiq/persistence/query/question/QuestionSpecificationFactory.java similarity index 54% rename from src/main/java/com/practiq/domain/query/question/QuestionSpecificationFactory.java rename to src/main/java/com/practiq/persistence/query/question/QuestionSpecificationFactory.java index b1e68aa..9dea2b5 100644 --- a/src/main/java/com/practiq/domain/query/question/QuestionSpecificationFactory.java +++ b/src/main/java/com/practiq/persistence/query/question/QuestionSpecificationFactory.java @@ -1,14 +1,14 @@ -package com.practiq.domain.query.question; +package com.practiq.persistence.query.question; -import com.practiq.domain.Question; -import com.practiq.domain.QuestionConcept; -import com.practiq.domain.QuestionConceptId_; -import com.practiq.domain.QuestionConcept_; -import com.practiq.domain.Question_; -import com.practiq.domain.query.QuerySpecificationFactory; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.QuestionConceptEntity; +import com.practiq.persistence.QuestionConceptEntityId_; +import com.practiq.persistence.QuestionConceptEntity_; +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.QuestionEntity_; +import com.practiq.persistence.query.QuerySpecificationFactory; import io.micronaut.core.util.CollectionUtils; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import jakarta.inject.Singleton; @@ -17,11 +17,11 @@ import java.util.List; @Singleton -public class QuestionSpecificationFactory extends QuerySpecificationFactory { +public class QuestionSpecificationFactory extends QuerySpecificationFactory { @Override - protected QuerySpecification applyDomain( - QuerySpecification specification, QuestionQuery query) { + protected QuerySpecification applyDomain( + QuerySpecification specification, QuestionQuery query) { if (query.getStatus() != null) { specification = specification.and(hasStatus(query.getStatus())); } @@ -50,50 +50,53 @@ protected QuerySpecification applyDomain( return specification; } - private QuerySpecification hasStatus(QuestionStatus status) { - return (root, criteriaQuery, cb) -> cb.equal(root.get(Question_.status), status); + private QuerySpecification hasStatus(QuestionStatus status) { + return (root, criteriaQuery, cb) -> cb.equal(root.get(QuestionEntity_.status), status); } - private QuerySpecification isInQuestionTypes(List types) { - return (root, criteriaQuery, cb) -> root.get(Question_.type).in(types); + private QuerySpecification isInQuestionTypes(List types) { + return (root, criteriaQuery, cb) -> root.get(QuestionEntity_.type).in(types); } - private QuerySpecification isInQuestionDifficulties(List difficulties) { - return (root, criteriaQuery, cb) -> root.get(Question_.difficulty).in(difficulties); + private QuerySpecification isInQuestionDifficulties(List difficulties) { + return (root, criteriaQuery, cb) -> root.get(QuestionEntity_.difficulty).in(difficulties); } // Filter on the to-many via EXISTS, deliberately NOT a join: joining question_concept would // multiply question rows (one per link) and corrupt the page count. EXISTS keeps exactly one row // per question, so pagination and counting stay correct with no distinct needed. This is why the // filter side stays clean even as concept filtering arrives. - private QuerySpecification hasConceptForId(long conceptId) { + private QuerySpecification hasConceptForId(long conceptId) { return (root, criteriaQuery, cb) -> { Subquery matchingLink = criteriaQuery.subquery(Long.class); - Root link = matchingLink.from(QuestionConcept.class); + Root link = matchingLink.from(QuestionConceptEntity.class); matchingLink .select(cb.literal(1L)) .where( cb.equal( - link.get(QuestionConcept_.id).get(QuestionConceptId_.questionId), - root.get(Question_.id)), - cb.equal(link.get(QuestionConcept_.id).get(QuestionConceptId_.conceptId), conceptId)); + link.get(QuestionConceptEntity_.id).get(QuestionConceptEntityId_.questionId), + root.get(QuestionEntity_.id)), + cb.equal( + link.get(QuestionConceptEntity_.id).get(QuestionConceptEntityId_.conceptId), + conceptId)); return cb.exists(matchingLink); }; } - private QuerySpecification hasConcept() { + private QuerySpecification hasConcept() { return (root, criteriaQuery, cb) -> { Subquery matchingLink = criteriaQuery.subquery(Long.class); - Root link = matchingLink.from(QuestionConcept.class); + Root link = matchingLink.from(QuestionConceptEntity.class); matchingLink .select(cb.literal(1L)) .where(cb.equal( - link.get(QuestionConcept_.id).get(QuestionConceptId_.questionId), root.get(Question_.id))); + link.get(QuestionConceptEntity_.id).get(QuestionConceptEntityId_.questionId), + root.get(QuestionEntity_.id))); return cb.exists(matchingLink); }; } - private QuerySpecification hasId(long id) { - return (root, criteriaQuery, cb) -> cb.equal(root.get(Question_.id), id); + private QuerySpecification hasId(long id) { + return (root, criteriaQuery, cb) -> cb.equal(root.get(QuestionEntity_.id), id); } } diff --git a/src/main/java/com/practiq/persistence/query/question/QuestionWithConceptIds.java b/src/main/java/com/practiq/persistence/query/question/QuestionWithConceptIds.java new file mode 100644 index 0000000..0fbd885 --- /dev/null +++ b/src/main/java/com/practiq/persistence/query/question/QuestionWithConceptIds.java @@ -0,0 +1,8 @@ +package com.practiq.persistence.query.question; + +import com.practiq.persistence.QuestionEntity; +import java.util.Set; + +// A question as read, with the concept ids that a fetch-join would have carried had paging allowed one. +// Assembled by the runner rather than returned by a query, so it is deliberately not called a projection. +public record QuestionWithConceptIds(QuestionEntity question, Set conceptIds) {} diff --git a/src/main/java/com/practiq/repository/ConceptRepository.java b/src/main/java/com/practiq/persistence/repository/ConceptRepository.java similarity index 56% rename from src/main/java/com/practiq/repository/ConceptRepository.java rename to src/main/java/com/practiq/persistence/repository/ConceptRepository.java index 62dc4dc..d66c922 100644 --- a/src/main/java/com/practiq/repository/ConceptRepository.java +++ b/src/main/java/com/practiq/persistence/repository/ConceptRepository.java @@ -1,11 +1,11 @@ -package com.practiq.repository; +package com.practiq.persistence.repository; -import com.practiq.domain.Concept; +import com.practiq.persistence.ConceptEntity; import io.micronaut.data.annotation.Repository; import io.micronaut.data.repository.CrudRepository; import java.util.List; @Repository -public interface ConceptRepository extends CrudRepository { - List listOrderByCreatedAtAsc(); +public interface ConceptRepository extends CrudRepository { + List listOrderByCreatedAtAsc(); } diff --git a/src/main/java/com/practiq/repository/DocumentRepository.java b/src/main/java/com/practiq/persistence/repository/DocumentRepository.java similarity index 61% rename from src/main/java/com/practiq/repository/DocumentRepository.java rename to src/main/java/com/practiq/persistence/repository/DocumentRepository.java index 572fd2c..4bcb3ba 100644 --- a/src/main/java/com/practiq/repository/DocumentRepository.java +++ b/src/main/java/com/practiq/persistence/repository/DocumentRepository.java @@ -1,8 +1,8 @@ -package com.practiq.repository; +package com.practiq.persistence.repository; -import com.practiq.domain.Document; +import com.practiq.persistence.DocumentEntity; import io.micronaut.data.annotation.Repository; import io.micronaut.data.repository.CrudRepository; @Repository -public interface DocumentRepository extends CrudRepository {} +public interface DocumentRepository extends CrudRepository {} diff --git a/src/main/java/com/practiq/repository/MarkSchemeRepository.java b/src/main/java/com/practiq/persistence/repository/MarkSchemeRepository.java similarity index 53% rename from src/main/java/com/practiq/repository/MarkSchemeRepository.java rename to src/main/java/com/practiq/persistence/repository/MarkSchemeRepository.java index dd6e30c..5b2161f 100644 --- a/src/main/java/com/practiq/repository/MarkSchemeRepository.java +++ b/src/main/java/com/practiq/persistence/repository/MarkSchemeRepository.java @@ -1,11 +1,11 @@ -package com.practiq.repository; +package com.practiq.persistence.repository; -import com.practiq.domain.MarkScheme; +import com.practiq.persistence.MarkSchemeEntity; import io.micronaut.data.annotation.Repository; import io.micronaut.data.repository.CrudRepository; import java.util.Optional; @Repository -public interface MarkSchemeRepository extends CrudRepository { - Optional findByQuestionId(long questionId); +public interface MarkSchemeRepository extends CrudRepository { + Optional findByQuestionId(long questionId); } diff --git a/src/main/java/com/practiq/repository/QuestionAttemptRepository.java b/src/main/java/com/practiq/persistence/repository/QuestionAttemptRepository.java similarity index 51% rename from src/main/java/com/practiq/repository/QuestionAttemptRepository.java rename to src/main/java/com/practiq/persistence/repository/QuestionAttemptRepository.java index b8d3ce2..2f97a4d 100644 --- a/src/main/java/com/practiq/repository/QuestionAttemptRepository.java +++ b/src/main/java/com/practiq/persistence/repository/QuestionAttemptRepository.java @@ -1,10 +1,10 @@ -package com.practiq.repository; +package com.practiq.persistence.repository; -import com.practiq.domain.QuestionAttempt; +import com.practiq.persistence.QuestionAttemptEntity; import io.micronaut.data.annotation.Repository; import io.micronaut.data.repository.CrudRepository; import io.micronaut.data.repository.jpa.JpaSpecificationExecutor; @Repository public interface QuestionAttemptRepository - extends CrudRepository, JpaSpecificationExecutor {} + extends CrudRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/practiq/persistence/repository/QuestionConceptRepository.java b/src/main/java/com/practiq/persistence/repository/QuestionConceptRepository.java new file mode 100644 index 0000000..9927b37 --- /dev/null +++ b/src/main/java/com/practiq/persistence/repository/QuestionConceptRepository.java @@ -0,0 +1,22 @@ +package com.practiq.persistence.repository; + +import com.practiq.persistence.QuestionConceptEntity; +import com.practiq.persistence.QuestionConceptEntityId; +import com.practiq.persistence.projection.QuestionConceptLinkProjection; +import io.micronaut.data.annotation.Query; +import io.micronaut.data.annotation.Repository; +import io.micronaut.data.repository.GenericRepository; +import java.util.Collection; +import java.util.List; + +@Repository +public interface QuestionConceptRepository extends GenericRepository { + + // Loads just the (questionId, conceptId) pairs for a set of questions, so the query runner can attach + // concept ids without fetch-joining the collection into a paged query. Selecting the two scalars into a + // projection (with explicit aliases) avoids both the lazy associations and Micronaut Data's inability to + // map a selected @EmbeddedId back to QuestionConceptEntityId (see QuestionConceptLinkProjection). + @Query("SELECT qc.id.questionId AS questionId, qc.id.conceptId AS conceptId " + + "FROM QuestionConceptEntity qc WHERE qc.id.questionId IN (:questionIds)") + List findLinksByQuestionIds(Collection questionIds); +} diff --git a/src/main/java/com/practiq/persistence/repository/QuestionRepository.java b/src/main/java/com/practiq/persistence/repository/QuestionRepository.java new file mode 100644 index 0000000..4e1436c --- /dev/null +++ b/src/main/java/com/practiq/persistence/repository/QuestionRepository.java @@ -0,0 +1,10 @@ +package com.practiq.persistence.repository; + +import com.practiq.persistence.QuestionEntity; +import io.micronaut.data.annotation.Repository; +import io.micronaut.data.repository.CrudRepository; +import io.micronaut.data.repository.jpa.JpaSpecificationExecutor; + +@Repository +public interface QuestionRepository + extends CrudRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/practiq/repository/QuestionConceptRepository.java b/src/main/java/com/practiq/repository/QuestionConceptRepository.java deleted file mode 100644 index c72b803..0000000 --- a/src/main/java/com/practiq/repository/QuestionConceptRepository.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.practiq.repository; - -import com.practiq.domain.QuestionConcept; -import com.practiq.domain.QuestionConceptId; -import com.practiq.domain.projection.QuestionConceptLink; -import io.micronaut.data.annotation.Query; -import io.micronaut.data.annotation.Repository; -import io.micronaut.data.repository.GenericRepository; -import java.util.Collection; -import java.util.List; - -@Repository -public interface QuestionConceptRepository extends GenericRepository { - - // Loads just the (questionId, conceptId) pairs for a page of questions, so the service can stitch - // concept ids onto the DTOs without fetch-joining the collection into the paged query. Selecting the - // two scalars into a projection (with explicit aliases) avoids both the lazy associations and Micronaut - // Data's inability to map a selected @EmbeddedId back to QuestionConceptId (see QuestionConceptLink). - @Query("SELECT qc.id.questionId AS questionId, qc.id.conceptId AS conceptId " - + "FROM QuestionConcept qc WHERE qc.id.questionId IN (:questionIds)") - List findLinksByQuestionIds(Collection questionIds); -} diff --git a/src/main/java/com/practiq/repository/QuestionOriginRepository.java b/src/main/java/com/practiq/repository/QuestionOriginRepository.java deleted file mode 100644 index eb4b876..0000000 --- a/src/main/java/com/practiq/repository/QuestionOriginRepository.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.practiq.repository; - -import com.practiq.domain.QuestionOrigin; -import io.micronaut.data.annotation.Repository; -import io.micronaut.data.repository.CrudRepository; - -@Repository -public interface QuestionOriginRepository extends CrudRepository {} diff --git a/src/main/java/com/practiq/repository/QuestionRepository.java b/src/main/java/com/practiq/repository/QuestionRepository.java deleted file mode 100644 index 8b492b1..0000000 --- a/src/main/java/com/practiq/repository/QuestionRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.practiq.repository; - -import com.practiq.domain.Question; -import io.micronaut.data.annotation.Repository; -import io.micronaut.data.repository.CrudRepository; -import io.micronaut.data.repository.jpa.JpaSpecificationExecutor; - -@Repository -public interface QuestionRepository extends CrudRepository, JpaSpecificationExecutor {} diff --git a/src/main/java/com/practiq/service/MarkSchemeService.java b/src/main/java/com/practiq/service/MarkSchemeService.java deleted file mode 100644 index e90a8a6..0000000 --- a/src/main/java/com/practiq/service/MarkSchemeService.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.practiq.service; - -import com.practiq.domain.query.question.StudentQuestionQueryRunner; -import com.practiq.dto.mapper.MarkSchemeResponseMapper; -import com.practiq.dto.response.MarkSchemeResponse; -import com.practiq.repository.MarkSchemeRepository; -import io.micronaut.transaction.annotation.Transactional; -import jakarta.inject.Singleton; -import java.util.Optional; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Singleton -public class MarkSchemeService { - - private final StudentQuestionQueryRunner questionQueryRunner; - private final MarkSchemeRepository markSchemeRepository; - - public MarkSchemeService( - StudentQuestionQueryRunner questionQueryRunner, MarkSchemeRepository markSchemeRepository) { - this.questionQueryRunner = questionQueryRunner; - this.markSchemeRepository = markSchemeRepository; - } - - @Transactional(readOnly = true) - public Optional getForQuestionId(long questionId) { - log.debug("Getting mark scheme for question id: {}", questionId); - - if (questionQueryRunner.doesQuestionExistForId(questionId)) { - return markSchemeRepository - .findByQuestionId(questionId) - .map(MarkSchemeResponseMapper::toMarkSchemeResponse); - } else { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/practiq/service/QuestionAttemptService.java b/src/main/java/com/practiq/service/QuestionAttemptService.java deleted file mode 100644 index d007941..0000000 --- a/src/main/java/com/practiq/service/QuestionAttemptService.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.practiq.service; - -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.identity.UserRef; -import com.practiq.domain.query.attempt.QuestionAttemptQueryRunner; -import com.practiq.domain.query.question.StudentQuestionQueryRunner; -import com.practiq.dto.mapper.QuestionAttemptResponseMapper; -import com.practiq.dto.request.QuestionAttemptRequest; -import com.practiq.dto.response.QuestionAttemptResponse; -import io.micronaut.transaction.annotation.Transactional; -import jakarta.inject.Singleton; -import jakarta.validation.Valid; -import java.util.List; -import java.util.Optional; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Singleton -public class QuestionAttemptService { - - private final StudentQuestionQueryRunner questionQueryRunner; - private final QuestionAttemptQueryRunner questionAttemptQueryRunner; - - public QuestionAttemptService( - StudentQuestionQueryRunner questionQueryRunner, QuestionAttemptQueryRunner questionAttemptQueryRunner) { - this.questionQueryRunner = questionQueryRunner; - this.questionAttemptQueryRunner = questionAttemptQueryRunner; - } - - @Transactional(readOnly = true) - public Optional> getForQuestionId(@Valid UserRef userRef, long questionId) { - log.debug("Getting question attempt for question id: {}", questionId); - - if (questionQueryRunner.doesQuestionExistForId(questionId)) { - return Optional.of(questionAttemptQueryRunner.getQuestionAttempts(userRef, questionId).stream() - .map(QuestionAttemptResponseMapper::toQuestionAttemptResponse) - .toList()); - } else { - return Optional.empty(); - } - } - - @Transactional - public Optional postForQuestionId( - @Valid UserRef userRef, @Valid QuestionAttemptRequest request, long questionId) { - log.debug("Posting question attempt for question id: {}", questionId); - - if (questionQueryRunner.doesQuestionExistForId(questionId)) { - QuestionAttempt attempt = new QuestionAttempt(questionId, userRef.sessionToken(), request.body()); - QuestionAttempt savedAttempt = questionAttemptQueryRunner.createQuestionAttempt(attempt); - QuestionAttemptResponse response = QuestionAttemptResponseMapper.toQuestionAttemptResponse(savedAttempt); - return Optional.of(response); - } else { - return Optional.empty(); - } - } -} diff --git a/src/main/java/com/practiq/service/QuestionService.java b/src/main/java/com/practiq/service/QuestionService.java deleted file mode 100644 index ee57f60..0000000 --- a/src/main/java/com/practiq/service/QuestionService.java +++ /dev/null @@ -1,42 +0,0 @@ -package com.practiq.service; - -import static com.practiq.dto.mapper.QuestionResponseMapper.toQuestionResponses; - -import com.practiq.domain.projection.LinkedQuestion; -import com.practiq.domain.query.question.StudentQuestionQueryRunner; -import com.practiq.dto.mapper.QuestionResponseMapper; -import com.practiq.dto.request.QuestionRequest; -import com.practiq.dto.response.PageResponse; -import com.practiq.dto.response.QuestionResponse; -import io.micronaut.data.model.Page; -import io.micronaut.data.model.Pageable; -import io.micronaut.transaction.annotation.Transactional; -import jakarta.inject.Singleton; -import jakarta.validation.Valid; -import java.util.Optional; -import lombok.extern.slf4j.Slf4j; - -@Slf4j -@Singleton -public class QuestionService { - - private final StudentQuestionQueryRunner questionQueryRunner; - - public QuestionService(StudentQuestionQueryRunner questionQueryRunner) { - this.questionQueryRunner = questionQueryRunner; - } - - @Transactional(readOnly = true) - public Optional get(long id) { - log.debug("Getting question for id {}", id); - return questionQueryRunner.findQuestionById(id).map(QuestionResponseMapper::toQuestionResponse); - } - - @Transactional(readOnly = true) - public PageResponse get(@Valid QuestionRequest request, Pageable pageable) { - log.debug("Getting approved questions, page {}", pageable.getNumber()); - Page page = questionQueryRunner.findQuestionsPagedAndFiltered( - request.types(), request.difficulties(), request.conceptId(), pageable); - return PageResponse.of(page, toQuestionResponses(page.getContent())); - } -} diff --git a/src/main/java/com/practiq/service/UserRef.java b/src/main/java/com/practiq/service/UserRef.java new file mode 100644 index 0000000..02596a7 --- /dev/null +++ b/src/main/java/com/practiq/service/UserRef.java @@ -0,0 +1,21 @@ +package com.practiq.service; + +import com.practiq.foundation.util.StringUtil; +import com.practiq.persistence.QuestionAttemptEntity; + +// The user an operation is about — not necessarily the caller. Both guards are caller bugs rather than bad +// input: the wire rejects a blank or oversized session-token header with a 422 before anything constructs +// one of these, so reaching here with either means the value came from somewhere that never checked. +public record UserRef(String sessionToken) { + + public UserRef { + if (StringUtil.isBlank(sessionToken)) { + throw new IllegalArgumentException("sessionToken must not be blank"); + } + + if (sessionToken.length() > QuestionAttemptEntity.SESSION_TOKEN_MAX_LENGTH) { + throw new IllegalArgumentException( + "sessionToken cannot exceed max length " + QuestionAttemptEntity.SESSION_TOKEN_MAX_LENGTH); + } + } +} diff --git a/src/main/java/com/practiq/service/attempt/QuestionAttemptService.java b/src/main/java/com/practiq/service/attempt/QuestionAttemptService.java new file mode 100644 index 0000000..36b14b0 --- /dev/null +++ b/src/main/java/com/practiq/service/attempt/QuestionAttemptService.java @@ -0,0 +1,67 @@ +package com.practiq.service.attempt; + +import static com.practiq.service.question.QuestionAccessorFactory.STUDENT; + +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.query.attempt.QuestionAttemptQuery; +import com.practiq.persistence.query.attempt.QuestionAttemptQueryRunner; +import com.practiq.persistence.repository.QuestionAttemptRepository; +import com.practiq.service.UserRef; +import com.practiq.service.attempt.dto.request.QuestionAttemptCommand; +import com.practiq.service.attempt.dto.response.QuestionAttempt; +import com.practiq.service.attempt.mapper.QuestionAttemptMapper; +import com.practiq.service.question.QuestionAccessor; +import io.micronaut.transaction.annotation.Transactional; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.util.List; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class QuestionAttemptService { + + private final QuestionAccessor questions; + private final QuestionAttemptQueryRunner questionAttemptQueryRunner; + private final QuestionAttemptRepository questionAttemptRepository; + + public QuestionAttemptService( + @Named(STUDENT) QuestionAccessor questions, + QuestionAttemptQueryRunner questionAttemptQueryRunner, + QuestionAttemptRepository questionAttemptRepository) { + this.questions = questions; + this.questionAttemptQueryRunner = questionAttemptQueryRunner; + this.questionAttemptRepository = questionAttemptRepository; + } + + // An empty Optional means the question is not one this user may attempt; an empty list means they have + // not attempted it yet. The caller needs to tell those apart to answer 404 rather than an empty array. + @Transactional(readOnly = true) + public Optional> getForQuestionId(UserRef userRef, long questionId) { + log.debug("Getting question attempts for question id: {}", questionId); + + if (!questions.exists(questionId)) { + return Optional.empty(); + } + + QuestionAttemptQuery query = new QuestionAttemptQuery(questionId, userRef.sessionToken()); + return Optional.of(questionAttemptQueryRunner.findAll(query).stream() + .map(QuestionAttemptMapper::toQuestionAttempt) + .toList()); + } + + @Transactional + public Optional create(QuestionAttemptCommand command) { + log.debug("Posting question attempt for question id: {}", command.questionId()); + + if (!questions.exists(command.questionId())) { + return Optional.empty(); + } + + QuestionAttemptEntity attempt = new QuestionAttemptEntity( + command.questionId(), command.userRef().sessionToken(), command.body()); + + return Optional.of(QuestionAttemptMapper.toQuestionAttempt(questionAttemptRepository.save(attempt))); + } +} diff --git a/src/main/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommand.java b/src/main/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommand.java new file mode 100644 index 0000000..8116111 --- /dev/null +++ b/src/main/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommand.java @@ -0,0 +1,25 @@ +package com.practiq.service.attempt.dto.request; + +import com.practiq.foundation.util.StringUtil; +import com.practiq.service.UserRef; + +// A request to record an attempt that has already been checked for shape at the wire. Reaching here with a +// bad value means a caller has a bug, so these fail loudly rather than becoming an EntityValidationException +// the client would see as a 422. +public record QuestionAttemptCommand(long questionId, UserRef userRef, String body) { + + public QuestionAttemptCommand { + if (questionId < 1) { + throw new IllegalArgumentException("questionId must be greater than or equal to 1"); + } + + // Only null needs checking: a UserRef that exists is blank-free by construction. + if (userRef == null) { + throw new IllegalArgumentException("userRef must not be null"); + } + + if (StringUtil.isBlank(body)) { + throw new IllegalArgumentException("body must not be blank"); + } + } +} diff --git a/src/main/java/com/practiq/service/attempt/dto/response/QuestionAttempt.java b/src/main/java/com/practiq/service/attempt/dto/response/QuestionAttempt.java new file mode 100644 index 0000000..2b05685 --- /dev/null +++ b/src/main/java/com/practiq/service/attempt/dto/response/QuestionAttempt.java @@ -0,0 +1,5 @@ +package com.practiq.service.attempt.dto.response; + +import java.time.Instant; + +public record QuestionAttempt(long id, long questionId, String sessionToken, String body, Instant createdAt) {} diff --git a/src/main/java/com/practiq/service/attempt/mapper/QuestionAttemptMapper.java b/src/main/java/com/practiq/service/attempt/mapper/QuestionAttemptMapper.java new file mode 100644 index 0000000..b007a39 --- /dev/null +++ b/src/main/java/com/practiq/service/attempt/mapper/QuestionAttemptMapper.java @@ -0,0 +1,16 @@ +package com.practiq.service.attempt.mapper; + +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.service.attempt.dto.response.QuestionAttempt; + +public class QuestionAttemptMapper { + + public static QuestionAttempt toQuestionAttempt(QuestionAttemptEntity questionAttemptEntity) { + return new QuestionAttempt( + questionAttemptEntity.getId(), + questionAttemptEntity.getQuestionId(), + questionAttemptEntity.getSessionToken(), + questionAttemptEntity.getBody(), + questionAttemptEntity.getCreatedAt()); + } +} diff --git a/src/main/java/com/practiq/service/ConceptService.java b/src/main/java/com/practiq/service/concept/ConceptService.java similarity index 58% rename from src/main/java/com/practiq/service/ConceptService.java rename to src/main/java/com/practiq/service/concept/ConceptService.java index 2ed5cba..2e10d66 100644 --- a/src/main/java/com/practiq/service/ConceptService.java +++ b/src/main/java/com/practiq/service/concept/ConceptService.java @@ -1,8 +1,8 @@ -package com.practiq.service; +package com.practiq.service.concept; -import com.practiq.dto.mapper.ConceptResponseMapper; -import com.practiq.dto.response.ConceptResponse; -import com.practiq.repository.ConceptRepository; +import com.practiq.persistence.repository.ConceptRepository; +import com.practiq.service.concept.dto.response.Concept; +import com.practiq.service.concept.mapper.ConceptMapper; import jakarta.inject.Singleton; import java.util.List; import java.util.Optional; @@ -12,21 +12,22 @@ @Slf4j @Singleton public class ConceptService { + private final ConceptRepository conceptRepository; public ConceptService(ConceptRepository conceptRepository) { this.conceptRepository = conceptRepository; } - public List get() { + public List get() { log.debug("Getting all concepts"); return conceptRepository.listOrderByCreatedAtAsc().stream() - .map(ConceptResponseMapper::toConceptResponse) + .map(ConceptMapper::toConcept) .collect(Collectors.toList()); } - public Optional get(long id) { + public Optional getById(long id) { log.debug("Getting concept by id: {}", id); - return conceptRepository.findById(id).map(ConceptResponseMapper::toConceptResponse); + return conceptRepository.findById(id).map(ConceptMapper::toConcept); } } diff --git a/src/main/java/com/practiq/service/concept/dto/response/Concept.java b/src/main/java/com/practiq/service/concept/dto/response/Concept.java new file mode 100644 index 0000000..1cfcf58 --- /dev/null +++ b/src/main/java/com/practiq/service/concept/dto/response/Concept.java @@ -0,0 +1,5 @@ +package com.practiq.service.concept.dto.response; + +import java.time.Instant; + +public record Concept(long id, int version, String name, String description, Instant createdAt) {} diff --git a/src/main/java/com/practiq/service/concept/mapper/ConceptMapper.java b/src/main/java/com/practiq/service/concept/mapper/ConceptMapper.java new file mode 100644 index 0000000..a5a536e --- /dev/null +++ b/src/main/java/com/practiq/service/concept/mapper/ConceptMapper.java @@ -0,0 +1,15 @@ +package com.practiq.service.concept.mapper; + +import com.practiq.persistence.ConceptEntity; +import com.practiq.service.concept.dto.response.Concept; + +public class ConceptMapper { + public static Concept toConcept(ConceptEntity conceptEntity) { + return new Concept( + conceptEntity.getId(), + conceptEntity.getVersion(), + conceptEntity.getName(), + conceptEntity.getDescription(), + conceptEntity.getCreatedAt()); + } +} diff --git a/src/main/java/com/practiq/service/DocumentService.java b/src/main/java/com/practiq/service/document/DocumentService.java similarity index 62% rename from src/main/java/com/practiq/service/DocumentService.java rename to src/main/java/com/practiq/service/document/DocumentService.java index d684e18..ee6a23e 100644 --- a/src/main/java/com/practiq/service/DocumentService.java +++ b/src/main/java/com/practiq/service/document/DocumentService.java @@ -1,19 +1,15 @@ -package com.practiq.service; +package com.practiq.service.document; import static com.practiq.service.document.DocumentUploadRules.UPLOAD_URL_EXPIRY; -import com.practiq.domain.Document; -import com.practiq.dto.request.PostDocumentRequest; -import com.practiq.dto.response.UploadDocumentResponse; -import com.practiq.repository.DocumentRepository; -import com.practiq.service.document.DocumentStager; -import com.practiq.service.document.DocumentUploadCommand; -import com.practiq.service.document.StagedDocumentUpload; +import com.practiq.persistence.DocumentEntity; +import com.practiq.persistence.repository.DocumentRepository; +import com.practiq.service.document.dto.request.DocumentPresignUploadCommand; +import com.practiq.service.document.dto.response.DocumentPresignUpload; import com.practiq.storage.PresignedUpload; import com.practiq.storage.PresignedUploadRequest; import com.practiq.storage.S3DocumentStorage; import jakarta.inject.Singleton; -import jakarta.validation.Valid; @Singleton public class DocumentService { @@ -29,9 +25,7 @@ public DocumentService( this.documentStorage = documentStorage; } - public UploadDocumentResponse stageDocumentUpload(@Valid PostDocumentRequest request) { - DocumentUploadCommand uploadCommand = new DocumentUploadCommand( - request.filename(), request.contentType(), request.contentLength(), request.sourceSpec()); + public DocumentPresignUpload stageUploadAndPresign(DocumentPresignUploadCommand uploadCommand) { StagedDocumentUpload stagedDocumentUpload = documentStager.stageUpload(uploadCommand); PresignedUploadRequest presignedUploadRequest = new PresignedUploadRequest( @@ -41,11 +35,11 @@ public UploadDocumentResponse stageDocumentUpload(@Valid PostDocumentRequest req UPLOAD_URL_EXPIRY); PresignedUpload presignedUpload = documentStorage.presignUpload(presignedUploadRequest); - Document document = Document.newUpload( + DocumentEntity document = DocumentEntity.newUpload( stagedDocumentUpload.key(), stagedDocumentUpload.filename(), stagedDocumentUpload.sourceSpec()); - Document savedDocument = documentRepository.save(document); + DocumentEntity savedDocument = documentRepository.save(document); - return new UploadDocumentResponse( + return new DocumentPresignUpload( savedDocument.getId(), presignedUpload.url().toString(), presignedUpload.expiresAt()); } } diff --git a/src/main/java/com/practiq/service/document/DocumentStager.java b/src/main/java/com/practiq/service/document/DocumentStager.java index 842cf47..3f4bfe9 100644 --- a/src/main/java/com/practiq/service/document/DocumentStager.java +++ b/src/main/java/com/practiq/service/document/DocumentStager.java @@ -2,9 +2,10 @@ import static com.practiq.service.document.DocumentUploadRules.MAX_CONTENT_LENGTH; -import com.practiq.exception.ContentTooLargeException; -import com.practiq.exception.EntityValidationException; -import com.practiq.util.StringUtil; +import com.practiq.foundation.exception.ContentTooLargeException; +import com.practiq.foundation.exception.EntityValidationException; +import com.practiq.foundation.util.StringUtil; +import com.practiq.service.document.dto.request.DocumentPresignUploadCommand; import io.micronaut.http.MediaType; import jakarta.inject.Singleton; import java.util.Arrays; @@ -22,7 +23,7 @@ public class DocumentStager { public DocumentStager() {} - public StagedDocumentUpload stageUpload(DocumentUploadCommand command) { + public StagedDocumentUpload stageUpload(DocumentPresignUploadCommand command) { int contentLength = command.contentLength(); if (contentLength > MAX_CONTENT_LENGTH) { throw new ContentTooLargeException( diff --git a/src/main/java/com/practiq/service/document/StagedDocumentUpload.java b/src/main/java/com/practiq/service/document/StagedDocumentUpload.java index 77d4f66..6e42042 100644 --- a/src/main/java/com/practiq/service/document/StagedDocumentUpload.java +++ b/src/main/java/com/practiq/service/document/StagedDocumentUpload.java @@ -1,26 +1,6 @@ package com.practiq.service.document; -import com.practiq.util.StringUtil; import io.micronaut.http.MediaType; public record StagedDocumentUpload( - String key, String filename, String sourceSpec, MediaType contentType, int contentLength) { - - // Facts about any staged upload, however it was built — not the request rules, which stay in - // DocumentStager. Reaching here with a bad value means a caller has a bug, so these fail loudly - // rather than becoming an EntityValidationException the client would see as a 422. - public StagedDocumentUpload { - if (StringUtil.isBlank(key)) { - throw new IllegalArgumentException("key must not be blank"); - } - if (StringUtil.isBlank(filename)) { - throw new IllegalArgumentException("filename must not be blank"); - } - if (contentType == null) { - throw new IllegalArgumentException("contentType must not be null"); - } - if (contentLength < 1) { - throw new IllegalArgumentException("contentLength must be greater than or equal to 1"); - } - } -} + String key, String filename, String sourceSpec, MediaType contentType, int contentLength) {} diff --git a/src/main/java/com/practiq/service/document/DocumentUploadCommand.java b/src/main/java/com/practiq/service/document/dto/request/DocumentPresignUploadCommand.java similarity index 50% rename from src/main/java/com/practiq/service/document/DocumentUploadCommand.java rename to src/main/java/com/practiq/service/document/dto/request/DocumentPresignUploadCommand.java index f4dcaba..e661686 100644 --- a/src/main/java/com/practiq/service/document/DocumentUploadCommand.java +++ b/src/main/java/com/practiq/service/document/dto/request/DocumentPresignUploadCommand.java @@ -1,25 +1,35 @@ -package com.practiq.service.document; +package com.practiq.service.document.dto.request; -import com.practiq.util.StringUtil; +import com.practiq.foundation.util.StringUtil; +import com.practiq.persistence.DocumentEntity; // A staging request that has already been checked for shape, so DocumentStager is left with the // rules that are its own — the size cap, the allow-list, and the declared-versus-derived match. // Reaching here with a bad value means a caller has a bug, so these fail loudly rather than // becoming an EntityValidationException the client would see as a 422. -public record DocumentUploadCommand(String filename, String contentType, Integer contentLength, String sourceSpec) { +public record DocumentPresignUploadCommand(String filename, String contentType, int contentLength, String sourceSpec) { - public DocumentUploadCommand { + public DocumentPresignUploadCommand { if (StringUtil.isBlank(filename)) { throw new IllegalArgumentException("filename must not be blank"); } + + if (filename.length() > DocumentEntity.FILENAME_MAX_LENGTH) { + throw new IllegalArgumentException( + "filename cannot exceed max length " + DocumentEntity.FILENAME_MAX_LENGTH); + } + if (StringUtil.isBlank(contentType)) { throw new IllegalArgumentException("contentType must not be blank"); } - if (contentLength == null) { - throw new IllegalArgumentException("contentLength must not be null"); - } + if (contentLength < 1) { throw new IllegalArgumentException("contentLength must be greater than or equal to 1"); } + + if (sourceSpec != null && sourceSpec.length() > DocumentEntity.SOURCE_SPEC_MAX_LENGTH) { + throw new IllegalArgumentException( + "sourceSpec cannot exceed max length " + DocumentEntity.SOURCE_SPEC_MAX_LENGTH); + } } } diff --git a/src/main/java/com/practiq/service/document/dto/response/DocumentPresignUpload.java b/src/main/java/com/practiq/service/document/dto/response/DocumentPresignUpload.java new file mode 100644 index 0000000..9f55ca7 --- /dev/null +++ b/src/main/java/com/practiq/service/document/dto/response/DocumentPresignUpload.java @@ -0,0 +1,5 @@ +package com.practiq.service.document.dto.response; + +import java.time.Instant; + +public record DocumentPresignUpload(long id, String url, Instant expiresAt) {} diff --git a/src/main/java/com/practiq/service/markscheme/MarkSchemeService.java b/src/main/java/com/practiq/service/markscheme/MarkSchemeService.java new file mode 100644 index 0000000..8520229 --- /dev/null +++ b/src/main/java/com/practiq/service/markscheme/MarkSchemeService.java @@ -0,0 +1,37 @@ +package com.practiq.service.markscheme; + +import static com.practiq.service.question.QuestionAccessorFactory.STUDENT; + +import com.practiq.persistence.repository.MarkSchemeRepository; +import com.practiq.service.markscheme.dto.response.MarkScheme; +import com.practiq.service.markscheme.mapper.MarkSchemeMapper; +import com.practiq.service.question.QuestionAccessor; +import io.micronaut.transaction.annotation.Transactional; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class MarkSchemeService { + + private final QuestionAccessor questions; + private final MarkSchemeRepository markSchemeRepository; + + public MarkSchemeService(@Named(STUDENT) QuestionAccessor questions, MarkSchemeRepository markSchemeRepository) { + this.questions = questions; + this.markSchemeRepository = markSchemeRepository; + } + + @Transactional(readOnly = true) + public Optional getForQuestionId(long questionId) { + log.debug("Getting mark scheme for question id: {}", questionId); + + if (questions.exists(questionId)) { + return markSchemeRepository.findByQuestionId(questionId).map(MarkSchemeMapper::toMarkScheme); + } else { + return Optional.empty(); + } + } +} diff --git a/src/main/java/com/practiq/service/markscheme/dto/response/MarkScheme.java b/src/main/java/com/practiq/service/markscheme/dto/response/MarkScheme.java new file mode 100644 index 0000000..eb7752e --- /dev/null +++ b/src/main/java/com/practiq/service/markscheme/dto/response/MarkScheme.java @@ -0,0 +1,5 @@ +package com.practiq.service.markscheme.dto.response; + +import java.time.Instant; + +public record MarkScheme(long id, int version, long questionId, String body, Instant createdAt) {} diff --git a/src/main/java/com/practiq/service/markscheme/mapper/MarkSchemeMapper.java b/src/main/java/com/practiq/service/markscheme/mapper/MarkSchemeMapper.java new file mode 100644 index 0000000..b13c98e --- /dev/null +++ b/src/main/java/com/practiq/service/markscheme/mapper/MarkSchemeMapper.java @@ -0,0 +1,15 @@ +package com.practiq.service.markscheme.mapper; + +import com.practiq.persistence.MarkSchemeEntity; +import com.practiq.service.markscheme.dto.response.MarkScheme; + +public class MarkSchemeMapper { + public static MarkScheme toMarkScheme(MarkSchemeEntity markSchemeEntity) { + return new MarkScheme( + markSchemeEntity.getId(), + markSchemeEntity.getVersion(), + markSchemeEntity.getQuestionId(), + markSchemeEntity.getBody(), + markSchemeEntity.getCreatedAt()); + } +} diff --git a/src/main/java/com/practiq/service/question/QuestionAccessor.java b/src/main/java/com/practiq/service/question/QuestionAccessor.java new file mode 100644 index 0000000..9cae3f2 --- /dev/null +++ b/src/main/java/com/practiq/service/question/QuestionAccessor.java @@ -0,0 +1,47 @@ +package com.practiq.service.question; + +import com.practiq.persistence.query.question.QuestionQuery; +import com.practiq.persistence.query.question.QuestionQueryRunner; +import com.practiq.persistence.query.question.QuestionWithConceptIds; +import com.practiq.service.question.dto.request.QuestionSearchCriteria; +import com.practiq.service.question.policy.QuestionQueryPolicy; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import java.util.List; +import java.util.Optional; + +// The one place a question query gets its policy. Instances are bound to a policy at construction (see +// QuestionAccessorFactory), so a caller holding the student accessor cannot make an admin read — the choice +// is made once, where the accessor is injected, rather than at each call. +public class QuestionAccessor { + + private final QuestionQueryRunner questionQueryRunner; + private final QuestionQueryPolicy policy; + + public QuestionAccessor(QuestionQueryRunner questionQueryRunner, QuestionQueryPolicy policy) { + this.questionQueryRunner = questionQueryRunner; + this.policy = policy; + } + + public boolean exists(long id) { + return questionQueryRunner.exists(policy.forId(id)); + } + + public Optional findById(long id) { + List found = questionQueryRunner.findAll(policy.forId(id)); + + // The runner cannot know a query matches at most one row; this accessor can, because it built the + // query. A policy that stopped filtering by id would otherwise return the first of many silently. + if (found.size() > 1) { + throw new IllegalStateException( + "Expected at most one question for id %s, got %s".formatted(id, found.size())); + } + + return found.stream().findFirst(); + } + + public Page findPage(QuestionSearchCriteria criteria, Pageable pageable) { + QuestionQuery query = policy.catalogue(criteria.types(), criteria.difficulties(), criteria.conceptId()); + return questionQueryRunner.findPage(query, pageable); + } +} diff --git a/src/main/java/com/practiq/service/question/QuestionAccessorFactory.java b/src/main/java/com/practiq/service/question/QuestionAccessorFactory.java new file mode 100644 index 0000000..a85bd7a --- /dev/null +++ b/src/main/java/com/practiq/service/question/QuestionAccessorFactory.java @@ -0,0 +1,23 @@ +package com.practiq.service.question; + +import com.practiq.persistence.query.question.QuestionQueryRunner; +import com.practiq.service.question.policy.StudentQuestionQueryPolicy; +import io.micronaut.context.annotation.Factory; +import jakarta.inject.Named; +import jakarta.inject.Singleton; + +// Produces one accessor per audience, each already carrying its policy. Injection sites name the audience +// they want, so the decision is made once in a field declaration rather than on every call. An admin +// accessor joins this class when the review path lands. +@Factory +public class QuestionAccessorFactory { + + public static final String STUDENT = "student"; + + @Singleton + @Named(STUDENT) + public QuestionAccessor studentQuestionAccessor( + QuestionQueryRunner questionQueryRunner, StudentQuestionQueryPolicy policy) { + return new QuestionAccessor(questionQueryRunner, policy); + } +} diff --git a/src/main/java/com/practiq/service/question/QuestionService.java b/src/main/java/com/practiq/service/question/QuestionService.java new file mode 100644 index 0000000..c85c6b8 --- /dev/null +++ b/src/main/java/com/practiq/service/question/QuestionService.java @@ -0,0 +1,37 @@ +package com.practiq.service.question; + +import static com.practiq.service.question.QuestionAccessorFactory.STUDENT; + +import com.practiq.service.question.dto.request.QuestionSearchCriteria; +import com.practiq.service.question.dto.response.Question; +import com.practiq.service.question.mapper.QuestionMapper; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.transaction.annotation.Transactional; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Singleton +public class QuestionService { + + private final QuestionAccessor questions; + + public QuestionService(@Named(STUDENT) QuestionAccessor questions) { + this.questions = questions; + } + + @Transactional(readOnly = true) + public Optional getById(long id) { + log.debug("Getting question for id {}", id); + return questions.findById(id).map(QuestionMapper::toQuestion); + } + + @Transactional(readOnly = true) + public Page get(QuestionSearchCriteria criteria, Pageable pageable) { + log.debug("Getting approved questions, page {}", pageable.getNumber()); + return questions.findPage(criteria, pageable).map(QuestionMapper::toQuestion); + } +} diff --git a/src/main/java/com/practiq/service/question/dto/request/QuestionSearchCriteria.java b/src/main/java/com/practiq/service/question/dto/request/QuestionSearchCriteria.java new file mode 100644 index 0000000..f88ae1e --- /dev/null +++ b/src/main/java/com/practiq/service/question/dto/request/QuestionSearchCriteria.java @@ -0,0 +1,13 @@ +package com.practiq.service.question.dto.request; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionType; +import java.util.List; + +public record QuestionSearchCriteria(List types, List difficulties, Long conceptId) { + public QuestionSearchCriteria { + if (conceptId != null && conceptId < 1) { + throw new IllegalArgumentException("conceptId must be greater than 0"); + } + } +} diff --git a/src/main/java/com/practiq/service/question/dto/response/Question.java b/src/main/java/com/practiq/service/question/dto/response/Question.java new file mode 100644 index 0000000..206e320 --- /dev/null +++ b/src/main/java/com/practiq/service/question/dto/response/Question.java @@ -0,0 +1,17 @@ +package com.practiq.service.question.dto.response; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import java.time.Instant; +import java.util.Set; + +public record Question( + long id, + int version, + String body, + QuestionDifficulty difficulty, + QuestionType type, + QuestionStatus status, + Instant createdAt, + Set linkedConceptIds) {} diff --git a/src/main/java/com/practiq/service/question/mapper/QuestionMapper.java b/src/main/java/com/practiq/service/question/mapper/QuestionMapper.java new file mode 100644 index 0000000..05e758f --- /dev/null +++ b/src/main/java/com/practiq/service/question/mapper/QuestionMapper.java @@ -0,0 +1,22 @@ +package com.practiq.service.question.mapper; + +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.query.question.QuestionWithConceptIds; +import com.practiq.service.question.dto.response.Question; + +public class QuestionMapper { + + public static Question toQuestion(QuestionWithConceptIds questionWithConceptIds) { + QuestionEntity questionEntity = questionWithConceptIds.question(); + + return new Question( + questionEntity.getId(), + questionEntity.getVersion(), + questionEntity.getBody(), + questionEntity.getDifficulty(), + questionEntity.getType(), + questionEntity.getStatus(), + questionEntity.getCreatedAt(), + questionWithConceptIds.conceptIds()); + } +} diff --git a/src/main/java/com/practiq/service/question/policy/QuestionQueryPolicy.java b/src/main/java/com/practiq/service/question/policy/QuestionQueryPolicy.java new file mode 100644 index 0000000..3462168 --- /dev/null +++ b/src/main/java/com/practiq/service/question/policy/QuestionQueryPolicy.java @@ -0,0 +1,15 @@ +package com.practiq.service.question.policy; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.query.question.QuestionQuery; +import java.util.List; + +// Who may see what. A policy turns a request for questions into a query carrying the restrictions that +// audience is subject to, which is why it lives here and not beside the runner that executes it. +public interface QuestionQueryPolicy { + + QuestionQuery forId(long questionId); + + QuestionQuery catalogue(List types, List difficulties, Long conceptId); +} diff --git a/src/main/java/com/practiq/domain/query/question/StudentQuestionQueryPolicy.java b/src/main/java/com/practiq/service/question/policy/StudentQuestionQueryPolicy.java similarity index 53% rename from src/main/java/com/practiq/domain/query/question/StudentQuestionQueryPolicy.java rename to src/main/java/com/practiq/service/question/policy/StudentQuestionQueryPolicy.java index 688b806..185a36b 100644 --- a/src/main/java/com/practiq/domain/query/question/StudentQuestionQueryPolicy.java +++ b/src/main/java/com/practiq/service/question/policy/StudentQuestionQueryPolicy.java @@ -1,11 +1,16 @@ -package com.practiq.domain.query.question; +package com.practiq.service.question.policy; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.query.question.QuestionQuery; +import jakarta.inject.Singleton; import java.util.List; -class StudentQuestionQueryPolicy implements QuestionQueryPolicy { +// Students see approved questions that are linked to a concept, and nothing else. Request filters narrow +// that further; they can never widen it, because every query starts from this builder. +@Singleton +public class StudentQuestionQueryPolicy implements QuestionQueryPolicy { @Override public QuestionQuery forId(long questionId) { diff --git a/src/main/java/com/practiq/storage/PresignedUploadRequest.java b/src/main/java/com/practiq/storage/PresignedUploadRequest.java index d269426..9a24b10 100644 --- a/src/main/java/com/practiq/storage/PresignedUploadRequest.java +++ b/src/main/java/com/practiq/storage/PresignedUploadRequest.java @@ -1,6 +1,6 @@ package com.practiq.storage; -import com.practiq.util.StringUtil; +import com.practiq.foundation.util.StringUtil; import io.micronaut.http.MediaType; import java.time.Duration; diff --git a/src/main/java/com/practiq/http/AdminKeyValidator.java b/src/main/java/com/practiq/web/AdminKeyValidator.java similarity index 79% rename from src/main/java/com/practiq/http/AdminKeyValidator.java rename to src/main/java/com/practiq/web/AdminKeyValidator.java index 61a1799..83b2670 100644 --- a/src/main/java/com/practiq/http/AdminKeyValidator.java +++ b/src/main/java/com/practiq/web/AdminKeyValidator.java @@ -1,9 +1,9 @@ -package com.practiq.http; +package com.practiq.web; -import static com.practiq.http.HttpConstants.ADMIN_KEY_HEADER; +import static com.practiq.web.HttpConstants.ADMIN_KEY_HEADER; -import com.practiq.exception.UnauthorizedException; -import com.practiq.util.StringUtil; +import com.practiq.foundation.exception.UnauthorizedException; +import com.practiq.foundation.util.StringUtil; import io.micronaut.context.annotation.Value; import jakarta.inject.Singleton; diff --git a/src/main/java/com/practiq/http/HttpConstants.java b/src/main/java/com/practiq/web/HttpConstants.java similarity index 95% rename from src/main/java/com/practiq/http/HttpConstants.java rename to src/main/java/com/practiq/web/HttpConstants.java index 90750fd..d781750 100644 --- a/src/main/java/com/practiq/http/HttpConstants.java +++ b/src/main/java/com/practiq/web/HttpConstants.java @@ -1,4 +1,4 @@ -package com.practiq.http; +package com.practiq.web; // Custom HTTP header names and other wire-level constants owned by the app. Deliberately independent of // the test-side copy (utils.data.TestData.SESSION_TOKEN_HEADER): duplicated on purpose so a change to one diff --git a/src/main/java/com/practiq/web/binder/PageableConfigurationGuard.java b/src/main/java/com/practiq/web/binder/PageableConfigurationGuard.java new file mode 100644 index 0000000..47ccf70 --- /dev/null +++ b/src/main/java/com/practiq/web/binder/PageableConfigurationGuard.java @@ -0,0 +1,39 @@ +package com.practiq.web.binder; + +import io.micronaut.context.annotation.Context; +import io.micronaut.context.exceptions.ConfigurationException; +import io.micronaut.data.runtime.config.DataConfiguration.PageableConfiguration; + +// Replacing Micronaut's Pageable binder means inheriting its whole configuration surface while honouring +// only part of it: sort-ignore-case and sort-delimiter exist solely to parse a sort expression, and +// PageableQueryBinder refuses the sort parameter before any parsing happens. Setting either would do +// nothing whatsoever, so the app refuses to start rather than ignoring them. A startup failure is met in CI +// or a deploy; a silently inert knob is met by whoever spends an afternoon wondering why it had no effect. +// +// Bound values are compared rather than property keys because relaxed binding means the same setting can +// arrive as sort-ignore-case, sortIgnoreCase or MICRONAUT_DATA_PAGEABLE_SORT_IGNORE_CASE, and a key-based +// check has to enumerate the spellings. +@Context +class PageableConfigurationGuard { + + // Micronaut publishes constants for its other pageable defaults but not for this one. + private static final String DEFAULT_SORT_DELIMITER = ","; + + PageableConfigurationGuard(PageableConfiguration configuration) { + if (configuration.isSortIgnoreCase() != PageableConfiguration.DEFAULT_SORT_IGNORE_CASE) { + throw new ConfigurationException(hasNoEffect("sort-ignore-case")); + } + + if (!DEFAULT_SORT_DELIMITER.equals( + configuration.getSortDelimiterPattern().pattern())) { + throw new ConfigurationException(hasNoEffect("sort-delimiter")); + } + } + + private static String hasNoEffect(String property) { + return ("micronaut.data.pageable.%s is set but has no effect: PageableQueryBinder rejects the sort " + + "parameter, so no sort expression is ever parsed. Remove the property, or restore " + + "sorting in the binder.") + .formatted(property); + } +} diff --git a/src/main/java/com/practiq/web/binder/PageableQueryBinder.java b/src/main/java/com/practiq/web/binder/PageableQueryBinder.java new file mode 100644 index 0000000..03213f6 --- /dev/null +++ b/src/main/java/com/practiq/web/binder/PageableQueryBinder.java @@ -0,0 +1,108 @@ +package com.practiq.web.binder; + +import com.practiq.foundation.exception.EntityValidationException; +import io.micronaut.context.annotation.Replaces; +import io.micronaut.core.convert.ArgumentConversionContext; +import io.micronaut.core.convert.exceptions.ConversionErrorException; +import io.micronaut.core.type.Argument; +import io.micronaut.data.model.Pageable; +import io.micronaut.data.runtime.config.DataConfiguration; +import io.micronaut.data.runtime.http.PageableRequestArgumentBinder; +import io.micronaut.http.HttpParameters; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.bind.binders.TypedRequestArgumentBinder; +import jakarta.inject.Singleton; +import java.util.Optional; + +// Micronaut's own Pageable binder is deliberately lenient: it coerces every unusable paging value to a +// default and answers 200, so page=-1 and size=abc are indistinguishable from sending nothing. That is the +// opposite of how the rest of the API treats a value it cannot use, and it leaves a client unable to learn +// what the size ceiling is. This binder refuses instead, naming the parameter and the rule. +@Singleton +@Replaces(PageableRequestArgumentBinder.class) +public class PageableQueryBinder implements TypedRequestArgumentBinder { + + private static final Argument TYPE = Argument.of(Pageable.class); + + private static final int MIN_PAGE = 0; + private static final int MIN_SIZE = 1; + + private final DataConfiguration.PageableConfiguration configuration; + + public PageableQueryBinder(DataConfiguration.PageableConfiguration configuration) { + this.configuration = configuration; + } + + @Override + public Argument argumentType() { + return TYPE; + } + + @Override + public BindingResult bind(ArgumentConversionContext context, HttpRequest source) { + HttpParameters parameters = source.getParameters(); + + rejectSort(parameters); + Pageable pageable = Pageable.from(readPage(parameters), readSize(parameters)); + + return () -> Optional.of(pageable); + } + + // No endpoint offers sorting: the query runners impose their own total order so rows cannot straddle a + // page. To allow it later, put an allow-list annotation on the Pageable parameter and read it from + // context.getArgument().getAnnotationMetadata() — the list wants to be mandatory, since an open sort + // lets a client order by an unindexed column. The runner must then append its id tiebreak to whatever + // the client asked for or paging stops being stable, so the binder alone will not be enough. + private void rejectSort(HttpParameters parameters) { + String sortParameter = configuration.getSortParameterName(); + if (parameters.contains(sortParameter)) { + throw new EntityValidationException(sortParameter, "is not supported"); + } + } + + private int readPage(HttpParameters parameters) { + String pageParameter = configuration.getPageParameterName(); + Integer page = readInt(parameters, pageParameter); + + if (page == null) { + return MIN_PAGE; + } + if (page < MIN_PAGE) { + throw new EntityValidationException(pageParameter, "must be greater than or equal to " + MIN_PAGE); + } + return page; + } + + private int readSize(HttpParameters parameters) { + String sizeParameter = configuration.getSizeParameterName(); + Integer size = readInt(parameters, sizeParameter); + + if (size == null) { + return configuration.getDefaultPageSize(); + } + if (size < MIN_SIZE) { + throw new EntityValidationException(sizeParameter, "must be greater than or equal to " + MIN_SIZE); + } + // Stated back to the caller rather than clamped: a client handed 50 rows after asking for 500 cannot + // tell the ceiling from the end of the data, and the message is the only place the maximum is public. + int maxSize = configuration.getMaxPageSize(); + if (size > maxSize) { + throw new EntityValidationException(sizeParameter, "must not be greater than " + maxSize); + } + return size; + } + + // Absent reads as null; present but unparseable raises the same conversion error a bad filter parameter + // raises, so a non-numeric page is the 400 that conceptId=abc already is. + private Integer readInt(HttpParameters parameters, String name) { + String raw = parameters.getFirst(name, String.class).orElse(null); + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Integer.valueOf(raw); + } catch (NumberFormatException e) { + throw new ConversionErrorException(Argument.of(Integer.class, name), e); + } + } +} diff --git a/src/main/java/com/practiq/controller/AdminDocumentController.java b/src/main/java/com/practiq/web/controller/AdminDocumentController.java similarity index 54% rename from src/main/java/com/practiq/controller/AdminDocumentController.java rename to src/main/java/com/practiq/web/controller/AdminDocumentController.java index cc81dbc..a78eceb 100644 --- a/src/main/java/com/practiq/controller/AdminDocumentController.java +++ b/src/main/java/com/practiq/web/controller/AdminDocumentController.java @@ -1,16 +1,20 @@ -package com.practiq.controller; +package com.practiq.web.controller; -import static com.practiq.http.HttpConstants.ADMIN_KEY_HEADER; +import static com.practiq.web.HttpConstants.ADMIN_KEY_HEADER; +import static com.practiq.web.dto.mapper.UploadDocumentResponseMapper.toUploadDocumentResponse; -import com.practiq.dto.request.PostDocumentRequest; -import com.practiq.dto.response.UploadDocumentResponse; -import com.practiq.http.AdminKeyValidator; -import com.practiq.service.DocumentService; +import com.practiq.service.document.DocumentService; +import com.practiq.service.document.dto.request.DocumentPresignUploadCommand; +import com.practiq.service.document.dto.response.DocumentPresignUpload; +import com.practiq.web.AdminKeyValidator; +import com.practiq.web.dto.request.PostDocumentRequest; +import com.practiq.web.dto.response.UploadDocumentResponse; import io.micronaut.core.annotation.Nullable; import io.micronaut.http.HttpStatus; import io.micronaut.http.annotation.*; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; +import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -31,10 +35,15 @@ public AdminDocumentController(DocumentService documentService, AdminKeyValidato public UploadDocumentResponse postDocumentAndReturnPresignUrl( // Nullable so an absent header reaches the validator rather than failing to bind: a caller must // not be able to tell a missing key from a wrong one. - @Nullable @Header(ADMIN_KEY_HEADER) String adminKey, @Body PostDocumentRequest request) { + @Nullable @Header(ADMIN_KEY_HEADER) String adminKey, @Valid @Body PostDocumentRequest request) { log.debug("Requested to POST document with filename {}", request.filename()); adminKeyValidator.validate(adminKey); - return documentService.stageDocumentUpload(request); + + DocumentPresignUploadCommand uploadCommand = new DocumentPresignUploadCommand( + request.filename(), request.contentType(), request.contentLength(), request.sourceSpec()); + DocumentPresignUpload documentPresignUpload = documentService.stageUploadAndPresign(uploadCommand); + + return toUploadDocumentResponse(documentPresignUpload); } } diff --git a/src/main/java/com/practiq/controller/ConceptController.java b/src/main/java/com/practiq/web/controller/ConceptController.java similarity index 54% rename from src/main/java/com/practiq/controller/ConceptController.java rename to src/main/java/com/practiq/web/controller/ConceptController.java index 51f9576..057c228 100644 --- a/src/main/java/com/practiq/controller/ConceptController.java +++ b/src/main/java/com/practiq/web/controller/ConceptController.java @@ -1,13 +1,16 @@ -package com.practiq.controller; +package com.practiq.web.controller; -import com.practiq.dto.response.ConceptResponse; -import com.practiq.service.ConceptService; +import com.practiq.service.concept.ConceptService; +import com.practiq.web.dto.mapper.ConceptResponseMapper; +import com.practiq.web.dto.response.ConceptResponse; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; import io.micronaut.http.server.exceptions.NotFoundException; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; +import jakarta.validation.constraints.Min; import java.util.List; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -24,12 +27,17 @@ public ConceptController(ConceptService conceptService) { @Get public List get() { log.debug("Requested to GET all concepts"); - return conceptService.get(); + return conceptService.get().stream() + .map(ConceptResponseMapper::toConceptResponse) + .collect(Collectors.toList()); } @Get("/{id}") - public ConceptResponse getById(long id) { + public ConceptResponse getById(@Min(1) long id) { log.debug("Requested to GET concept by id: {}", id); - return conceptService.get(id).orElseThrow(NotFoundException::new); + return conceptService + .getById(id) + .map(ConceptResponseMapper::toConceptResponse) + .orElseThrow(NotFoundException::new); } } diff --git a/src/main/java/com/practiq/controller/MarkSchemeController.java b/src/main/java/com/practiq/web/controller/MarkSchemeController.java similarity index 57% rename from src/main/java/com/practiq/controller/MarkSchemeController.java rename to src/main/java/com/practiq/web/controller/MarkSchemeController.java index 33149e8..9b9a1ce 100644 --- a/src/main/java/com/practiq/controller/MarkSchemeController.java +++ b/src/main/java/com/practiq/web/controller/MarkSchemeController.java @@ -1,12 +1,14 @@ -package com.practiq.controller; +package com.practiq.web.controller; -import com.practiq.dto.response.MarkSchemeResponse; -import com.practiq.service.MarkSchemeService; +import com.practiq.service.markscheme.MarkSchemeService; +import com.practiq.web.dto.mapper.MarkSchemeResponseMapper; +import com.practiq.web.dto.response.MarkSchemeResponse; import io.micronaut.http.annotation.Controller; import io.micronaut.http.annotation.Get; import io.micronaut.http.server.exceptions.NotFoundException; import io.micronaut.scheduling.TaskExecutors; import io.micronaut.scheduling.annotation.ExecuteOn; +import jakarta.validation.constraints.Min; import lombok.extern.slf4j.Slf4j; @Slf4j @@ -21,8 +23,11 @@ public MarkSchemeController(MarkSchemeService markSchemeService) { } @Get("/{questionId}/mark-scheme") - public MarkSchemeResponse getForQuestionId(long questionId) { + public MarkSchemeResponse getForQuestionId(@Min(1) long questionId) { log.debug("Requested to GET mark scheme for question id: {}", questionId); - return markSchemeService.getForQuestionId(questionId).orElseThrow(NotFoundException::new); + return markSchemeService + .getForQuestionId(questionId) + .map(MarkSchemeResponseMapper::toMarkSchemeResponse) + .orElseThrow(NotFoundException::new); } } diff --git a/src/main/java/com/practiq/web/controller/QuestionAttemptController.java b/src/main/java/com/practiq/web/controller/QuestionAttemptController.java new file mode 100644 index 0000000..d7580f8 --- /dev/null +++ b/src/main/java/com/practiq/web/controller/QuestionAttemptController.java @@ -0,0 +1,65 @@ +package com.practiq.web.controller; + +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.service.UserRef; +import com.practiq.service.attempt.QuestionAttemptService; +import com.practiq.service.attempt.dto.request.QuestionAttemptCommand; +import com.practiq.web.HttpConstants; +import com.practiq.web.dto.mapper.QuestionAttemptResponseMapper; +import com.practiq.web.dto.request.QuestionAttemptRequest; +import com.practiq.web.dto.response.QuestionAttemptResponse; +import io.micronaut.http.HttpStatus; +import io.micronaut.http.annotation.*; +import io.micronaut.http.server.exceptions.NotFoundException; +import io.micronaut.scheduling.TaskExecutors; +import io.micronaut.scheduling.annotation.ExecuteOn; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExecuteOn(TaskExecutors.BLOCKING) +@Controller("api/v1/questions") +public class QuestionAttemptController { + + private final QuestionAttemptService questionAttemptService; + + public QuestionAttemptController(QuestionAttemptService questionAttemptService) { + this.questionAttemptService = questionAttemptService; + } + + @Get("/{questionId}/attempts") + public List getForQuestionId( + @NotBlank @Size(max = QuestionAttemptEntity.SESSION_TOKEN_MAX_LENGTH) @Header(HttpConstants.SESSION_TOKEN_HEADER) + String sessionToken, + @Min(1) long questionId) { + log.debug("Requested to GET question attempts for question id: {}", questionId); + + return questionAttemptService + .getForQuestionId(new UserRef(sessionToken), questionId) + .map(QuestionAttemptResponseMapper::toQuestionAttemptResponses) + .orElseThrow(NotFoundException::new); + } + + @Post("/{questionId}/attempts") + @Status(HttpStatus.CREATED) + public QuestionAttemptResponse postForQuestionId( + @NotBlank @Size(max = QuestionAttemptEntity.SESSION_TOKEN_MAX_LENGTH) @Header(HttpConstants.SESSION_TOKEN_HEADER) + String sessionToken, + @Valid @Body QuestionAttemptRequest request, + @Min(1) long questionId) { + log.debug("Requested to POST question attempt for question id: {}", questionId); + log.trace("POST body: {}", request.body()); + + QuestionAttemptCommand command = + new QuestionAttemptCommand(questionId, new UserRef(sessionToken), request.body()); + + return questionAttemptService + .create(command) + .map(QuestionAttemptResponseMapper::toQuestionAttemptResponse) + .orElseThrow(NotFoundException::new); + } +} diff --git a/src/main/java/com/practiq/web/controller/QuestionController.java b/src/main/java/com/practiq/web/controller/QuestionController.java new file mode 100644 index 0000000..0f1a290 --- /dev/null +++ b/src/main/java/com/practiq/web/controller/QuestionController.java @@ -0,0 +1,53 @@ +package com.practiq.web.controller; + +import static com.practiq.web.dto.mapper.QuestionResponseMapper.toQuestionResponses; + +import com.practiq.service.question.QuestionService; +import com.practiq.service.question.dto.request.QuestionSearchCriteria; +import com.practiq.service.question.dto.response.Question; +import com.practiq.web.dto.mapper.QuestionResponseMapper; +import com.practiq.web.dto.request.QuestionRequest; +import com.practiq.web.dto.response.PageResponse; +import com.practiq.web.dto.response.QuestionResponse; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.RequestBean; +import io.micronaut.http.server.exceptions.NotFoundException; +import io.micronaut.scheduling.TaskExecutors; +import io.micronaut.scheduling.annotation.ExecuteOn; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@ExecuteOn(TaskExecutors.BLOCKING) +@Controller("api/v1/questions") +public class QuestionController { + + private final QuestionService questionService; + + public QuestionController(QuestionService questionService) { + this.questionService = questionService; + } + + @Get + public PageResponse get(@Valid @RequestBean QuestionRequest request, Pageable pageable) { + log.debug("Requested to GET approved questions"); + QuestionSearchCriteria searchCriteria = + new QuestionSearchCriteria(request.types(), request.difficulties(), request.conceptId()); + + Page pageOfQuestions = questionService.get(searchCriteria, pageable); + return PageResponse.of(pageOfQuestions, toQuestionResponses(pageOfQuestions.getContent())); + } + + @Get("/{id}") + public QuestionResponse getById(@Min(1) long id) { + log.debug("Requested to GET question by id: {}", id); + return questionService + .getById(id) + .map(QuestionResponseMapper::toQuestionResponse) + .orElseThrow(NotFoundException::new); + } +} diff --git a/src/main/java/com/practiq/dto/converter/QuestionDifficultyTypeConverter.java b/src/main/java/com/practiq/web/dto/converter/QuestionDifficultyTypeConverter.java similarity index 90% rename from src/main/java/com/practiq/dto/converter/QuestionDifficultyTypeConverter.java rename to src/main/java/com/practiq/web/dto/converter/QuestionDifficultyTypeConverter.java index 6c5460b..c5c107f 100644 --- a/src/main/java/com/practiq/dto/converter/QuestionDifficultyTypeConverter.java +++ b/src/main/java/com/practiq/web/dto/converter/QuestionDifficultyTypeConverter.java @@ -1,6 +1,6 @@ -package com.practiq.dto.converter; +package com.practiq.web.dto.converter; -import com.practiq.domain.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionDifficulty; import io.micronaut.core.convert.ConversionContext; import io.micronaut.core.convert.TypeConverter; import jakarta.inject.Singleton; diff --git a/src/main/java/com/practiq/web/dto/mapper/ConceptResponseMapper.java b/src/main/java/com/practiq/web/dto/mapper/ConceptResponseMapper.java new file mode 100644 index 0000000..8c380f7 --- /dev/null +++ b/src/main/java/com/practiq/web/dto/mapper/ConceptResponseMapper.java @@ -0,0 +1,13 @@ +package com.practiq.web.dto.mapper; + +import com.practiq.service.concept.dto.response.Concept; +import com.practiq.web.dto.response.ConceptResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class ConceptResponseMapper { + public static ConceptResponse toConceptResponse(Concept concept) { + log.trace("Converting Concept to ConceptResponse: {}", concept.id()); + return new ConceptResponse(concept.id(), concept.name(), concept.description(), concept.createdAt()); + } +} diff --git a/src/main/java/com/practiq/dto/mapper/ErrorResponseMapper.java b/src/main/java/com/practiq/web/dto/mapper/ErrorResponseMapper.java similarity index 66% rename from src/main/java/com/practiq/dto/mapper/ErrorResponseMapper.java rename to src/main/java/com/practiq/web/dto/mapper/ErrorResponseMapper.java index 3d695e3..eb0ceda 100644 --- a/src/main/java/com/practiq/dto/mapper/ErrorResponseMapper.java +++ b/src/main/java/com/practiq/web/dto/mapper/ErrorResponseMapper.java @@ -1,7 +1,7 @@ -package com.practiq.dto.mapper; +package com.practiq.web.dto.mapper; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.EntityValidationError; +import com.practiq.foundation.exception.EntityValidationError; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpStatus; public class ErrorResponseMapper { diff --git a/src/main/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapper.java b/src/main/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapper.java new file mode 100644 index 0000000..8c8051e --- /dev/null +++ b/src/main/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapper.java @@ -0,0 +1,15 @@ +package com.practiq.web.dto.mapper; + +import com.practiq.service.markscheme.dto.response.MarkScheme; +import com.practiq.web.dto.response.MarkSchemeResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class MarkSchemeResponseMapper { + public static MarkSchemeResponse toMarkSchemeResponse(MarkScheme markScheme) { + log.trace("Converting MarkScheme to MarkSchemeResponse: {}", markScheme.id()); + + return new MarkSchemeResponse( + markScheme.id(), markScheme.questionId(), markScheme.body(), markScheme.createdAt()); + } +} diff --git a/src/main/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapper.java b/src/main/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapper.java new file mode 100644 index 0000000..a7c460a --- /dev/null +++ b/src/main/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapper.java @@ -0,0 +1,28 @@ +package com.practiq.web.dto.mapper; + +import com.practiq.service.attempt.dto.response.QuestionAttempt; +import com.practiq.web.dto.response.QuestionAttemptResponse; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class QuestionAttemptResponseMapper { + + public static List toQuestionAttemptResponses(List questionAttempts) { + return questionAttempts.stream() + .map(QuestionAttemptResponseMapper::toQuestionAttemptResponse) + .toList(); + } + + public static QuestionAttemptResponse toQuestionAttemptResponse(QuestionAttempt questionAttempt) { + log.trace("Converting QuestionAttempt to QuestionAttemptResponse: {}", questionAttempt.id()); + + // sessionToken is deliberately dropped: the caller supplied it, and echoing it back would put a + // credential-shaped value into response bodies, logs and caches for no benefit. + return new QuestionAttemptResponse( + questionAttempt.id(), + questionAttempt.questionId(), + questionAttempt.body(), + questionAttempt.createdAt()); + } +} diff --git a/src/main/java/com/practiq/web/dto/mapper/QuestionResponseMapper.java b/src/main/java/com/practiq/web/dto/mapper/QuestionResponseMapper.java new file mode 100644 index 0000000..edf9a33 --- /dev/null +++ b/src/main/java/com/practiq/web/dto/mapper/QuestionResponseMapper.java @@ -0,0 +1,31 @@ +package com.practiq.web.dto.mapper; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.service.question.dto.response.Question; +import com.practiq.web.dto.response.QuestionDifficultyResponse; +import com.practiq.web.dto.response.QuestionResponse; +import java.util.List; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class QuestionResponseMapper { + + public static List toQuestionResponses(List questions) { + return questions.stream() + .map(QuestionResponseMapper::toQuestionResponse) + .toList(); + } + + public static QuestionResponse toQuestionResponse(Question question) { + log.trace("Converting question to QuestionResponse: {}", question.id()); + + QuestionDifficulty difficulty = question.difficulty(); + return new QuestionResponse( + question.id(), + question.body(), + difficulty == null ? null : new QuestionDifficultyResponse(difficulty), + question.type(), + question.createdAt(), + question.linkedConceptIds()); + } +} diff --git a/src/main/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapper.java b/src/main/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapper.java new file mode 100644 index 0000000..cc32b4b --- /dev/null +++ b/src/main/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapper.java @@ -0,0 +1,15 @@ +package com.practiq.web.dto.mapper; + +import com.practiq.service.document.dto.response.DocumentPresignUpload; +import com.practiq.web.dto.response.UploadDocumentResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class UploadDocumentResponseMapper { + public static UploadDocumentResponse toUploadDocumentResponse(DocumentPresignUpload documentPresignUpload) { + log.trace("Converting DocumentPresignUpload to UploadDocumentResponse: {}", documentPresignUpload.id()); + + return new UploadDocumentResponse( + documentPresignUpload.id(), documentPresignUpload.url(), documentPresignUpload.expiresAt()); + } +} diff --git a/src/main/java/com/practiq/dto/request/PostDocumentRequest.java b/src/main/java/com/practiq/web/dto/request/PostDocumentRequest.java similarity index 61% rename from src/main/java/com/practiq/dto/request/PostDocumentRequest.java rename to src/main/java/com/practiq/web/dto/request/PostDocumentRequest.java index d439722..16a1c06 100644 --- a/src/main/java/com/practiq/dto/request/PostDocumentRequest.java +++ b/src/main/java/com/practiq/web/dto/request/PostDocumentRequest.java @@ -1,5 +1,6 @@ -package com.practiq.dto.request; +package com.practiq.web.dto.request; +import com.practiq.persistence.DocumentEntity; import io.micronaut.core.annotation.Nullable; import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.constraints.Min; @@ -9,7 +10,7 @@ @Serdeable public record PostDocumentRequest( - @NotBlank String filename, + @NotBlank @Size(max = DocumentEntity.FILENAME_MAX_LENGTH) String filename, @NotNull @Min(1) Integer contentLength, @NotBlank String contentType, - @Nullable @Size(max = 255) String sourceSpec) {} + @Nullable @Size(max = DocumentEntity.SOURCE_SPEC_MAX_LENGTH) String sourceSpec) {} diff --git a/src/main/java/com/practiq/dto/request/QuestionAttemptRequest.java b/src/main/java/com/practiq/web/dto/request/QuestionAttemptRequest.java similarity index 86% rename from src/main/java/com/practiq/dto/request/QuestionAttemptRequest.java rename to src/main/java/com/practiq/web/dto/request/QuestionAttemptRequest.java index 31400ad..ec0daa7 100644 --- a/src/main/java/com/practiq/dto/request/QuestionAttemptRequest.java +++ b/src/main/java/com/practiq/web/dto/request/QuestionAttemptRequest.java @@ -1,4 +1,4 @@ -package com.practiq.dto.request; +package com.practiq.web.dto.request; import io.micronaut.serde.annotation.Serdeable; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/practiq/dto/request/QuestionRequest.java b/src/main/java/com/practiq/web/dto/request/QuestionRequest.java similarity index 60% rename from src/main/java/com/practiq/dto/request/QuestionRequest.java rename to src/main/java/com/practiq/web/dto/request/QuestionRequest.java index 5a98486..286c47c 100644 --- a/src/main/java/com/practiq/dto/request/QuestionRequest.java +++ b/src/main/java/com/practiq/web/dto/request/QuestionRequest.java @@ -1,14 +1,15 @@ -package com.practiq.dto.request; +package com.practiq.web.dto.request; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionType; import io.micronaut.core.annotation.Introspected; import io.micronaut.core.annotation.Nullable; import io.micronaut.validation.annotation.UniqueElements; +import jakarta.validation.constraints.Min; import java.util.List; @Introspected public record QuestionRequest( @Nullable @UniqueElements List types, @Nullable @UniqueElements List difficulties, - @Nullable Long conceptId) {} + @Nullable @Min(1) Long conceptId) {} diff --git a/src/main/java/com/practiq/web/dto/response/ConceptResponse.java b/src/main/java/com/practiq/web/dto/response/ConceptResponse.java new file mode 100644 index 0000000..7b1df61 --- /dev/null +++ b/src/main/java/com/practiq/web/dto/response/ConceptResponse.java @@ -0,0 +1,7 @@ +package com.practiq.web.dto.response; + +import io.micronaut.serde.annotation.Serdeable; +import java.time.Instant; + +@Serdeable +public record ConceptResponse(long id, String name, String description, Instant createdAt) {} diff --git a/src/main/java/com/practiq/dto/response/ErrorResponse.java b/src/main/java/com/practiq/web/dto/response/ErrorResponse.java similarity index 75% rename from src/main/java/com/practiq/dto/response/ErrorResponse.java rename to src/main/java/com/practiq/web/dto/response/ErrorResponse.java index a63fd75..7fdb85c 100644 --- a/src/main/java/com/practiq/dto/response/ErrorResponse.java +++ b/src/main/java/com/practiq/web/dto/response/ErrorResponse.java @@ -1,4 +1,4 @@ -package com.practiq.dto.response; +package com.practiq.web.dto.response; import io.micronaut.serde.annotation.Serdeable; diff --git a/src/main/java/com/practiq/web/dto/response/MarkSchemeResponse.java b/src/main/java/com/practiq/web/dto/response/MarkSchemeResponse.java new file mode 100644 index 0000000..ce04800 --- /dev/null +++ b/src/main/java/com/practiq/web/dto/response/MarkSchemeResponse.java @@ -0,0 +1,7 @@ +package com.practiq.web.dto.response; + +import io.micronaut.serde.annotation.Serdeable; +import java.time.Instant; + +@Serdeable +public record MarkSchemeResponse(long id, long questionId, String body, Instant createdAt) {} diff --git a/src/main/java/com/practiq/dto/response/PageResponse.java b/src/main/java/com/practiq/web/dto/response/PageResponse.java similarity index 96% rename from src/main/java/com/practiq/dto/response/PageResponse.java rename to src/main/java/com/practiq/web/dto/response/PageResponse.java index d362f9f..0f40aae 100644 --- a/src/main/java/com/practiq/dto/response/PageResponse.java +++ b/src/main/java/com/practiq/web/dto/response/PageResponse.java @@ -1,4 +1,4 @@ -package com.practiq.dto.response; +package com.practiq.web.dto.response; import io.micronaut.core.annotation.NonNull; import io.micronaut.data.model.Page; diff --git a/src/main/java/com/practiq/web/dto/response/QuestionAttemptResponse.java b/src/main/java/com/practiq/web/dto/response/QuestionAttemptResponse.java new file mode 100644 index 0000000..4f5415a --- /dev/null +++ b/src/main/java/com/practiq/web/dto/response/QuestionAttemptResponse.java @@ -0,0 +1,7 @@ +package com.practiq.web.dto.response; + +import io.micronaut.serde.annotation.Serdeable; +import java.time.Instant; + +@Serdeable +public record QuestionAttemptResponse(long id, long questionId, String body, Instant createdAt) {} diff --git a/src/main/java/com/practiq/dto/response/QuestionDifficultyResponse.java b/src/main/java/com/practiq/web/dto/response/QuestionDifficultyResponse.java similarity index 80% rename from src/main/java/com/practiq/dto/response/QuestionDifficultyResponse.java rename to src/main/java/com/practiq/web/dto/response/QuestionDifficultyResponse.java index 59e24a5..d03ffef 100644 --- a/src/main/java/com/practiq/dto/response/QuestionDifficultyResponse.java +++ b/src/main/java/com/practiq/web/dto/response/QuestionDifficultyResponse.java @@ -1,6 +1,6 @@ -package com.practiq.dto.response; +package com.practiq.web.dto.response; -import com.practiq.domain.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionDifficulty; import io.micronaut.serde.annotation.Serdeable; import lombok.Getter; import lombok.ToString; diff --git a/src/main/java/com/practiq/web/dto/response/QuestionResponse.java b/src/main/java/com/practiq/web/dto/response/QuestionResponse.java new file mode 100644 index 0000000..4bf0189 --- /dev/null +++ b/src/main/java/com/practiq/web/dto/response/QuestionResponse.java @@ -0,0 +1,15 @@ +package com.practiq.web.dto.response; + +import com.practiq.foundation.types.QuestionType; +import io.micronaut.serde.annotation.Serdeable; +import java.time.Instant; +import java.util.Set; + +@Serdeable +public record QuestionResponse( + long id, + String body, + QuestionDifficultyResponse difficulty, + QuestionType type, + Instant createdAt, + Set linkedConceptIds) {} diff --git a/src/main/java/com/practiq/dto/response/UploadDocumentResponse.java b/src/main/java/com/practiq/web/dto/response/UploadDocumentResponse.java similarity index 81% rename from src/main/java/com/practiq/dto/response/UploadDocumentResponse.java rename to src/main/java/com/practiq/web/dto/response/UploadDocumentResponse.java index 072df62..6a6fd21 100644 --- a/src/main/java/com/practiq/dto/response/UploadDocumentResponse.java +++ b/src/main/java/com/practiq/web/dto/response/UploadDocumentResponse.java @@ -1,4 +1,4 @@ -package com.practiq.dto.response; +package com.practiq.web.dto.response; import io.micronaut.serde.annotation.Serdeable; import java.time.Instant; diff --git a/src/main/java/com/practiq/exception/handler/ConstraintViolationExceptionHandler.java b/src/main/java/com/practiq/web/handler/ConstraintViolationExceptionHandler.java similarity index 95% rename from src/main/java/com/practiq/exception/handler/ConstraintViolationExceptionHandler.java rename to src/main/java/com/practiq/web/handler/ConstraintViolationExceptionHandler.java index c414835..951773b 100644 --- a/src/main/java/com/practiq/exception/handler/ConstraintViolationExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/ConstraintViolationExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.UNPROCESSABLE_ENTITY; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; diff --git a/src/main/java/com/practiq/exception/handler/ContentLengthExceededHandler.java b/src/main/java/com/practiq/web/handler/ContentLengthExceededHandler.java similarity index 94% rename from src/main/java/com/practiq/exception/handler/ContentLengthExceededHandler.java rename to src/main/java/com/practiq/web/handler/ContentLengthExceededHandler.java index 193c4b1..9f159d3 100644 --- a/src/main/java/com/practiq/exception/handler/ContentLengthExceededHandler.java +++ b/src/main/java/com/practiq/web/handler/ContentLengthExceededHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.REQUEST_ENTITY_TOO_LARGE; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; diff --git a/src/main/java/com/practiq/exception/handler/ContentTooLargeExceptionHandler.java b/src/main/java/com/practiq/web/handler/ContentTooLargeExceptionHandler.java similarity index 82% rename from src/main/java/com/practiq/exception/handler/ContentTooLargeExceptionHandler.java rename to src/main/java/com/practiq/web/handler/ContentTooLargeExceptionHandler.java index 334f15e..e7e8b0a 100644 --- a/src/main/java/com/practiq/exception/handler/ContentTooLargeExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/ContentTooLargeExceptionHandler.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; -import static com.practiq.dto.mapper.ErrorResponseMapper.toErrorResponse; +import static com.practiq.web.dto.mapper.ErrorResponseMapper.toErrorResponse; import static io.micronaut.http.HttpStatus.REQUEST_ENTITY_TOO_LARGE; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.ContentTooLargeException; +import com.practiq.foundation.exception.ContentTooLargeException; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/main/java/com/practiq/exception/handler/ConversionErrorExceptionHandler.java b/src/main/java/com/practiq/web/handler/ConversionErrorExceptionHandler.java similarity index 96% rename from src/main/java/com/practiq/exception/handler/ConversionErrorExceptionHandler.java rename to src/main/java/com/practiq/web/handler/ConversionErrorExceptionHandler.java index 38ba214..194f0f4 100644 --- a/src/main/java/com/practiq/exception/handler/ConversionErrorExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/ConversionErrorExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.core.convert.exceptions.ConversionErrorException; diff --git a/src/main/java/com/practiq/exception/handler/EntityValidationExceptionHandler.java b/src/main/java/com/practiq/web/handler/EntityValidationExceptionHandler.java similarity index 82% rename from src/main/java/com/practiq/exception/handler/EntityValidationExceptionHandler.java rename to src/main/java/com/practiq/web/handler/EntityValidationExceptionHandler.java index 3f4d913..e0e5048 100644 --- a/src/main/java/com/practiq/exception/handler/EntityValidationExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/EntityValidationExceptionHandler.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; -import static com.practiq.dto.mapper.ErrorResponseMapper.toErrorResponse; +import static com.practiq.web.dto.mapper.ErrorResponseMapper.toErrorResponse; import static io.micronaut.http.HttpStatus.UNPROCESSABLE_ENTITY; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.EntityValidationException; +import com.practiq.foundation.exception.EntityValidationException; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/main/java/com/practiq/exception/handler/GenericExceptionHandler.java b/src/main/java/com/practiq/web/handler/GenericExceptionHandler.java similarity index 91% rename from src/main/java/com/practiq/exception/handler/GenericExceptionHandler.java rename to src/main/java/com/practiq/web/handler/GenericExceptionHandler.java index ae97d3d..b870a5c 100644 --- a/src/main/java/com/practiq/exception/handler/GenericExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/GenericExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.INTERNAL_SERVER_ERROR; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/main/java/com/practiq/exception/handler/NotFoundExceptionHandler.java b/src/main/java/com/practiq/web/handler/NotFoundExceptionHandler.java similarity index 92% rename from src/main/java/com/practiq/exception/handler/NotFoundExceptionHandler.java rename to src/main/java/com/practiq/web/handler/NotFoundExceptionHandler.java index 11b5aab..b551911 100644 --- a/src/main/java/com/practiq/exception/handler/NotFoundExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/NotFoundExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.NOT_FOUND; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/main/java/com/practiq/exception/handler/UnauthorizedExceptionHandler.java b/src/main/java/com/practiq/web/handler/UnauthorizedExceptionHandler.java similarity index 88% rename from src/main/java/com/practiq/exception/handler/UnauthorizedExceptionHandler.java rename to src/main/java/com/practiq/web/handler/UnauthorizedExceptionHandler.java index 495b390..8538956 100644 --- a/src/main/java/com/practiq/exception/handler/UnauthorizedExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/UnauthorizedExceptionHandler.java @@ -1,9 +1,9 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.UNAUTHORIZED; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.UnauthorizedException; +import com.practiq.foundation.exception.UnauthorizedException; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/main/java/com/practiq/exception/handler/UnsatisfiedArgumentExceptionHandler.java b/src/main/java/com/practiq/web/handler/UnsatisfiedArgumentExceptionHandler.java similarity index 93% rename from src/main/java/com/practiq/exception/handler/UnsatisfiedArgumentExceptionHandler.java rename to src/main/java/com/practiq/web/handler/UnsatisfiedArgumentExceptionHandler.java index 757f599..7ee6471 100644 --- a/src/main/java/com/practiq/exception/handler/UnsatisfiedArgumentExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/UnsatisfiedArgumentExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.core.bind.exceptions.UnsatisfiedArgumentException; diff --git a/src/main/java/com/practiq/exception/handler/UnsatisfiedBodyRouteExceptionHandler.java b/src/main/java/com/practiq/web/handler/UnsatisfiedBodyRouteExceptionHandler.java similarity index 92% rename from src/main/java/com/practiq/exception/handler/UnsatisfiedBodyRouteExceptionHandler.java rename to src/main/java/com/practiq/web/handler/UnsatisfiedBodyRouteExceptionHandler.java index 7873fa3..16154d7 100644 --- a/src/main/java/com/practiq/exception/handler/UnsatisfiedBodyRouteExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/UnsatisfiedBodyRouteExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/main/java/com/practiq/exception/handler/UnsatisfiedRouteExceptionHandler.java b/src/main/java/com/practiq/web/handler/UnsatisfiedRouteExceptionHandler.java similarity index 93% rename from src/main/java/com/practiq/exception/handler/UnsatisfiedRouteExceptionHandler.java rename to src/main/java/com/practiq/web/handler/UnsatisfiedRouteExceptionHandler.java index 9070be7..45d3c5c 100644 --- a/src/main/java/com/practiq/exception/handler/UnsatisfiedRouteExceptionHandler.java +++ b/src/main/java/com/practiq/web/handler/UnsatisfiedRouteExceptionHandler.java @@ -1,8 +1,8 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.context.annotation.Replaces; import io.micronaut.context.annotation.Requires; import io.micronaut.http.HttpRequest; diff --git a/src/main/resources/db/migration/V10__entity_field_restrictions.sql b/src/main/resources/db/migration/V10__entity_field_restrictions.sql new file mode 100644 index 0000000..e60ef33 --- /dev/null +++ b/src/main/resources/db/migration/V10__entity_field_restrictions.sql @@ -0,0 +1,17 @@ +ALTER TABLE concept + ALTER COLUMN description TYPE VARCHAR(500); + +ALTER TABLE mark_scheme + ALTER COLUMN body TYPE VARCHAR(10000); + +ALTER TABLE question + ALTER COLUMN body TYPE VARCHAR(10000); + +ALTER TABLE question_attempt + ALTER COLUMN body TYPE VARCHAR(25000); + +-- A client-generated UUID is 36 characters; 64 leaves room for a different token format without a +-- migration. Bounded because the value is client-supplied and indexed: an oversized token would otherwise +-- fail at insert on Postgres's btree entry limit rather than at validation. +ALTER TABLE question_attempt + ALTER COLUMN session_token TYPE VARCHAR(64); diff --git a/src/main/resources/db/migration/V9__document_field_lengths.sql b/src/main/resources/db/migration/V9__document_field_lengths.sql new file mode 100644 index 0000000..acd8f94 --- /dev/null +++ b/src/main/resources/db/migration/V9__document_field_lengths.sql @@ -0,0 +1,4 @@ +ALTER TABLE document + ALTER COLUMN filename TYPE VARCHAR(255); +ALTER TABLE document + ALTER COLUMN s3_key TYPE VARCHAR(255); diff --git a/src/test/java/com/practiq/controller/PagingCT.java b/src/test/java/com/practiq/controller/PagingCT.java deleted file mode 100644 index 8e8a353..0000000 --- a/src/test/java/com/practiq/controller/PagingCT.java +++ /dev/null @@ -1,116 +0,0 @@ -package com.practiq.controller; - -import static io.micronaut.http.HttpStatus.OK; -import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.empty; -import static org.hamcrest.Matchers.equalTo; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -import com.practiq.repository.QuestionRepository; -import io.micronaut.data.model.Page; -import io.micronaut.data.model.Pageable; -import io.micronaut.data.repository.jpa.criteria.QuerySpecification; -import io.micronaut.runtime.server.EmbeddedServer; -import io.micronaut.test.annotation.MockBean; -import io.restassured.RestAssured; -import jakarta.inject.Inject; -import java.util.Collections; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.mockito.Mockito; -import utils.ComponentTest; - -/** - * A little bit hacky, but necessary. - * Default paging behavior is controlled by Micronaut and can be specified by application properties. - * We don't need to test Micronaut, but we do want to pin the default behavior for the app so that it - * doesn't get changed without due consideration. We don't want to have to do this for every end point - * as that will lead to bloat, however there isn't a nice way of doing this super generically. Using - * Questions endpoint as our pin mechanism. Can always be updated to another endpoint later if needs be. - */ -@ComponentTest -public class PagingCT { - - private static final String QUESTIONS_PATH = "/api/v1/questions"; - - @Inject - private QuestionRepository questionRepository; - - @Inject - private EmbeddedServer embeddedServer; - - @MockBean(QuestionRepository.class) - QuestionRepository questionRepository() { - return mock(QuestionRepository.class); - } - - @BeforeEach - void setUp() { - RestAssured.port = embeddedServer.getPort(); - } - - @Test - void getQuestionsCapsRequestedSizeAtTheConfiguredMaximum() { - stubRepositoryToEchoThePageable(); - - // micronaut.data.pageable.max-page-size=50: an oversized request is capped, not rejected. The - // envelope's size field is the requested-size echo, so the cap is observable on the wire. - given().when() - .get(QUESTIONS_PATH + "?size=500") - .then() - .statusCode(OK.getCode()) - .body("size", equalTo(50)); - } - - @Test - void getQuestionsClampsNegativePageToZero() { - stubRepositoryToEchoThePageable(); - - given().when() - .get(QUESTIONS_PATH + "?page=-1") - .then() - .statusCode(OK.getCode()) - .body("page", equalTo(0)); - } - - @Test - void getQuestionsFallsBackToDefaultsForUnusablePagingValues() { - stubRepositoryToEchoThePageable(); - - // Micronaut's Pageable binding is deliberately lenient: non-positive or non-numeric page/size - // fall back to the defaults (page 0, size 10) rather than producing a 400. Pinned so a framework - // upgrade changing this surfaces here — note the contrast with filter params (conceptId=abc is a - // 400), which is a known inconsistency of the binder, not of our handlers. - for (String query : new String[] {"?size=0", "?size=-5", "?size=abc", "?page=abc"}) { - given().when() - .get(QUESTIONS_PATH + query) - .then() - .statusCode(OK.getCode()) - .body("page", equalTo(0)) - .body("size", equalTo(10)); - } - } - - @Test - void getQuestionsReturnsAnEmptyPageBeyondTheData() { - stubRepositoryToEchoThePageable(); - - // Paging past the end is not an error: the envelope echoes the requested position with no rows, - // which is what lets a client walk pages without a priori knowledge of the total. - given().when() - .get(QUESTIONS_PATH + "?page=999") - .then() - .statusCode(OK.getCode()) - .body("content", empty()) - .body("page", equalTo(999)) - .body("totalCount", equalTo(0)); - } - - // Echoes whatever Pageable the web layer bound back through the empty repository Page, so the - // envelope reveals exactly how the layer interpreted the query params. - private void stubRepositoryToEchoThePageable() { - when(questionRepository.findAll(Mockito.any(QuerySpecification.class), Mockito.any(Pageable.class))) - .thenAnswer(invocation -> Page.of(Collections.emptyList(), invocation.getArgument(1), 0L)); - } -} diff --git a/src/test/java/com/practiq/domain/query/question/StudentQuestionQueryRunnerTest.java b/src/test/java/com/practiq/domain/query/question/StudentQuestionQueryRunnerTest.java deleted file mode 100644 index b5b9297..0000000 --- a/src/test/java/com/practiq/domain/query/question/StudentQuestionQueryRunnerTest.java +++ /dev/null @@ -1,150 +0,0 @@ -package com.practiq.domain.query.question; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.is; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.*; -import static utils.TestReflection.setField; - -import com.practiq.domain.Question; -import com.practiq.domain.projection.LinkedQuestion; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; -import com.practiq.repository.QuestionConceptRepository; -import com.practiq.repository.QuestionRepository; -import io.micronaut.data.model.Page; -import io.micronaut.data.model.Pageable; -import io.micronaut.data.model.Sort; -import io.micronaut.data.repository.jpa.criteria.QuerySpecification; -import java.util.List; -import java.util.Optional; -import java.util.Set; -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; - -// The runner is the unit entry point. Its policy (StudentQuestionQueryPolicy) and the QuestionSpecificationFactory -// are real collaborators exercised through it — only the repositories, the DB boundary, are mocked. What the -// student query actually FILTERS (approved + concept-linked) is a property of the executed SQL and is proven in -// integration.repository.QuestionRepositoryIT; that the STUDENT policy imposes those fields is proven directly in -// StudentQuestionQueryPolicyTest. Here we prove the runner's own logic: the two-query concept stitch, the stable -// page order it imposes, and its delegation to the right repository call. -@ExtendWith(MockitoExtension.class) -class StudentQuestionQueryRunnerTest { - - private static final Pageable ORDERED_FIRST_PAGE = - Pageable.from(0, 20, Sort.of(Sort.Order.asc("createdAt"), Sort.Order.asc("id"))); - - @Mock - private QuestionRepository questionRepository; - - @Mock - private QuestionConceptRepository questionConceptRepository; - - private StudentQuestionQueryRunner runner; - - @BeforeEach - void setUp() { - // Real policy (built inside the runner) and real spec factory: the query the runner constructs is genuine, - // only its execution against the DB is mocked. - runner = new StudentQuestionQueryRunner( - questionRepository, questionConceptRepository, new QuestionSpecificationFactory()); - } - - @Test - void findByIdStitchesTheQuestionsConceptLinksOntoIt() { - long id = 1L; - Question question = approvedQuestion(id); - QuestionConceptLink linkA = new QuestionConceptLink(id, 10L); - QuestionConceptLink linkB = new QuestionConceptLink(id, 11L); - - when(questionRepository.findOne(anySpec())).thenReturn(Optional.of(question)); - when(questionConceptRepository.findLinksByQuestionIds(Set.of(id))).thenReturn(List.of(linkA, linkB)); - - Optional result = runner.findQuestionById(id); - - assertThat(result.isPresent(), is(true)); - assertThat(result.get().question(), is(question)); - assertThat(result.get().conceptLinks(), equalTo(Set.of(linkA, linkB))); - } - - @Test - void findByIdReturnsEmptyAndFiresNoLinkQueryWhenNothingMatches() { - when(questionRepository.findOne(anySpec())).thenReturn(Optional.empty()); - - assertThat(runner.findQuestionById(1L).isPresent(), is(false)); - - verifyNoInteractions(questionConceptRepository); - } - - @Test - void pagedQueryStitchesEachQuestionsLinksAndDefaultsToEmptyWhenItHasNone() { - long linkedId = 1L; - long bareId = 2L; - Question linked = approvedQuestion(linkedId); - Question bare = approvedQuestion(bareId); - - when(questionRepository.findAll(anySpec(), any(Pageable.class))) - .thenReturn(Page.of(List.of(linked, bare), ORDERED_FIRST_PAGE, 2L)); - QuestionConceptLink link = new QuestionConceptLink(linkedId, 10L); - when(questionConceptRepository.findLinksByQuestionIds(Set.of(linkedId, bareId))) - .thenReturn(List.of(link)); - - List content = runner.findQuestionsPagedAndFiltered(null, null, null, Pageable.from(0, 20)) - .getContent(); - - // The linked question carries its link; the bare one defaults to an empty set rather than being dropped - // or carrying null. - assertThat(linksOf(content, linkedId), equalTo(Set.of(link))); - assertThat(linksOf(content, bareId), equalTo(Set.of())); - } - - @Test - void pagedQueryImposesTheStableCreatedAtThenIdOrder() { - when(questionRepository.findAll(anySpec(), any(Pageable.class))) - .thenReturn(Page.of(List.of(), ORDERED_FIRST_PAGE, 0L)); - - runner.findQuestionsPagedAndFiltered(null, null, null, Pageable.from(0, 20)); - - // The caller's plain page is replaced by one carrying the (created_at, id) order. - verify(questionRepository).findAll(anySpec(), eq(ORDERED_FIRST_PAGE)); - } - - @Test - void pagedQueryFiresNoLinkQueryForAnEmptyPage() { - when(questionRepository.findAll(anySpec(), any(Pageable.class))) - .thenReturn(Page.of(List.of(), ORDERED_FIRST_PAGE, 0L)); - - runner.findQuestionsPagedAndFiltered(null, null, null, Pageable.from(0, 20)); - - verifyNoInteractions(questionConceptRepository); - } - - // Typed matcher so overload resolution picks findAll(QuerySpecification, Pageable)/findOne(QuerySpecification) - // rather than the Sort-taking siblings (Pageable extends Sort, so an untyped any() is ambiguous). The runner - // builds the spec from its real factory, so the test can't hold the instance — it only matches on type. - private static QuerySpecification anySpec() { - return any(); - } - - private static Question approvedQuestion(long id) { - Question question = new Question( - "Body " + id, QuestionDifficulty.MEDIUM, QuestionType.SHORT_ANSWER, QuestionStatus.APPROVED); - setField(question, "id", id); - return question; - } - - private static Set linksOf(List content, long questionId) { - return content.stream() - .filter(linkedQuestion -> linkedQuestion.question().getId() == questionId) - .findFirst() - .orElseThrow(() -> new AssertionError("No question with id " + questionId)) - .conceptLinks(); - } -} diff --git a/src/test/java/com/practiq/dto/mapper/ConceptResponseMapperTest.java b/src/test/java/com/practiq/dto/mapper/ConceptResponseMapperTest.java deleted file mode 100644 index def2303..0000000 --- a/src/test/java/com/practiq/dto/mapper/ConceptResponseMapperTest.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.practiq.dto.mapper; - -import static com.practiq.dto.mapper.ConceptResponseMapper.toConceptResponse; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static utils.TestReflection.setField; - -import com.practiq.domain.Concept; -import com.practiq.dto.response.ConceptResponse; -import java.time.Instant; -import org.junit.jupiter.api.Test; - -class ConceptResponseMapperTest { - - @Test - void conceptMapsToConceptResponse() { - long conceptId = 1L; - String conceptName = "Diffraction"; - String conceptDescription = "The spreading of waves through a gap or around an obstacle."; - Instant conceptCreatedAt = Instant.parse("2026-01-01T00:00:00Z"); - Concept concept = new Concept(conceptName, conceptDescription); - setField(concept, "id", conceptId); - setField(concept, "createdAt", conceptCreatedAt); - - ConceptResponse conceptResponse = toConceptResponse(concept); - - assertThat(conceptResponse.getId(), equalTo(conceptId)); - assertThat(conceptResponse.getName(), equalTo(conceptName)); - assertThat(conceptResponse.getDescription(), equalTo(conceptDescription)); - assertThat(conceptResponse.getCreatedAt(), equalTo(conceptCreatedAt)); - } -} diff --git a/src/test/java/com/practiq/dto/mapper/MarkSchemeResponseMapperTest.java b/src/test/java/com/practiq/dto/mapper/MarkSchemeResponseMapperTest.java deleted file mode 100644 index a289c24..0000000 --- a/src/test/java/com/practiq/dto/mapper/MarkSchemeResponseMapperTest.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.practiq.dto.mapper; - -import static com.practiq.dto.mapper.MarkSchemeResponseMapper.toMarkSchemeResponse; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static utils.TestReflection.setField; - -import com.practiq.domain.MarkScheme; -import com.practiq.dto.response.MarkSchemeResponse; -import java.time.Instant; -import org.junit.jupiter.api.Test; - -class MarkSchemeResponseMapperTest { - - @Test - void markSchemeMapsToMarkSchemeResponse() { - long id = 1L; - long questionId = 10L; - String body = "Diffraction"; - Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - - MarkScheme markScheme = new MarkScheme(questionId, body); - setField(markScheme, "id", id); - setField(markScheme, "createdAt", createdAt); - - MarkSchemeResponse markSchemeResponse = toMarkSchemeResponse(markScheme); - - assertThat(markSchemeResponse.getId(), equalTo(id)); - assertThat(markSchemeResponse.getQuestionId(), equalTo(questionId)); - assertThat(markSchemeResponse.getBody(), equalTo(body)); - assertThat(markSchemeResponse.getCreatedAt(), equalTo(createdAt)); - } -} diff --git a/src/test/java/com/practiq/dto/mapper/QuestionAttemptResponseMapperTest.java b/src/test/java/com/practiq/dto/mapper/QuestionAttemptResponseMapperTest.java deleted file mode 100644 index 005d6a2..0000000 --- a/src/test/java/com/practiq/dto/mapper/QuestionAttemptResponseMapperTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.practiq.dto.mapper; - -import static com.practiq.dto.mapper.QuestionAttemptResponseMapper.toQuestionAttemptResponse; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static utils.TestReflection.setField; - -import com.practiq.domain.QuestionAttempt; -import com.practiq.dto.response.QuestionAttemptResponse; -import java.time.Instant; -import org.junit.jupiter.api.Test; - -class QuestionAttemptResponseMapperTest { - - @Test - void toQuestionAttemptResponseMapsExpectedFields() { - long questionId = 5L; - String sessionToken = "session-token"; - - long attemptId = 10L; - String attemptBody = "attempt"; - Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - QuestionAttempt attempt = new QuestionAttempt(questionId, sessionToken, attemptBody); - setField(attempt, "id", attemptId); - setField(attempt, "createdAt", createdAt); - - QuestionAttemptResponse attemptResponse = toQuestionAttemptResponse(attempt); - - assertThat(attemptResponse.getId(), equalTo(attemptId)); - assertThat(attemptResponse.getQuestionId(), equalTo(questionId)); - assertThat(attemptResponse.getBody(), equalTo(attemptBody)); - assertThat(attemptResponse.getCreatedAt(), equalTo(createdAt)); - } -} diff --git a/src/test/java/com/practiq/dto/mapper/QuestionResponseMapperTest.java b/src/test/java/com/practiq/dto/mapper/QuestionResponseMapperTest.java deleted file mode 100644 index 866d0dc..0000000 --- a/src/test/java/com/practiq/dto/mapper/QuestionResponseMapperTest.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.practiq.dto.mapper; - -import static com.practiq.dto.mapper.QuestionResponseMapper.toQuestionResponse; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.nullValue; -import static utils.TestReflection.setField; - -import com.practiq.domain.Question; -import com.practiq.domain.projection.LinkedQuestion; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; -import com.practiq.dto.response.QuestionResponse; -import java.time.Instant; -import java.util.Set; -import org.junit.jupiter.api.Test; - -class QuestionResponseMapperTest { - - @Test - void linkedQuestionMapsToQuestionResponse() { - long id = 1L; - QuestionDifficulty difficulty = QuestionDifficulty.MEDIUM; - QuestionType type = QuestionType.SHORT_ANSWER; - String body = "Diffraction"; - Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - - Question question = new Question(body, difficulty, type, QuestionStatus.APPROVED); - setField(question, "id", id); - setField(question, "createdAt", createdAt); - - long conceptId1 = 1L; - long conceptId2 = 2L; - LinkedQuestion linkedQuestion = new LinkedQuestion( - question, Set.of(new QuestionConceptLink(id, conceptId1), new QuestionConceptLink(id, conceptId2))); - - QuestionResponse questionResponse = toQuestionResponse(linkedQuestion); - - assertThat(questionResponse.getId(), equalTo(id)); - assertThat(questionResponse.getBody(), equalTo(body)); - assertThat(questionResponse.getDifficulty().getValue(), equalTo(difficulty.value())); - assertThat(questionResponse.getDifficulty().getCode(), equalTo(difficulty.name())); - assertThat(questionResponse.getType(), equalTo(type)); - assertThat(questionResponse.getCreatedAt(), equalTo(createdAt)); - assertThat(questionResponse.getLinkedConceptIds(), equalTo(Set.of(conceptId1, conceptId2))); - } - - // A bare question: an unrated difficulty and absent type both serialise as null (difficulty as a whole - // object, never partially populated), and no concept links yields an empty set rather than null. - @Test - void bareLinkedQuestionMapsNullDifficultyAndTypeAndEmptyLinks() { - long id = 2L; - String body = "Explain what is meant by diffraction."; - Instant createdAt = Instant.parse("2026-01-02T00:00:00Z"); - - Question question = new Question(body, null, null, QuestionStatus.APPROVED); - setField(question, "id", id); - setField(question, "createdAt", createdAt); - - LinkedQuestion linkedQuestion = new LinkedQuestion(question, Set.of()); - - QuestionResponse questionResponse = toQuestionResponse(linkedQuestion); - - assertThat(questionResponse.getId(), equalTo(id)); - assertThat(questionResponse.getBody(), equalTo(body)); - assertThat(questionResponse.getDifficulty(), nullValue()); - assertThat(questionResponse.getType(), nullValue()); - assertThat(questionResponse.getCreatedAt(), equalTo(createdAt)); - assertThat(questionResponse.getLinkedConceptIds(), equalTo(Set.of())); - } -} diff --git a/src/test/java/com/practiq/domain/types/QuestionDifficultyTest.java b/src/test/java/com/practiq/foundation/types/QuestionDifficultyTest.java similarity index 97% rename from src/test/java/com/practiq/domain/types/QuestionDifficultyTest.java rename to src/test/java/com/practiq/foundation/types/QuestionDifficultyTest.java index 6e6e1eb..6dca6d5 100644 --- a/src/test/java/com/practiq/domain/types/QuestionDifficultyTest.java +++ b/src/test/java/com/practiq/foundation/types/QuestionDifficultyTest.java @@ -1,4 +1,4 @@ -package com.practiq.domain.types; +package com.practiq.foundation.types; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; diff --git a/src/test/java/com/practiq/util/StringUtilTest.java b/src/test/java/com/practiq/foundation/util/StringUtilTest.java similarity index 94% rename from src/test/java/com/practiq/util/StringUtilTest.java rename to src/test/java/com/practiq/foundation/util/StringUtilTest.java index e0c26ec..9fc5972 100644 --- a/src/test/java/com/practiq/util/StringUtilTest.java +++ b/src/test/java/com/practiq/foundation/util/StringUtilTest.java @@ -1,4 +1,4 @@ -package com.practiq.util; +package com.practiq.foundation.util; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; diff --git a/src/test/java/com/practiq/domain/converters/QuestionDifficultyAttributeConverterTest.java b/src/test/java/com/practiq/persistence/converter/QuestionDifficultyEntityAttributeConverterTest.java similarity index 82% rename from src/test/java/com/practiq/domain/converters/QuestionDifficultyAttributeConverterTest.java rename to src/test/java/com/practiq/persistence/converter/QuestionDifficultyEntityAttributeConverterTest.java index 1d5ff44..9cf30cf 100644 --- a/src/test/java/com/practiq/domain/converters/QuestionDifficultyAttributeConverterTest.java +++ b/src/test/java/com/practiq/persistence/converter/QuestionDifficultyEntityAttributeConverterTest.java @@ -1,15 +1,16 @@ -package com.practiq.domain.converters; +package com.practiq.persistence.converter; import static org.junit.jupiter.api.Assertions.*; -import com.practiq.domain.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionDifficulty; import org.junit.jupiter.api.Test; -class QuestionDifficultyAttributeConverterTest { +class QuestionDifficultyEntityAttributeConverterTest { private static final int[] OUT_OF_RANGE_VALUES = {0, 6, -1, 100}; - private final QuestionDifficultyAttributeConverter converter = new QuestionDifficultyAttributeConverter(); + private final QuestionDifficultyEntityAttributeConverter converter = + new QuestionDifficultyEntityAttributeConverter(); @Test void roundTripsEveryDifficulty() { diff --git a/src/test/java/com/practiq/domain/query/SessionTokenRestrictionTest.java b/src/test/java/com/practiq/persistence/query/SessionTokenQueryRestrictionTest.java similarity index 86% rename from src/test/java/com/practiq/domain/query/SessionTokenRestrictionTest.java rename to src/test/java/com/practiq/persistence/query/SessionTokenQueryRestrictionTest.java index 1a8b9be..16c119d 100644 --- a/src/test/java/com/practiq/domain/query/SessionTokenRestrictionTest.java +++ b/src/test/java/com/practiq/persistence/query/SessionTokenQueryRestrictionTest.java @@ -1,4 +1,4 @@ -package com.practiq.domain.query; +package com.practiq.persistence.query; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.is; @@ -17,20 +17,21 @@ import org.mockito.junit.jupiter.MockitoExtension; import utils.CriteriaProbe; -// SessionTokenRestriction is a reusable component — any factory whose entity carries a session token can +// SessionTokenQueryRestriction is a reusable component — any factory whose entity carries a session token can // declare it — so its contract is pinned here, entity-independent (synthetic TestEntity, mocked attribute), // rather than only through whichever factories currently use it. The spec's calls on the mocked criteria // environment (CriteriaProbe) are its output at this boundary; what the predicate selects against real rows // stays with the consuming factory's repository IT. @ExtendWith(MockitoExtension.class) -class SessionTokenRestrictionTest { +class SessionTokenQueryRestrictionTest { private static final class TestEntity {} @Test void restrictRejectsANullSessionToken() { SingularAttribute sessionTokenAttribute = mock(SingularAttribute.class); - SessionTokenRestriction restriction = new SessionTokenRestriction<>(sessionTokenAttribute); + SessionTokenQueryRestriction restriction = + new SessionTokenQueryRestriction<>(sessionTokenAttribute); // Fails fast at query-build time: a null token silently becoming match-nothing (or worse, match-all) // would be a session-isolation bypass for every factory that reuses this restriction. @@ -52,7 +53,7 @@ void restrictBuildsAnEqualityBetweenTheGivenAttributeAndTheQuerysToken() { when(probe.criteriaBuilder().equal(sessionTokenPath, sessionToken)).thenReturn(equality); QuerySpecification specification = - new SessionTokenRestriction<>(sessionTokenAttribute).restrict(() -> sessionToken); + new SessionTokenQueryRestriction<>(sessionTokenAttribute).restrict(() -> sessionToken); // The resolved predicate IS the equality built from the given attribute and the query's token — the // exact-arg stubs above only match if both reached the criteria builder unchanged. diff --git a/src/test/java/com/practiq/domain/query/attempt/QuestionAttemptQueryRunnerTest.java b/src/test/java/com/practiq/persistence/query/attempt/QuestionAttemptQueryRunnerTest.java similarity index 55% rename from src/test/java/com/practiq/domain/query/attempt/QuestionAttemptQueryRunnerTest.java rename to src/test/java/com/practiq/persistence/query/attempt/QuestionAttemptQueryRunnerTest.java index 6614cd9..a282351 100644 --- a/src/test/java/com/practiq/domain/query/attempt/QuestionAttemptQueryRunnerTest.java +++ b/src/test/java/com/practiq/persistence/query/attempt/QuestionAttemptQueryRunnerTest.java @@ -1,20 +1,15 @@ -package com.practiq.domain.query.attempt; +package com.practiq.persistence.query.attempt; -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static utils.TestReflection.setField; -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.identity.UserRef; -import com.practiq.repository.QuestionAttemptRepository; +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.repository.QuestionAttemptRepository; import io.micronaut.data.model.Sort; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; -import java.time.Instant; import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -42,45 +37,23 @@ void setUp() { } @Test - void getQuestionAttemptsReturnsTheRepositoriesResultsUnderTheStableOrder() { + void findAllReturnsTheRepositoriesResultsUnderTheStableOrder() { long questionId = 7L; String sessionToken = "session-token"; - List found = List.of( - new QuestionAttempt(questionId, sessionToken, "attempt 1"), - new QuestionAttempt(questionId, sessionToken, "attempt 2")); + List found = List.of( + new QuestionAttemptEntity(questionId, sessionToken, "attempt 1"), + new QuestionAttemptEntity(questionId, sessionToken, "attempt 2")); when(questionAttemptRepository.findAll(anySpec(), eq(STABLE_ORDER))).thenReturn(found); - List result = runner.getQuestionAttempts(new UserRef(sessionToken), questionId); + List result = runner.findAll(new QuestionAttemptQuery(questionId, sessionToken)); assertEquals(found, result); verify(questionAttemptRepository).findAll(anySpec(), eq(STABLE_ORDER)); } - @Test - void createQuestionAttemptUsesSavedEntity() { - long questionId = 5L; - String sessionToken = "session-token"; - String body = "attempt"; - - QuestionAttempt incomingAttempt = new QuestionAttempt(questionId, sessionToken, body); - long attemptId = 20L; - Instant createdAt = Instant.parse("2026-01-03T00:00:00Z"); - QuestionAttempt attemptDB = new QuestionAttempt(questionId, sessionToken, body); - setField(attemptDB, "createdAt", createdAt); - setField(attemptDB, "id", attemptId); - - when(questionAttemptRepository.save(incomingAttempt)).thenReturn(attemptDB); - - QuestionAttempt attempt = runner.createQuestionAttempt(incomingAttempt); - - assertThat(attempt, is(attemptDB)); - - verify(questionAttemptRepository).save(incomingAttempt); - } - // Typed matcher so overload resolution picks findAll(QuerySpecification, Sort) over its siblings; the runner // builds the spec from its real factory, so the test matches on type rather than the instance. - private static QuerySpecification anySpec() { + private static QuerySpecification anySpec() { return any(); } } diff --git a/src/test/java/com/practiq/persistence/query/question/QuestionQueryRunnerTest.java b/src/test/java/com/practiq/persistence/query/question/QuestionQueryRunnerTest.java new file mode 100644 index 0000000..c823291 --- /dev/null +++ b/src/test/java/com/practiq/persistence/query/question/QuestionQueryRunnerTest.java @@ -0,0 +1,119 @@ +package com.practiq.persistence.query.question; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static utils.TestReflection.setField; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.projection.QuestionConceptLinkProjection; +import com.practiq.persistence.repository.QuestionConceptRepository; +import com.practiq.persistence.repository.QuestionRepository; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.data.model.Sort; +import io.micronaut.data.repository.jpa.criteria.QuerySpecification; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +// Tested with its real specification factory inside it and only the repositories mocked: the factory is an +// implementation detail the runner is exercised through, not a seam that earns its own test file. +@ExtendWith(MockitoExtension.class) +class QuestionQueryRunnerTest { + + @Mock + private QuestionRepository questionRepository; + + @Mock + private QuestionConceptRepository questionConceptRepository; + + private QuestionQueryRunner runner; + + @BeforeEach + void setUp() { + runner = new QuestionQueryRunner( + questionRepository, questionConceptRepository, new QuestionSpecificationFactory()); + } + + @Test + void findAllAttachesEachQuestionsConceptIds() { + QuestionEntity linked = question(1L, "Linked"); + QuestionEntity unlinked = question(2L, "Unlinked"); + + when(questionRepository.findAll(any(QuerySpecification.class))).thenReturn(List.of(linked, unlinked)); + when(questionConceptRepository.findLinksByQuestionIds(any())) + .thenReturn(List.of( + new QuestionConceptLinkProjection(1L, 10L), new QuestionConceptLinkProjection(1L, 11L))); + + List found = + runner.findAll(QuestionQuery.builder().build()); + + assertThat(found.stream().map(q -> q.question().getBody()).toList(), contains("Linked", "Unlinked")); + assertThat(found.getFirst().conceptIds(), containsInAnyOrder(10L, 11L)); + assertThat(found.getLast().conceptIds(), empty()); + } + + @Test + void findAllSkipsTheLinkQueryWhenNothingMatched() { + when(questionRepository.findAll(any(QuerySpecification.class))).thenReturn(List.of()); + + assertThat(runner.findAll(QuestionQuery.builder().build()), empty()); + + // A second statement for a miss would be a query issued with an empty id set. + verifyNoInteractions(questionConceptRepository); + } + + @Test + void findPageImposesTheStableCreatedAtThenIdOrderOverTheRequestedPage() { + QuestionEntity question = question(1L, "Question"); + + when(questionRepository.findAll(any(QuerySpecification.class), any(Pageable.class))) + .thenReturn(Page.of(List.of(question), Pageable.from(2, 5), 11L)); + when(questionConceptRepository.findLinksByQuestionIds(any())) + .thenReturn(List.of(new QuestionConceptLinkProjection(1L, 10L))); + + Page page = + runner.findPage(QuestionQuery.builder().build(), Pageable.from(2, 5)); + + ArgumentCaptor used = ArgumentCaptor.forClass(Pageable.class); + verify(questionRepository).findAll(any(QuerySpecification.class), used.capture()); + + assertThat(used.getValue().getNumber(), equalTo(2)); + assertThat(used.getValue().getSize(), equalTo(5)); + assertThat(used.getValue().getSort().getOrderBy(), contains(Sort.Order.asc("createdAt"), Sort.Order.asc("id"))); + + // The page's own metadata survives the mapping, so paging stays correct through the attachment. + assertThat(page.getTotalSize(), equalTo(11L)); + assertThat(page.getContent().getFirst().conceptIds(), contains(10L)); + } + + @Test + void existsAsksTheRepositoryWithoutFetchingRows() { + when(questionRepository.exists(any(QuerySpecification.class))).thenReturn(true); + + assertThat(runner.exists(QuestionQuery.builder().build()), equalTo(true)); + + verifyNoInteractions(questionConceptRepository); + } + + private static QuestionEntity question(long id, String body) { + QuestionEntity question = + new QuestionEntity(body, QuestionDifficulty.EASY, QuestionType.MCQ, QuestionStatus.APPROVED); + setField(question, "id", id); + return question; + } +} diff --git a/src/test/java/com/practiq/service/ConceptServiceTest.java b/src/test/java/com/practiq/service/ConceptServiceTest.java deleted file mode 100644 index adeb763..0000000 --- a/src/test/java/com/practiq/service/ConceptServiceTest.java +++ /dev/null @@ -1,119 +0,0 @@ -package com.practiq.service; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.mockito.Mockito.*; -import static utils.TestReflection.setField; - -import com.practiq.domain.Concept; -import com.practiq.dto.response.ConceptResponse; -import com.practiq.repository.ConceptRepository; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class ConceptServiceTest { - - @Mock - private ConceptRepository conceptRepository; - - @InjectMocks - private ConceptService conceptService; - - @Test - void getReturnsAllConcepts() { - long diffractionId = 42L; - String diffractionName = "Diffraction"; - String diffractionDescription = "The spreading of waves through a gap or around an obstacle."; - Instant diffractionCreatedAt = Instant.parse("2026-06-29T10:15:30Z"); - - Concept diffraction = concept(diffractionId, diffractionName, diffractionDescription, diffractionCreatedAt); - - long accelerationId = 43L; - String accelerationName = "Acceleration"; - String accelerationDescription = "The rate of change of velocity over time."; - Instant accelerationCreatedAt = Instant.parse("2026-06-29T10:15:30Z"); - - Concept acceleration = - concept(accelerationId, accelerationName, accelerationDescription, accelerationCreatedAt); - - when(conceptRepository.listOrderByCreatedAtAsc()).thenReturn(List.of(diffraction, acceleration)); - - List conceptResponses = conceptService.get(); - assertThat( - conceptResponses, - containsInAnyOrder( - allOf( - hasProperty("id", equalTo(diffractionId)), - hasProperty("name", equalTo(diffractionName)), - hasProperty("description", equalTo(diffractionDescription)), - hasProperty("createdAt", equalTo(diffractionCreatedAt))), - allOf( - hasProperty("id", equalTo(accelerationId)), - hasProperty("name", equalTo(accelerationName)), - hasProperty("description", equalTo(accelerationDescription)), - hasProperty("createdAt", equalTo(accelerationCreatedAt))))); - - verify(conceptRepository).listOrderByCreatedAtAsc(); - } - - @Test - void getReturnsEmptyListWhenNoneExist() { - when(conceptRepository.listOrderByCreatedAtAsc()).thenReturn(List.of()); - - List concepts = conceptService.get(); - assertEquals(0, concepts.size()); - - verify(conceptRepository).listOrderByCreatedAtAsc(); - } - - @Test - void getReturnsConceptById() { - long id = 42L; - String name = "Diffraction"; - String description = "The spreading of waves through a gap or around an obstacle."; - Instant createdAt = Instant.parse("2026-06-29T10:15:30Z"); - - when(conceptRepository.findById(id)).thenReturn(Optional.of(concept(id, name, description, createdAt))); - - Optional conceptDto = conceptService.get(id); - assertThat(conceptDto.isPresent(), is(true)); - assertThat( - conceptDto.get(), - allOf( - hasProperty("id", equalTo(id)), - hasProperty("name", equalTo(name)), - hasProperty("description", equalTo(description)), - hasProperty("createdAt", equalTo(createdAt)))); - - verify(conceptRepository).findById(id); - } - - @Test - void getReturnsConceptByIdNotFound() { - long id = 42L; - when(conceptRepository.findById(id)).thenReturn(Optional.empty()); - - Optional conceptDto = conceptService.get(id); - assertThat(conceptDto.isPresent(), is(false)); - - verify(conceptRepository).findById(id); - } - - // A real Concept rather than a mock: id and createdAt are DB-assigned in production, so they're set by - // reflection. A mocked entity would answer whatever was stubbed for whichever getter the mapper happens - // to call, so it can't catch the mapper reading the wrong field. - private static Concept concept(long id, String name, String description, Instant createdAt) { - Concept concept = new Concept(name, description); - setField(concept, "id", id); - setField(concept, "createdAt", createdAt); - return concept; - } -} diff --git a/src/test/java/com/practiq/service/QuestionAttemptServiceTest.java b/src/test/java/com/practiq/service/QuestionAttemptServiceTest.java deleted file mode 100644 index d193825..0000000 --- a/src/test/java/com/practiq/service/QuestionAttemptServiceTest.java +++ /dev/null @@ -1,166 +0,0 @@ -package com.practiq.service; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.hamcrest.Matchers.equalTo; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static utils.TestReflection.setField; - -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.identity.UserRef; -import com.practiq.domain.query.attempt.QuestionAttemptQueryRunner; -import com.practiq.domain.query.question.StudentQuestionQueryRunner; -import com.practiq.dto.request.QuestionAttemptRequest; -import com.practiq.dto.response.QuestionAttemptResponse; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class QuestionAttemptServiceTest { - - @Mock - private StudentQuestionQueryRunner questionQueryRunner; - - @Mock - private QuestionAttemptQueryRunner questionAttemptQueryRunner; - - @InjectMocks - private QuestionAttemptService questionAttemptService; - - @Test - void getQuestionAttemptByIdReturnsOptionalEmptyIfNoQuestionForId() { - long questionId = 5L; - String sessionToken = "session-token"; - UserRef userRef = new UserRef(sessionToken); - - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(false); - - Optional> attempts = questionAttemptService.getForQuestionId(userRef, questionId); - - assertThat(attempts.isPresent(), is(false)); - - verify(questionQueryRunner).doesQuestionExistForId(questionId); - } - - @Test - void getQuestionAttemptByIdReturnsOptionalOfEmptyListIfNoAttemptsForQuestion() { - long questionId = 5L; - String sessionToken = "session-token"; - UserRef userRef = new UserRef(sessionToken); - - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(true); - when(questionAttemptQueryRunner.getQuestionAttempts(userRef, questionId)) - .thenReturn(List.of()); - - Optional> attempts = questionAttemptService.getForQuestionId(userRef, questionId); - - assertThat(attempts.isPresent(), is(true)); - assertThat(attempts.get(), empty()); - - verify(questionQueryRunner).doesQuestionExistForId(questionId); - verify(questionAttemptQueryRunner).getQuestionAttempts(userRef, questionId); - } - - @Test - void getQuestionAttemptByIdReturnsOptionalListIfAttemptsForQuestion() { - long questionId = 5L; - String sessionToken = "session-token"; - UserRef userRef = new UserRef(sessionToken); - - long attemptId1 = 10L; - String attemptBody1 = "attempt1"; - Instant createdAt1 = Instant.parse("2026-01-01T00:00:00Z"); - QuestionAttempt attempt1 = new QuestionAttempt(questionId, sessionToken, attemptBody1); - setField(attempt1, "id", attemptId1); - setField(attempt1, "createdAt", createdAt1); - - long attemptId2 = 20L; - String attemptBody2 = "attempt2"; - Instant createdAt2 = Instant.parse("2026-01-02T00:00:00Z"); - QuestionAttempt attempt2 = new QuestionAttempt(questionId, sessionToken, attemptBody2); - setField(attempt2, "id", attemptId2); - setField(attempt2, "createdAt", createdAt2); - - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(true); - when(questionAttemptQueryRunner.getQuestionAttempts(userRef, questionId)) - .thenReturn(List.of(attempt1, attempt2)); - - Optional> attempts = questionAttemptService.getForQuestionId(userRef, questionId); - - // The full ordered response list is the service's observable outcome — asserted independently of how it - // is produced, so a mis-wired or broken mapping fails here even though the mapper's own test stays green. - assertThat(attempts.isPresent(), is(true)); - assertThat(attempts.get().size(), is(2)); - - QuestionAttemptResponse attemptResponse1 = attempts.get().get(0); - assertThat(attemptResponse1.getId(), equalTo(attemptId1)); - assertThat(attemptResponse1.getQuestionId(), equalTo(questionId)); - assertThat(attemptResponse1.getBody(), equalTo(attemptBody1)); - assertThat(attemptResponse1.getCreatedAt(), equalTo(createdAt1)); - - QuestionAttemptResponse attemptResponse2 = attempts.get().get(1); - assertThat(attemptResponse2.getId(), equalTo(attemptId2)); - assertThat(attemptResponse2.getQuestionId(), equalTo(questionId)); - assertThat(attemptResponse2.getBody(), equalTo(attemptBody2)); - assertThat(attemptResponse2.getCreatedAt(), equalTo(createdAt2)); - - verify(questionQueryRunner).doesQuestionExistForId(questionId); - verify(questionAttemptQueryRunner).getQuestionAttempts(userRef, questionId); - } - - @Test - void postQuestionAttemptByIdReturnsOptionalEmptyIfNoQuestionForId() { - long questionId = 5L; - String sessionToken = "session-token"; - String body = "attempt"; - QuestionAttemptRequest request = new QuestionAttemptRequest(body); - - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(false); - - Optional attempt = - questionAttemptService.postForQuestionId(new UserRef(sessionToken), request, questionId); - - assertThat(attempt.isPresent(), is(false)); - - verify(questionQueryRunner).doesQuestionExistForId(questionId); - } - - @Test - void postQuestionAttemptReturnsUpdatedEntity() { - long questionId = 5L; - String sessionToken = "session-token"; - String body = "attempt"; - QuestionAttemptRequest request = new QuestionAttemptRequest(body); - - QuestionAttempt incomingAttempt = new QuestionAttempt(questionId, sessionToken, body); - long attemptId = 20L; - Instant createdAt = Instant.parse("2026-01-03T00:00:00Z"); - QuestionAttempt attemptDB = new QuestionAttempt(questionId, sessionToken, body); - setField(attemptDB, "createdAt", createdAt); - setField(attemptDB, "id", attemptId); - - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(true); - when(questionAttemptQueryRunner.createQuestionAttempt(incomingAttempt)).thenReturn(attemptDB); - - Optional attempt = - questionAttemptService.postForQuestionId(new UserRef(sessionToken), request, questionId); - - assertThat(attempt.isPresent(), is(true)); - - QuestionAttemptResponse response = attempt.get(); - assertThat(response.getId(), is(attemptId)); - assertThat(response.getQuestionId(), is(questionId)); - assertThat(response.getBody(), is(body)); - assertThat(response.getCreatedAt(), is(createdAt)); - - verify(questionQueryRunner).doesQuestionExistForId(questionId); - verify(questionAttemptQueryRunner).createQuestionAttempt(incomingAttempt); - } -} diff --git a/src/test/java/com/practiq/service/QuestionAttemptServiceValidationCT.java b/src/test/java/com/practiq/service/QuestionAttemptServiceValidationCT.java deleted file mode 100644 index 5056f24..0000000 --- a/src/test/java/com/practiq/service/QuestionAttemptServiceValidationCT.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.practiq.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import com.practiq.domain.identity.UserRef; -import com.practiq.dto.request.QuestionAttemptRequest; -import jakarta.inject.Inject; -import jakarta.validation.ConstraintViolationException; -import org.junit.jupiter.api.Test; -import utils.ComponentTest; - -@ComponentTest -class QuestionAttemptServiceValidationCT { - - private static final long QUESTION_ID = 1L; - private static final String BLANK = " "; - - @Inject - private QuestionAttemptService questionAttemptService; - - @Test - void getForQuestionIdRejectsBlankSessionTokenOnUserRef() { - UserRef userRef = new UserRef(BLANK); - - ConstraintViolationException exception = assertThrows( - ConstraintViolationException.class, - () -> questionAttemptService.getForQuestionId(userRef, QUESTION_ID)); - - assertEquals("getForQuestionId.userRef.sessionToken: must not be blank", exception.getMessage()); - } - - @Test - void postForQuestionIdRejectsBlankSessionTokenOnUserRef() { - UserRef userRef = new UserRef(BLANK); - QuestionAttemptRequest request = new QuestionAttemptRequest("an attempt"); - - ConstraintViolationException exception = assertThrows( - ConstraintViolationException.class, - () -> questionAttemptService.postForQuestionId(userRef, request, QUESTION_ID)); - - assertEquals("postForQuestionId.userRef.sessionToken: must not be blank", exception.getMessage()); - } - - @Test - void postForQuestionIdRejectsBlankBodyOnRequest() { - UserRef userRef = new UserRef("a session token"); - QuestionAttemptRequest request = new QuestionAttemptRequest(BLANK); - - ConstraintViolationException exception = assertThrows( - ConstraintViolationException.class, - () -> questionAttemptService.postForQuestionId(userRef, request, QUESTION_ID)); - - assertEquals("postForQuestionId.request.body: must not be blank", exception.getMessage()); - } -} diff --git a/src/test/java/com/practiq/service/QuestionServiceTest.java b/src/test/java/com/practiq/service/QuestionServiceTest.java deleted file mode 100644 index 28ae282..0000000 --- a/src/test/java/com/practiq/service/QuestionServiceTest.java +++ /dev/null @@ -1,147 +0,0 @@ -package com.practiq.service; - -import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.*; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static utils.TestReflection.setField; - -import com.practiq.domain.Question; -import com.practiq.domain.projection.LinkedQuestion; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.query.question.StudentQuestionQueryRunner; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; -import com.practiq.dto.request.QuestionRequest; -import com.practiq.dto.response.PageResponse; -import com.practiq.dto.response.QuestionResponse; -import io.micronaut.data.model.Page; -import io.micronaut.data.model.Pageable; -import java.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.Set; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class QuestionServiceTest { - - @Mock - private StudentQuestionQueryRunner questionQueryRunner; - - @InjectMocks - private QuestionService questionService; - - @Test - void getQuestionByIdReturnsEmptyWhenNoVisibleQuestionExists() { - long questionId = 1L; - - when(questionQueryRunner.findQuestionById(questionId)).thenReturn(Optional.empty()); - - Optional response = questionService.get(questionId); - - assertThat(response.isPresent(), equalTo(false)); - - verify(questionQueryRunner).findQuestionById(questionId); - } - - @Test - void getQuestionByIdMapsTheLinkedQuestionToAResponse() { - long questionId = 1L; - long conceptId = 10L; - String body = "State Newton's first law."; - QuestionDifficulty difficulty = QuestionDifficulty.MEDIUM; - QuestionType type = QuestionType.SHORT_ANSWER; - Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - - Question question = new Question(body, difficulty, type, QuestionStatus.APPROVED); - setField(question, "id", questionId); - setField(question, "createdAt", createdAt); - LinkedQuestion linkedQuestion = - new LinkedQuestion(question, Set.of(new QuestionConceptLink(questionId, conceptId))); - - when(questionQueryRunner.findQuestionById(questionId)).thenReturn(Optional.of(linkedQuestion)); - - Optional response = questionService.get(questionId); - - // The full response is the service's observable outcome — asserted independently of how it is produced, - // so a mis-wired or broken mapping fails here even though QuestionResponseMapperTest stays green. - assertThat(response.isPresent(), equalTo(true)); - QuestionResponse questionResponse = response.get(); - assertThat(questionResponse.getId(), equalTo(questionId)); - assertThat(questionResponse.getBody(), equalTo(body)); - assertThat(questionResponse.getDifficulty().getValue(), equalTo(difficulty.value())); - assertThat(questionResponse.getDifficulty().getCode(), equalTo(difficulty.name())); - assertThat(questionResponse.getType(), equalTo(type)); - assertThat(questionResponse.getCreatedAt(), equalTo(createdAt)); - assertThat(questionResponse.getLinkedConceptIds(), equalTo(Set.of(conceptId))); - - verify(questionQueryRunner).findQuestionById(questionId); - } - - @Test - void getQuestionsPairsThePagesMetadataWithItsMappedContent() { - long questionId = 7L; - String body = "Explain what is meant by diffraction."; - - Question question = new Question(body, null, null, QuestionStatus.APPROVED); - setField(question, "id", questionId); - LinkedQuestion linkedQuestion = new LinkedQuestion(question, Set.of()); - - // Distinct filter values so the assertion proves the service unpacks the request and hands the - // runner each field, rather than passing something opaque through. - List types = List.of(QuestionType.MCQ); - List difficulties = List.of(QuestionDifficulty.HARD); - long conceptId = 3L; - QuestionRequest request = new QuestionRequest(types, difficulties, conceptId); - - Pageable requested = Pageable.from(2, 5); - - // Page number, size and total are all distinct, so an envelope that hardcodes a value or reads the - // wrong field off the Page fails rather than coincidentally matching. - Page page = Page.of(List.of(linkedQuestion), requested, 11L); - when(questionQueryRunner.findQuestionsPagedAndFiltered(types, difficulties, conceptId, requested)) - .thenReturn(page); - - PageResponse response = questionService.get(request, requested); - - // The content is the mapped question, not an empty list — the field-by-field mapping itself is - // covered by getQuestionByIdMapsTheLinkedQuestionToAResponse. - assertThat(response.content().size(), equalTo(1)); - assertThat(response.content().getFirst().getId(), equalTo(questionId)); - assertThat(response.content().getFirst().getBody(), equalTo(body)); - assertThat(response.page(), equalTo(2)); - assertThat(response.size(), equalTo(5)); - assertThat(response.totalCount(), equalTo(11L)); - - verify(questionQueryRunner).findQuestionsPagedAndFiltered(types, difficulties, conceptId, requested); - } - - @Test - void getQuestionsReturnsAnEmptyContentPageWithMetadataWhenTheRunnerFindsNothing() { - QuestionRequest request = new QuestionRequest(null, null, null); - Pageable requested = Pageable.from(3, 5); - - // An empty page that still reports a non-zero total — a page requested past the end of the data. The - // service must map the empty content without falling over and carry the metadata through rather than - // zeroing it out. An empty page is an input the runner genuinely produces, so it is the service's own - // contract to handle, not something to assume the mapping tolerates. - Page emptyPage = Page.of(List.of(), requested, 11L); - when(questionQueryRunner.findQuestionsPagedAndFiltered(null, null, null, requested)) - .thenReturn(emptyPage); - - PageResponse response = questionService.get(request, requested); - - assertThat(response.content(), is(empty())); - assertThat(response.page(), equalTo(3)); - assertThat(response.size(), equalTo(5)); - assertThat(response.totalCount(), equalTo(11L)); - - verify(questionQueryRunner).findQuestionsPagedAndFiltered(null, null, null, requested); - } -} diff --git a/src/test/java/com/practiq/service/QuestionServiceValidationCT.java b/src/test/java/com/practiq/service/QuestionServiceValidationCT.java deleted file mode 100644 index af49213..0000000 --- a/src/test/java/com/practiq/service/QuestionServiceValidationCT.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.practiq.service; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import com.practiq.domain.types.QuestionType; -import com.practiq.dto.request.QuestionRequest; -import io.micronaut.data.model.Pageable; -import jakarta.inject.Inject; -import jakarta.validation.ConstraintViolationException; -import java.util.List; -import org.junit.jupiter.api.Test; -import utils.ComponentTest; - -@ComponentTest -class QuestionServiceValidationCT { - - @Inject - private QuestionService questionService; - - @Test - void getRejectsDuplicateTypesOnRequest() { - QuestionRequest request = new QuestionRequest(List.of(QuestionType.MCQ, QuestionType.MCQ), null, null); - - ConstraintViolationException exception = - assertThrows(ConstraintViolationException.class, () -> questionService.get(request, Pageable.UNPAGED)); - - assertEquals("get.request.types: contains duplicates ([MCQ, MCQ])", exception.getMessage()); - } -} diff --git a/src/test/java/com/practiq/service/UserRefTest.java b/src/test/java/com/practiq/service/UserRefTest.java new file mode 100644 index 0000000..3d1b5b9 --- /dev/null +++ b/src/test/java/com/practiq/service/UserRefTest.java @@ -0,0 +1,47 @@ +package com.practiq.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH; + +import org.junit.jupiter.api.Test; + +class UserRefTest { + + @Test + void aUserRefHoldsTheTokenItWasBuiltWith() { + String sessionToken = "b10ef800-3a3a-4395-9bbd-bfe2fa316872"; + + assertEquals(sessionToken, new UserRef(sessionToken).sessionToken()); + } + + @Test + void aUserRefCannotBeBuiltWithoutASessionToken() { + assertEquals( + "sessionToken must not be blank", + assertThrows(IllegalArgumentException.class, () -> new UserRef(null)) + .getMessage()); + assertEquals( + "sessionToken must not be blank", + assertThrows(IllegalArgumentException.class, () -> new UserRef("")) + .getMessage()); + assertEquals( + "sessionToken must not be blank", + assertThrows(IllegalArgumentException.class, () -> new UserRef(" ")) + .getMessage()); + } + + @Test + void aUserRefTakesASessionTokenAtTheMaximumLengthButNotOneAbove() { + String atTheMaximum = "a".repeat(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH); + + assertEquals(atTheMaximum, new UserRef(atTheMaximum).sessionToken()); + + String oneAbove = "a".repeat(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH + 1); + + assertEquals( + "sessionToken cannot exceed max length " + QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH, + assertThrows(IllegalArgumentException.class, () -> new UserRef(oneAbove)) + .getMessage()); + } +} diff --git a/src/test/java/com/practiq/service/attempt/QuestionAttemptServiceTest.java b/src/test/java/com/practiq/service/attempt/QuestionAttemptServiceTest.java new file mode 100644 index 0000000..6d620d9 --- /dev/null +++ b/src/test/java/com/practiq/service/attempt/QuestionAttemptServiceTest.java @@ -0,0 +1,135 @@ +package com.practiq.service.attempt; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static utils.TestReflection.setField; + +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.query.attempt.QuestionAttemptQuery; +import com.practiq.persistence.query.attempt.QuestionAttemptQueryRunner; +import com.practiq.persistence.repository.QuestionAttemptRepository; +import com.practiq.service.UserRef; +import com.practiq.service.attempt.dto.request.QuestionAttemptCommand; +import com.practiq.service.attempt.dto.response.QuestionAttempt; +import com.practiq.service.question.QuestionAccessor; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +// The visibility gate is the whole point of this layer: a question the user may not see must produce an empty +// Optional without the attempt store being touched, so a 404 can't be told apart from "no attempts yet". +@ExtendWith(MockitoExtension.class) +class QuestionAttemptServiceTest { + + @Mock + private QuestionAccessor questions; + + @Mock + private QuestionAttemptQueryRunner questionAttemptQueryRunner; + + @Mock + private QuestionAttemptRepository questionAttemptRepository; + + @InjectMocks + private QuestionAttemptService questionAttemptService; + + @Test + void getForQuestionIdReturnsEmptyWhenTheQuestionIsNotVisible() { + long questionId = 10L; + when(questions.exists(questionId)).thenReturn(false); + + Optional> result = + questionAttemptService.getForQuestionId(new UserRef("session-token"), questionId); + + assertThat(result.isPresent(), is(false)); + verifyNoInteractions(questionAttemptQueryRunner); + } + + @Test + void getForQuestionIdReturnsThisSessionsMappedAttempts() { + long questionId = 10L; + String sessionToken = "session-token"; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + when(questions.exists(questionId)).thenReturn(true); + when(questionAttemptQueryRunner.findAll(new QuestionAttemptQuery(questionId, sessionToken))) + .thenReturn(List.of(attempt(1L, questionId, sessionToken, "First", createdAt))); + + Optional> result = + questionAttemptService.getForQuestionId(new UserRef(sessionToken), questionId); + + assertThat(result.isPresent(), is(true)); + assertThat(result.get(), contains(new QuestionAttempt(1L, questionId, sessionToken, "First", createdAt))); + } + + @Test + void getForQuestionIdReturnsAnEmptyListWhenTheVisibleQuestionHasNoAttempts() { + long questionId = 10L; + String sessionToken = "session-token"; + + when(questions.exists(questionId)).thenReturn(true); + when(questionAttemptQueryRunner.findAll(new QuestionAttemptQuery(questionId, sessionToken))) + .thenReturn(List.of()); + + Optional> result = + questionAttemptService.getForQuestionId(new UserRef(sessionToken), questionId); + + // Present but empty, which is what separates "you have not attempted this" from "no such question". + assertThat(result.isPresent(), is(true)); + assertThat(result.get(), is(List.of())); + } + + @Test + void createReturnsEmptyAndSavesNothingWhenTheQuestionIsNotVisible() { + long questionId = 10L; + when(questions.exists(questionId)).thenReturn(false); + + Optional result = questionAttemptService.create( + new QuestionAttemptCommand(questionId, new UserRef("session-token"), "An attempt.")); + + assertThat(result.isPresent(), is(false)); + verify(questionAttemptRepository, never()).save(any(QuestionAttemptEntity.class)); + } + + @Test + void createSavesTheAttemptAndReturnsWhatPersistenceAssigned() { + long questionId = 10L; + String sessionToken = "session-token"; + String body = "An attempt."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + when(questions.exists(questionId)).thenReturn(true); + when(questionAttemptRepository.save(any(QuestionAttemptEntity.class))).thenAnswer(invocation -> { + QuestionAttemptEntity saved = invocation.getArgument(0); + setField(saved, "id", 99L); + setField(saved, "createdAt", createdAt); + return saved; + }); + + Optional result = + questionAttemptService.create(new QuestionAttemptCommand(questionId, new UserRef(sessionToken), body)); + + assertThat(result.isPresent(), is(true)); + assertThat(result.get(), equalTo(new QuestionAttempt(99L, questionId, sessionToken, body, createdAt))); + } + + private static QuestionAttemptEntity attempt( + long id, long questionId, String sessionToken, String body, Instant createdAt) { + QuestionAttemptEntity attempt = new QuestionAttemptEntity(questionId, sessionToken, body); + setField(attempt, "id", id); + setField(attempt, "createdAt", createdAt); + return attempt; + } +} diff --git a/src/test/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommandTest.java b/src/test/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommandTest.java new file mode 100644 index 0000000..86b2a44 --- /dev/null +++ b/src/test/java/com/practiq/service/attempt/dto/request/QuestionAttemptCommandTest.java @@ -0,0 +1,45 @@ +package com.practiq.service.attempt.dto.request; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.practiq.service.UserRef; +import org.junit.jupiter.api.Test; + +class QuestionAttemptCommandTest { + + private static final UserRef USER = new UserRef("a-session-token"); + + @Test + void aCommandHoldsTheValuesItWasBuiltWith() { + QuestionAttemptCommand command = new QuestionAttemptCommand(10L, USER, "An attempt."); + + assertEquals(10L, command.questionId()); + assertEquals(USER, command.userRef()); + assertEquals("An attempt.", command.body()); + } + + @Test + void aCommandCannotBeBuiltWithAQuestionIdBelowOne() { + assertEquals( + "questionId must be greater than or equal to 1", + assertThrows(IllegalArgumentException.class, () -> new QuestionAttemptCommand(0L, USER, "An attempt.")) + .getMessage()); + } + + @Test + void aCommandCannotBeBuiltWithoutAUser() { + assertEquals( + "userRef must not be null", + assertThrows(IllegalArgumentException.class, () -> new QuestionAttemptCommand(10L, null, "An attempt.")) + .getMessage()); + } + + @Test + void aCommandCannotBeBuiltWithoutABody() { + assertEquals( + "body must not be blank", + assertThrows(IllegalArgumentException.class, () -> new QuestionAttemptCommand(10L, USER, " ")) + .getMessage()); + } +} diff --git a/src/test/java/com/practiq/service/attempt/mapper/QuestionAttemptMapperTest.java b/src/test/java/com/practiq/service/attempt/mapper/QuestionAttemptMapperTest.java new file mode 100644 index 0000000..cceb898 --- /dev/null +++ b/src/test/java/com/practiq/service/attempt/mapper/QuestionAttemptMapperTest.java @@ -0,0 +1,36 @@ +package com.practiq.service.attempt.mapper; + +import static com.practiq.service.attempt.mapper.QuestionAttemptMapper.toQuestionAttempt; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static utils.TestReflection.setField; + +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.service.attempt.dto.response.QuestionAttempt; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class QuestionAttemptMapperTest { + + @Test + void questionAttemptEntityMapsToQuestionAttempt() { + long id = 1L; + long questionId = 10L; + String sessionToken = "a-session-token"; + String body = "Because the wave bends around the edge of the gap."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + QuestionAttemptEntity attemptEntity = new QuestionAttemptEntity(questionId, sessionToken, body); + setField(attemptEntity, "id", id); + setField(attemptEntity, "createdAt", createdAt); + + QuestionAttempt questionAttempt = toQuestionAttempt(attemptEntity); + + assertThat(questionAttempt.id(), equalTo(id)); + assertThat(questionAttempt.questionId(), equalTo(questionId)); + // Carried through the service layer; the web mapper is what drops it. + assertThat(questionAttempt.sessionToken(), equalTo(sessionToken)); + assertThat(questionAttempt.body(), equalTo(body)); + assertThat(questionAttempt.createdAt(), equalTo(createdAt)); + } +} diff --git a/src/test/java/com/practiq/service/concept/ConceptServiceTest.java b/src/test/java/com/practiq/service/concept/ConceptServiceTest.java new file mode 100644 index 0000000..df9bf55 --- /dev/null +++ b/src/test/java/com/practiq/service/concept/ConceptServiceTest.java @@ -0,0 +1,136 @@ +package com.practiq.service.concept; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static utils.TestReflection.setField; + +import com.practiq.persistence.ConceptEntity; +import com.practiq.persistence.repository.ConceptRepository; +import com.practiq.service.concept.dto.response.Concept; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ConceptServiceTest { + + @Mock + private ConceptRepository conceptRepository; + + @InjectMocks + private ConceptService conceptService; + + @Test + void getReturnsAllConceptsInTheOrderTheRepositoryGaveThem() { + long diffractionId = 42L; + int diffractionVersion = 3; + String diffractionName = "Diffraction"; + String diffractionDescription = "The spreading of waves through a gap or around an obstacle."; + Instant diffractionCreatedAt = Instant.parse("2026-06-29T10:15:30Z"); + + long accelerationId = 43L; + int accelerationVersion = 0; + String accelerationName = "Acceleration"; + String accelerationDescription = "The rate of change of velocity over time."; + Instant accelerationCreatedAt = Instant.parse("2026-06-29T11:15:30Z"); + + when(conceptRepository.listOrderByCreatedAtAsc()) + .thenReturn(List.of( + conceptEntity( + diffractionId, + diffractionVersion, + diffractionName, + diffractionDescription, + diffractionCreatedAt), + conceptEntity( + accelerationId, + accelerationVersion, + accelerationName, + accelerationDescription, + accelerationCreatedAt))); + + List concepts = conceptService.get(); + + // Built here rather than through ConceptMapper: an expected value produced by the code under test + // moves with it and can never fail. + assertThat( + concepts, + contains( + equalTo(new Concept( + diffractionId, + diffractionVersion, + diffractionName, + diffractionDescription, + diffractionCreatedAt)), + equalTo(new Concept( + accelerationId, + accelerationVersion, + accelerationName, + accelerationDescription, + accelerationCreatedAt)))); + + verify(conceptRepository).listOrderByCreatedAtAsc(); + } + + @Test + void getReturnsEmptyListWhenNoneExist() { + when(conceptRepository.listOrderByCreatedAtAsc()).thenReturn(List.of()); + + List concepts = conceptService.get(); + assertEquals(0, concepts.size()); + + verify(conceptRepository).listOrderByCreatedAtAsc(); + } + + @Test + void getByIdReturnsTheConcept() { + long id = 42L; + int version = 7; + String name = "Diffraction"; + String description = "The spreading of waves through a gap or around an obstacle."; + Instant createdAt = Instant.parse("2026-06-29T10:15:30Z"); + + when(conceptRepository.findById(id)) + .thenReturn(Optional.of(conceptEntity(id, version, name, description, createdAt))); + + Optional concept = conceptService.getById(id); + + assertThat(concept.isPresent(), is(true)); + assertThat(concept.get(), equalTo(new Concept(id, version, name, description, createdAt))); + + verify(conceptRepository).findById(id); + } + + @Test + void getByIdReturnsEmptyWhenNoConceptHasThatId() { + long id = 42L; + when(conceptRepository.findById(id)).thenReturn(Optional.empty()); + + Optional concept = conceptService.getById(id); + assertThat(concept.isPresent(), is(false)); + + verify(conceptRepository).findById(id); + } + + // A real ConceptEntity rather than a mock: id, version and createdAt are DB-assigned in production, so + // they're set by reflection. A mocked entity would answer whatever was stubbed for whichever getter the + // mapper happens to call, so it can't catch the mapper reading the wrong field. + private static ConceptEntity conceptEntity( + long id, int version, String name, String description, Instant createdAt) { + ConceptEntity conceptEntity = new ConceptEntity(name, description); + setField(conceptEntity, "id", id); + setField(conceptEntity, "version", version); + setField(conceptEntity, "createdAt", createdAt); + return conceptEntity; + } +} diff --git a/src/test/java/com/practiq/service/concept/mapper/ConceptMapperTest.java b/src/test/java/com/practiq/service/concept/mapper/ConceptMapperTest.java new file mode 100644 index 0000000..5417fa8 --- /dev/null +++ b/src/test/java/com/practiq/service/concept/mapper/ConceptMapperTest.java @@ -0,0 +1,38 @@ +package com.practiq.service.concept.mapper; + +import static com.practiq.service.concept.mapper.ConceptMapper.toConcept; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static utils.TestReflection.setField; + +import com.practiq.persistence.ConceptEntity; +import com.practiq.service.concept.dto.response.Concept; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ConceptMapperTest { + + @Test + void conceptEntityMapsToConcept() { + long id = 1L; + int version = 4; + String name = "Diffraction"; + String description = "The spreading of waves through a gap or around an obstacle."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + ConceptEntity conceptEntity = new ConceptEntity(name, description); + setField(conceptEntity, "id", id); + setField(conceptEntity, "version", version); + setField(conceptEntity, "createdAt", createdAt); + + Concept concept = toConcept(conceptEntity); + + assertThat(concept.id(), equalTo(id)); + // The service model carries the lock token even though no web response exposes it: the caller of a + // service method is not necessarily the web layer. + assertThat(concept.version(), equalTo(version)); + assertThat(concept.name(), equalTo(name)); + assertThat(concept.description(), equalTo(description)); + assertThat(concept.createdAt(), equalTo(createdAt)); + } +} diff --git a/src/test/java/com/practiq/service/document/DocumentPresignUploadCommandTest.java b/src/test/java/com/practiq/service/document/DocumentPresignUploadCommandTest.java new file mode 100644 index 0000000..e8b8ac2 --- /dev/null +++ b/src/test/java/com/practiq/service/document/DocumentPresignUploadCommandTest.java @@ -0,0 +1,130 @@ +package com.practiq.service.document; + +import static org.junit.jupiter.api.Assertions.*; +import static utils.data.TestData.DOCUMENT_FILENAME_MAX_LENGTH; +import static utils.data.TestData.DOCUMENT_SOURCE_SPEC_MAX_LENGTH; + +import com.practiq.service.document.dto.request.DocumentPresignUploadCommand; +import org.junit.jupiter.api.Test; + +class DocumentPresignUploadCommandTest { + + @Test + void aCommandHoldsTheValuesItWasBuiltWith() { + String filename = "physics-notes.pdf"; + String contentType = "application/pdf"; + String sourceSpec = "AQA GCSE Physics"; + int contentLength = 2048; + + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand(filename, contentType, contentLength, sourceSpec); + + assertEquals(filename, command.filename()); + assertEquals(contentType, command.contentType()); + assertEquals(contentLength, command.contentLength()); + assertEquals(sourceSpec, command.sourceSpec()); + } + + @Test + void sourceSpecIsOptional() { + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("physics-notes.pdf", "application/pdf", 2048, null); + + assertNull(command.sourceSpec()); + } + + @Test + void aCommandCannotBeBuiltWithoutAFilename() { + IllegalArgumentException nullFilename = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand(null, "application/pdf", 2048, "AQA GCSE Physics")); + + assertEquals("filename must not be blank", nullFilename.getMessage()); + + IllegalArgumentException emptyFilename = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("", "application/pdf", 2048, "AQA GCSE Physics")); + + assertEquals("filename must not be blank", emptyFilename.getMessage()); + + IllegalArgumentException whitespaceFilename = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand(" ", "application/pdf", 2048, "AQA GCSE Physics")); + + assertEquals("filename must not be blank", whitespaceFilename.getMessage()); + } + + @Test + void aCommandCannotBeBuiltWithoutAContentType() { + IllegalArgumentException nullContentType = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("physics-notes.pdf", null, 2048, "AQA GCSE Physics")); + + assertEquals("contentType must not be blank", nullContentType.getMessage()); + + IllegalArgumentException emptyContentType = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("physics-notes.pdf", "", 2048, "AQA GCSE Physics")); + + assertEquals("contentType must not be blank", emptyContentType.getMessage()); + + IllegalArgumentException whitespaceContentType = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("physics-notes.pdf", " ", 2048, "AQA GCSE Physics")); + + assertEquals("contentType must not be blank", whitespaceContentType.getMessage()); + } + + @Test + void aCommandTakesAFilenameAtTheMaximumLengthButNotOneAbove() { + String atTheMaximum = "a".repeat(DOCUMENT_FILENAME_MAX_LENGTH - ".pdf".length()) + ".pdf"; + + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand(atTheMaximum, "application/pdf", 2048, "AQA GCSE Physics"); + + assertEquals(DOCUMENT_FILENAME_MAX_LENGTH, command.filename().length()); + assertEquals(atTheMaximum, command.filename()); + + String oneAbove = "a".repeat(DOCUMENT_FILENAME_MAX_LENGTH + 1 - ".pdf".length()) + ".pdf"; + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand(oneAbove, "application/pdf", 2048, "AQA GCSE Physics")); + + assertEquals("filename cannot exceed max length " + DOCUMENT_FILENAME_MAX_LENGTH, exception.getMessage()); + } + + @Test + void aCommandTakesASourceSpecAtTheMaximumLengthButNotOneAbove() { + String atTheMaximum = "a".repeat(DOCUMENT_SOURCE_SPEC_MAX_LENGTH); + + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("physics-notes.pdf", "application/pdf", 2048, atTheMaximum); + + assertEquals(DOCUMENT_SOURCE_SPEC_MAX_LENGTH, command.sourceSpec().length()); + assertEquals(atTheMaximum, command.sourceSpec()); + + String oneAbove = "a".repeat(DOCUMENT_SOURCE_SPEC_MAX_LENGTH + 1); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("physics-notes.pdf", "application/pdf", 2048, oneAbove)); + + assertEquals("sourceSpec cannot exceed max length " + DOCUMENT_SOURCE_SPEC_MAX_LENGTH, exception.getMessage()); + } + + @Test + void aCommandCannotBeBuiltWithAContentLengthBelowOne() { + IllegalArgumentException zero = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("physics-notes.pdf", "application/pdf", 0, "AQA GCSE Physics")); + + assertEquals("contentLength must be greater than or equal to 1", zero.getMessage()); + + IllegalArgumentException negative = assertThrows( + IllegalArgumentException.class, + () -> new DocumentPresignUploadCommand("physics-notes.pdf", "application/pdf", -1, "AQA GCSE Physics")); + + assertEquals("contentLength must be greater than or equal to 1", negative.getMessage()); + } +} diff --git a/src/test/java/com/practiq/service/DocumentServiceTest.java b/src/test/java/com/practiq/service/document/DocumentServiceTest.java similarity index 61% rename from src/test/java/com/practiq/service/DocumentServiceTest.java rename to src/test/java/com/practiq/service/document/DocumentServiceTest.java index 9876423..39d15c1 100644 --- a/src/test/java/com/practiq/service/DocumentServiceTest.java +++ b/src/test/java/com/practiq/service/document/DocumentServiceTest.java @@ -1,20 +1,15 @@ -package com.practiq.service; +package com.practiq.service.document; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; -import static utils.data.TestData.UPLOAD_URL_EXPIRY; +import static org.mockito.Mockito.*; +import static utils.data.TestData.DOCUMENT_UPLOAD_URL_EXPIRY; -import com.practiq.domain.Document; -import com.practiq.domain.types.DocumentStatus; -import com.practiq.dto.request.PostDocumentRequest; -import com.practiq.dto.response.UploadDocumentResponse; -import com.practiq.repository.DocumentRepository; -import com.practiq.service.document.DocumentStager; -import com.practiq.service.document.DocumentUploadCommand; -import com.practiq.service.document.StagedDocumentUpload; +import com.practiq.foundation.types.DocumentStatus; +import com.practiq.persistence.DocumentEntity; +import com.practiq.persistence.repository.DocumentRepository; +import com.practiq.service.document.dto.request.DocumentPresignUploadCommand; +import com.practiq.service.document.dto.response.DocumentPresignUpload; import com.practiq.storage.PresignedUpload; import com.practiq.storage.PresignedUploadRequest; import com.practiq.storage.S3DocumentStorage; @@ -35,16 +30,15 @@ class DocumentServiceTest { new DocumentService(documentStager, documentRepository, documentStorage); @Test - void stageDocumentUploadStagesTheRequestSavesTheDocumentAndReturnsThePresignedUrl() { + void stageDocumentUploadStagesTheRequestSavesTheDocumentAndReturnsThePresignedUrlAndGeneratePresign() { String filename = "physics-notes.pdf"; String contentType = "application/pdf"; String sourceSpec = "AQA GCSE Physics"; int contentLength = 2048; long savedId = 99L; - PostDocumentRequest request = new PostDocumentRequest(filename, contentLength, contentType, sourceSpec); - DocumentUploadCommand expectedCommand = - new DocumentUploadCommand(filename, contentType, contentLength, sourceSpec); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand(filename, contentType, contentLength, sourceSpec); StagedDocumentUpload staged = new StagedDocumentUpload( "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", @@ -56,30 +50,30 @@ void stageDocumentUploadStagesTheRequestSavesTheDocumentAndReturnsThePresignedUr URI.create("https://documents.s3.example.com/" + staged.key() + "?X-Amz-Signature=abc"), Instant.parse("2026-08-12T18:10:00Z")); - when(documentStager.stageUpload(expectedCommand)).thenReturn(staged); + when(documentStager.stageUpload(command)).thenReturn(staged); PresignedUploadRequest expectedPresignRequest = new PresignedUploadRequest( - staged.key(), staged.contentType(), staged.contentLength(), UPLOAD_URL_EXPIRY); + staged.key(), staged.contentType(), staged.contentLength(), DOCUMENT_UPLOAD_URL_EXPIRY); when(documentStorage.presignUpload(expectedPresignRequest)).thenReturn(presignedUpload); - when(documentRepository.save(any(Document.class))) + when(documentRepository.save(any(DocumentEntity.class))) .thenAnswer(invocation -> TestReflection.setField(invocation.getArgument(0), "id", savedId)); - UploadDocumentResponse response = documentService.stageDocumentUpload(request); + DocumentPresignUpload presign = documentService.stageUploadAndPresign(command); - verify(documentStager).stageUpload(expectedCommand); + verify(documentStager).stageUpload(command); verify(documentStorage).presignUpload(expectedPresignRequest); - ArgumentCaptor savedDocument = ArgumentCaptor.forClass(Document.class); + ArgumentCaptor savedDocument = ArgumentCaptor.forClass(DocumentEntity.class); verify(documentRepository).save(savedDocument.capture()); - Document document = savedDocument.getValue(); + DocumentEntity document = savedDocument.getValue(); assertEquals(staged.key(), document.getS3Key()); assertEquals(staged.filename(), document.getFilename()); assertEquals(staged.sourceSpec(), document.getSourceSpec()); assertEquals(DocumentStatus.AWAITING_UPLOAD, document.getStatus()); assertEquals( - new UploadDocumentResponse(savedId, presignedUpload.url().toString(), presignedUpload.expiresAt()), - response); + new DocumentPresignUpload(savedId, presignedUpload.url().toString(), presignedUpload.expiresAt()), + presign); } } diff --git a/src/test/java/com/practiq/service/document/DocumentStagerTest.java b/src/test/java/com/practiq/service/document/DocumentStagerTest.java index 9fa73a0..b2f2fb6 100644 --- a/src/test/java/com/practiq/service/document/DocumentStagerTest.java +++ b/src/test/java/com/practiq/service/document/DocumentStagerTest.java @@ -1,13 +1,12 @@ package com.practiq.service.document; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static utils.data.TestData.MAX_UPLOAD_CONTENT_LENGTH; - -import com.practiq.exception.ContentTooLargeException; -import com.practiq.exception.EntityValidationError; -import com.practiq.exception.EntityValidationException; +import static org.junit.jupiter.api.Assertions.*; +import static utils.data.TestData.DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH; + +import com.practiq.foundation.exception.ContentTooLargeException; +import com.practiq.foundation.exception.EntityValidationError; +import com.practiq.foundation.exception.EntityValidationException; +import com.practiq.service.document.dto.request.DocumentPresignUploadCommand; import io.micronaut.http.MediaType; import org.junit.jupiter.api.Test; import utils.RegexUtils; @@ -22,8 +21,8 @@ void stageUploadStagesTheUpload() { String sourceSpec = "AQA GCSE Physics"; int contentLength = 2048; - DocumentUploadCommand command = - new DocumentUploadCommand(filename, "application/pdf", contentLength, sourceSpec); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand(filename, "application/pdf", contentLength, sourceSpec); StagedDocumentUpload staged = stager.stageUpload(command); @@ -38,10 +37,10 @@ void stageUploadStagesTheUpload() { void stageUploadStagesTheUploadWhenContentLengthIsAtTheMaximum() { String filename = "physics-notes.pdf"; String sourceSpec = "AQA GCSE Physics"; - int contentLength = MAX_UPLOAD_CONTENT_LENGTH; + int contentLength = DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH; - DocumentUploadCommand command = - new DocumentUploadCommand(filename, "application/pdf", contentLength, sourceSpec); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand(filename, "application/pdf", contentLength, sourceSpec); StagedDocumentUpload staged = stager.stageUpload(command); @@ -54,16 +53,17 @@ void stageUploadStagesTheUploadWhenContentLengthIsAtTheMaximum() { @Test void stageUploadThrowsWhenContentLengthIsOneOverTheMaximum() { - int contentLength = MAX_UPLOAD_CONTENT_LENGTH + 1; + int contentLength = DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH + 1; - DocumentUploadCommand command = - new DocumentUploadCommand("physics-notes.pdf", "application/pdf", contentLength, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = new DocumentPresignUploadCommand( + "physics-notes.pdf", "application/pdf", contentLength, "AQA GCSE Physics"); ContentTooLargeException exception = assertThrows(ContentTooLargeException.class, () -> stager.stageUpload(command)); assertEquals( - new EntityValidationError("contentLength", "must not be greater than " + MAX_UPLOAD_CONTENT_LENGTH), + new EntityValidationError( + "contentLength", "must not be greater than " + DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH), exception.error()); } @@ -71,8 +71,8 @@ void stageUploadThrowsWhenContentLengthIsOneOverTheMaximum() { void stageUploadThrowsWhenContentTypeCannotBeParsed() { String contentType = "pdf"; - DocumentUploadCommand command = - new DocumentUploadCommand("physics-notes.pdf", contentType, 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("physics-notes.pdf", contentType, 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -86,8 +86,8 @@ void stageUploadThrowsWhenContentTypeCannotBeParsed() { void stageUploadThrowsWhenContentTypeIsNotRecognised() { String contentType = "application/x-not-real"; - DocumentUploadCommand command = - new DocumentUploadCommand("physics-notes.pdf", contentType, 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("physics-notes.pdf", contentType, 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -101,7 +101,8 @@ void stageUploadThrowsWhenContentTypeIsNotRecognised() { void stageUploadThrowsWhenContentTypeIsRecognisedButNotAccepted() { String contentType = "application/zip"; - DocumentUploadCommand command = new DocumentUploadCommand("archive.zip", contentType, 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("archive.zip", contentType, 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -115,7 +116,8 @@ void stageUploadThrowsWhenContentTypeIsRecognisedButNotAccepted() { void stageUploadAcceptsAContentTypeThatOnlyMatchesOnceLowercased() { String filename = "diagram.PNG"; - DocumentUploadCommand command = new DocumentUploadCommand(filename, "IMAGE/PNG", 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand(filename, "IMAGE/PNG", 1024, "AQA GCSE Physics"); StagedDocumentUpload staged = stager.stageUpload(command); @@ -126,8 +128,8 @@ void stageUploadAcceptsAContentTypeThatOnlyMatchesOnceLowercased() { @Test void stageUploadThrowsWhenFilenameHasNoExtensionSeparator() { - DocumentUploadCommand command = - new DocumentUploadCommand("physics-notes", "application/pdf", 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("physics-notes", "application/pdf", 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -137,8 +139,8 @@ void stageUploadThrowsWhenFilenameHasNoExtensionSeparator() { @Test void stageUploadThrowsWhenFilenameEndsWithTheExtensionSeparator() { - DocumentUploadCommand command = - new DocumentUploadCommand("physics-notes.", "application/pdf", 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("physics-notes.", "application/pdf", 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -150,8 +152,8 @@ void stageUploadThrowsWhenFilenameEndsWithTheExtensionSeparator() { void stageUploadThrowsWhenNoContentTypeIsKnownForTheExtension() { String extension = "dat"; - DocumentUploadCommand command = - new DocumentUploadCommand("physics-notes." + extension, "application/pdf", 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = new DocumentPresignUploadCommand( + "physics-notes." + extension, "application/pdf", 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -165,8 +167,8 @@ void stageUploadThrowsWhenNoContentTypeIsKnownForTheExtension() { void stageUploadThrowsWhenTheExtensionsContentTypeIsNotAccepted() { String extension = "zip"; - DocumentUploadCommand command = - new DocumentUploadCommand("archive." + extension, "application/pdf", 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("archive." + extension, "application/pdf", 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); @@ -181,8 +183,8 @@ void stageUploadThrowsWhenTheDeclaredAndDerivedContentTypesAreBothAcceptedButDif String contentType = "application/pdf"; String extension = "png"; - DocumentUploadCommand command = - new DocumentUploadCommand("diagram." + extension, contentType, 1024, "AQA GCSE Physics"); + DocumentPresignUploadCommand command = + new DocumentPresignUploadCommand("diagram." + extension, contentType, 1024, "AQA GCSE Physics"); EntityValidationException exception = assertThrows(EntityValidationException.class, () -> stager.stageUpload(command)); diff --git a/src/test/java/com/practiq/service/document/DocumentUploadCommandTest.java b/src/test/java/com/practiq/service/document/DocumentUploadCommandTest.java deleted file mode 100644 index 155e2f1..0000000 --- a/src/test/java/com/practiq/service/document/DocumentUploadCommandTest.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.practiq.service.document; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import org.junit.jupiter.api.Test; - -class DocumentUploadCommandTest { - - @Test - void aCommandHoldsTheValuesItWasBuiltWith() { - String filename = "physics-notes.pdf"; - String contentType = "application/pdf"; - String sourceSpec = "AQA GCSE Physics"; - int contentLength = 2048; - - DocumentUploadCommand command = new DocumentUploadCommand(filename, contentType, contentLength, sourceSpec); - - assertEquals(filename, command.filename()); - assertEquals(contentType, command.contentType()); - assertEquals(contentLength, command.contentLength()); - assertEquals(sourceSpec, command.sourceSpec()); - } - - @Test - void sourceSpecIsOptional() { - DocumentUploadCommand command = new DocumentUploadCommand("physics-notes.pdf", "application/pdf", 2048, null); - - assertNull(command.sourceSpec()); - } - - @Test - void aCommandCannotBeBuiltWithoutAFilename() { - IllegalArgumentException nullFilename = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand(null, "application/pdf", 2048, "AQA GCSE Physics")); - - assertEquals("filename must not be blank", nullFilename.getMessage()); - - IllegalArgumentException emptyFilename = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("", "application/pdf", 2048, "AQA GCSE Physics")); - - assertEquals("filename must not be blank", emptyFilename.getMessage()); - - IllegalArgumentException whitespaceFilename = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand(" ", "application/pdf", 2048, "AQA GCSE Physics")); - - assertEquals("filename must not be blank", whitespaceFilename.getMessage()); - } - - @Test - void aCommandCannotBeBuiltWithoutAContentType() { - IllegalArgumentException nullContentType = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("physics-notes.pdf", null, 2048, "AQA GCSE Physics")); - - assertEquals("contentType must not be blank", nullContentType.getMessage()); - - IllegalArgumentException emptyContentType = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("physics-notes.pdf", "", 2048, "AQA GCSE Physics")); - - assertEquals("contentType must not be blank", emptyContentType.getMessage()); - - IllegalArgumentException whitespaceContentType = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("physics-notes.pdf", " ", 2048, "AQA GCSE Physics")); - - assertEquals("contentType must not be blank", whitespaceContentType.getMessage()); - } - - @Test - void aCommandCannotBeBuiltWithoutAContentLength() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("physics-notes.pdf", "application/pdf", null, "AQA GCSE Physics")); - - assertEquals("contentLength must not be null", exception.getMessage()); - } - - @Test - void aCommandCannotBeBuiltWithAContentLengthBelowOne() { - IllegalArgumentException zero = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("physics-notes.pdf", "application/pdf", 0, "AQA GCSE Physics")); - - assertEquals("contentLength must be greater than or equal to 1", zero.getMessage()); - - IllegalArgumentException negative = assertThrows( - IllegalArgumentException.class, - () -> new DocumentUploadCommand("physics-notes.pdf", "application/pdf", -1, "AQA GCSE Physics")); - - assertEquals("contentLength must be greater than or equal to 1", negative.getMessage()); - } -} diff --git a/src/test/java/com/practiq/service/document/StagedDocumentUploadTest.java b/src/test/java/com/practiq/service/document/StagedDocumentUploadTest.java deleted file mode 100644 index 99e11f2..0000000 --- a/src/test/java/com/practiq/service/document/StagedDocumentUploadTest.java +++ /dev/null @@ -1,115 +0,0 @@ -package com.practiq.service.document; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import io.micronaut.http.MediaType; -import org.junit.jupiter.api.Test; - -class StagedDocumentUploadTest { - - @Test - void aStagedUploadHoldsTheValuesItWasBuiltWith() { - String key = "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf"; - String filename = "physics-notes.pdf"; - String sourceSpec = "AQA GCSE Physics"; - int contentLength = 2048; - - StagedDocumentUpload staged = - new StagedDocumentUpload(key, filename, sourceSpec, MediaType.APPLICATION_PDF_TYPE, contentLength); - - assertEquals(key, staged.key()); - assertEquals(filename, staged.filename()); - assertEquals(sourceSpec, staged.sourceSpec()); - assertEquals(MediaType.APPLICATION_PDF_TYPE, staged.contentType()); - assertEquals(contentLength, staged.contentLength()); - } - - @Test - void sourceSpecIsOptional() { - StagedDocumentUpload staged = new StagedDocumentUpload( - "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", - "physics-notes.pdf", - null, - MediaType.APPLICATION_PDF_TYPE, - 2048); - - assertNull(staged.sourceSpec()); - } - - @Test - void aStagedUploadCannotBeBuiltWithoutAKey() { - IllegalArgumentException nullKey = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - null, "physics-notes.pdf", "AQA GCSE Physics", MediaType.APPLICATION_PDF_TYPE, 2048)); - assertEquals("key must not be blank", nullKey.getMessage()); - - IllegalArgumentException blankKey = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - " ", "physics-notes.pdf", "AQA GCSE Physics", MediaType.APPLICATION_PDF_TYPE, 2048)); - assertEquals("key must not be blank", blankKey.getMessage()); - } - - @Test - void aStagedUploadCannotBeBuiltWithoutAFilename() { - IllegalArgumentException nullFilename = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", - null, - "AQA GCSE Physics", - MediaType.APPLICATION_PDF_TYPE, - 2048)); - assertEquals("filename must not be blank", nullFilename.getMessage()); - - IllegalArgumentException blankFilename = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", - " ", - "AQA GCSE Physics", - MediaType.APPLICATION_PDF_TYPE, - 2048)); - assertEquals("filename must not be blank", blankFilename.getMessage()); - } - - @Test - void aStagedUploadCannotBeBuiltWithoutAContentType() { - IllegalArgumentException exception = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", - "physics-notes.pdf", - "AQA GCSE Physics", - null, - 2048)); - - assertEquals("contentType must not be null", exception.getMessage()); - } - - @Test - void aStagedUploadCannotBeBuiltWithAContentLengthBelowOne() { - IllegalArgumentException zero = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", - "physics-notes.pdf", - "AQA GCSE Physics", - MediaType.APPLICATION_PDF_TYPE, - 0)); - assertEquals("contentLength must be greater than or equal to 1", zero.getMessage()); - - IllegalArgumentException negative = assertThrows( - IllegalArgumentException.class, - () -> new StagedDocumentUpload( - "cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf", - "physics-notes.pdf", - "AQA GCSE Physics", - MediaType.APPLICATION_PDF_TYPE, - -1)); - assertEquals("contentLength must be greater than or equal to 1", negative.getMessage()); - } -} diff --git a/src/test/java/com/practiq/service/MarkSchemeServiceTest.java b/src/test/java/com/practiq/service/markscheme/MarkSchemeServiceTest.java similarity index 50% rename from src/test/java/com/practiq/service/MarkSchemeServiceTest.java rename to src/test/java/com/practiq/service/markscheme/MarkSchemeServiceTest.java index 80d20d5..5e2e802 100644 --- a/src/test/java/com/practiq/service/MarkSchemeServiceTest.java +++ b/src/test/java/com/practiq/service/markscheme/MarkSchemeServiceTest.java @@ -1,16 +1,14 @@ -package com.practiq.service; +package com.practiq.service.markscheme; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; import static utils.TestReflection.setField; -import com.practiq.domain.MarkScheme; -import com.practiq.domain.query.question.StudentQuestionQueryRunner; -import com.practiq.dto.response.MarkSchemeResponse; -import com.practiq.repository.MarkSchemeRepository; +import com.practiq.persistence.MarkSchemeEntity; +import com.practiq.persistence.repository.MarkSchemeRepository; +import com.practiq.service.markscheme.dto.response.MarkScheme; +import com.practiq.service.question.QuestionAccessor; import java.time.Instant; import java.util.Optional; import org.junit.jupiter.api.Test; @@ -19,14 +17,14 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -// A thin layer over the StudentQuestionQueryRunner visibility gate and MarkSchemeRepository. Both are mocked so +// A thin layer over the QuestionAccessor visibility gate and MarkSchemeRepository. Both are mocked so // these tests pin the orchestration: the question must be student-visible before the mark scheme is even // looked up, and a missing scheme is an empty result, not an error. @ExtendWith(MockitoExtension.class) class MarkSchemeServiceTest { @Mock - private StudentQuestionQueryRunner questionQueryRunner; + private QuestionAccessor questions; @Mock private MarkSchemeRepository markSchemeRepository; @@ -38,15 +36,15 @@ class MarkSchemeServiceTest { void getForQuestionIdReturnsEmptyWhenQuestionDoesNotExistForQuery() { long questionId = 10L; - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(false); + when(questions.exists(questionId)).thenReturn(false); - Optional result = markSchemeService.getForQuestionId(questionId); + Optional result = markSchemeService.getForQuestionId(questionId); assertThat(result.isPresent(), equalTo(false)); // The visibility gate short-circuits: an invisible question never reaches the mark-scheme lookup, // so its existence can't leak through a different not-found cause. - verify(questionQueryRunner).doesQuestionExistForId(questionId); + verify(questions).exists(questionId); verifyNoInteractions(markSchemeRepository); } @@ -54,41 +52,41 @@ void getForQuestionIdReturnsEmptyWhenQuestionDoesNotExistForQuery() { void getForQuestionIdReturnsEmptyWhenQuestionVisibleButHasNoMarkScheme() { long questionId = 10L; - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(true); + when(questions.exists(questionId)).thenReturn(true); when(markSchemeRepository.findByQuestionId(questionId)).thenReturn(Optional.empty()); - Optional result = markSchemeService.getForQuestionId(questionId); + Optional result = markSchemeService.getForQuestionId(questionId); assertThat(result.isPresent(), equalTo(false)); - verify(questionQueryRunner).doesQuestionExistForId(questionId); + verify(questions).exists(questionId); verify(markSchemeRepository).findByQuestionId(questionId); } @Test - void getForQuestionIdReturnsMappedResponseWhenQuestionVisibleAndMarkSchemeExists() { + void getForQuestionIdReturnsMappedMarkSchemeWhenQuestionVisibleAndMarkSchemeExists() { long questionId = 10L; long markSchemeId = 1L; + int version = 5; String body = "Award 1 mark for stating the wave bends around the edge of the gap."; Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - MarkScheme markScheme = new MarkScheme(questionId, body); - setField(markScheme, "id", markSchemeId); - setField(markScheme, "createdAt", createdAt); + MarkSchemeEntity markSchemeEntity = new MarkSchemeEntity(questionId, body); + setField(markSchemeEntity, "id", markSchemeId); + setField(markSchemeEntity, "version", version); + setField(markSchemeEntity, "createdAt", createdAt); - when(questionQueryRunner.doesQuestionExistForId(questionId)).thenReturn(true); - when(markSchemeRepository.findByQuestionId(questionId)).thenReturn(Optional.of(markScheme)); + when(questions.exists(questionId)).thenReturn(true); + when(markSchemeRepository.findByQuestionId(questionId)).thenReturn(Optional.of(markSchemeEntity)); - Optional result = markSchemeService.getForQuestionId(questionId); + Optional result = markSchemeService.getForQuestionId(questionId); assertThat(result.isPresent(), equalTo(true)); - MarkSchemeResponse response = result.get(); - assertThat(response.getId(), equalTo(markSchemeId)); - assertThat(response.getQuestionId(), equalTo(questionId)); - assertThat(response.getBody(), equalTo(body)); - assertThat(response.getCreatedAt(), equalTo(createdAt)); + // Built here rather than through MarkSchemeMapper: an expected value produced by the code under test + // moves with it and can never fail. + assertThat(result.get(), equalTo(new MarkScheme(markSchemeId, version, questionId, body, createdAt))); - verify(questionQueryRunner).doesQuestionExistForId(questionId); + verify(questions).exists(questionId); verify(markSchemeRepository).findByQuestionId(questionId); } } diff --git a/src/test/java/com/practiq/service/markscheme/mapper/MarkSchemeMapperTest.java b/src/test/java/com/practiq/service/markscheme/mapper/MarkSchemeMapperTest.java new file mode 100644 index 0000000..cb4df6e --- /dev/null +++ b/src/test/java/com/practiq/service/markscheme/mapper/MarkSchemeMapperTest.java @@ -0,0 +1,38 @@ +package com.practiq.service.markscheme.mapper; + +import static com.practiq.service.markscheme.mapper.MarkSchemeMapper.toMarkScheme; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static utils.TestReflection.setField; + +import com.practiq.persistence.MarkSchemeEntity; +import com.practiq.service.markscheme.dto.response.MarkScheme; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class MarkSchemeMapperTest { + + @Test + void markSchemeEntityMapsToMarkScheme() { + long id = 1L; + int version = 6; + long questionId = 10L; + String body = "Award 1 mark for stating the wave bends around the edge of the gap."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + MarkSchemeEntity markSchemeEntity = new MarkSchemeEntity(questionId, body); + setField(markSchemeEntity, "id", id); + setField(markSchemeEntity, "version", version); + setField(markSchemeEntity, "createdAt", createdAt); + + MarkScheme markScheme = toMarkScheme(markSchemeEntity); + + assertThat(markScheme.id(), equalTo(id)); + // The service model carries the lock token even though no web response exposes it: the caller of a + // service method is not necessarily the web layer. + assertThat(markScheme.version(), equalTo(version)); + assertThat(markScheme.questionId(), equalTo(questionId)); + assertThat(markScheme.body(), equalTo(body)); + assertThat(markScheme.createdAt(), equalTo(createdAt)); + } +} diff --git a/src/test/java/com/practiq/service/question/QuestionAccessorTest.java b/src/test/java/com/practiq/service/question/QuestionAccessorTest.java new file mode 100644 index 0000000..2dfeee7 --- /dev/null +++ b/src/test/java/com/practiq/service/question/QuestionAccessorTest.java @@ -0,0 +1,75 @@ +package com.practiq.service.question; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.query.question.QuestionQuery; +import com.practiq.persistence.query.question.QuestionQueryRunner; +import com.practiq.persistence.query.question.QuestionWithConceptIds; +import com.practiq.service.question.policy.QuestionQueryPolicy; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +// The cardinality guarantee lives here and nowhere else: the runner cannot know a query matches at most one +// row, and no higher tier can provoke a breach because a primary key makes it unreachable end to end. +class QuestionAccessorTest { + + private final QuestionQueryRunner runner = mock(QuestionQueryRunner.class); + private final QuestionQueryPolicy policy = mock(QuestionQueryPolicy.class); + private final QuestionAccessor accessor = new QuestionAccessor(runner, policy); + + @Test + void findByIdThrowsWhenTheQueryMatchedMoreThanOneQuestion() { + long id = 17L; + QuestionQuery query = QuestionQuery.builder().questionId(id).build(); + when(policy.forId(id)).thenReturn(query); + when(runner.findAll(query)).thenReturn(List.of(questionWithConceptIds(1L), questionWithConceptIds(2L))); + + // Loud rather than returning the first of several: a policy that stopped filtering by id would + // otherwise serve an arbitrary question under someone else's id. + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> accessor.findById(id)); + + assertEquals("Expected at most one question for id 17, got 2", thrown.getMessage()); + } + + @Test + void findByIdReturnsTheSingleMatch() { + long id = 17L; + QuestionQuery query = QuestionQuery.builder().questionId(id).build(); + QuestionWithConceptIds found = questionWithConceptIds(id); + when(policy.forId(id)).thenReturn(query); + when(runner.findAll(query)).thenReturn(List.of(found)); + + assertThat(accessor.findById(id).orElseThrow(), is(found)); + } + + @Test + void findByIdReturnsEmptyWhenNothingMatched() { + long id = 17L; + QuestionQuery query = QuestionQuery.builder().questionId(id).build(); + when(policy.forId(id)).thenReturn(query); + when(runner.findAll(query)).thenReturn(List.of()); + + assertThat(accessor.findById(id).isPresent(), is(false)); + } + + @Test + void existsAsksTheRunnerWithThePolicysQueryForThatId() { + long id = 17L; + QuestionQuery query = QuestionQuery.builder().questionId(id).build(); + when(policy.forId(id)).thenReturn(query); + when(runner.exists(query)).thenReturn(true); + + assertThat(accessor.exists(id), is(true)); + } + + private static QuestionWithConceptIds questionWithConceptIds(long id) { + return new QuestionWithConceptIds(new QuestionEntity("A question.", null, null, null), Set.of(id)); + } +} diff --git a/src/test/java/com/practiq/service/question/QuestionServiceAccessorCT.java b/src/test/java/com/practiq/service/question/QuestionServiceAccessorCT.java new file mode 100644 index 0000000..0268d83 --- /dev/null +++ b/src/test/java/com/practiq/service/question/QuestionServiceAccessorCT.java @@ -0,0 +1,94 @@ +package com.practiq.service.question; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.QuestionEntity_; +import com.practiq.persistence.repository.QuestionConceptRepository; +import com.practiq.persistence.repository.QuestionRepository; +import com.practiq.service.question.dto.request.QuestionSearchCriteria; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.data.repository.jpa.criteria.QuerySpecification; +import io.micronaut.test.annotation.MockBean; +import jakarta.inject.Inject; +import jakarta.persistence.criteria.Path; +import jakarta.persistence.criteria.Subquery; +import java.util.List; +import org.junit.jupiter.api.Test; +import utils.ComponentTest; +import utils.CriteriaProbe; + +// QuestionService is wired to a policy-bound accessor by qualifier alone. Get that wrong and every read +// silently widens — students receive PENDING questions with a 200 and a plausible body — so this proves the +// restrictions the injected accessor actually imposes, rather than that some accessor was called. +@ComponentTest +@SuppressWarnings("unchecked") +class QuestionServiceAccessorCT { + + @Inject + private QuestionService questionService; + + @Inject + private QuestionRepository questionRepository; + + @MockBean(QuestionRepository.class) + QuestionRepository questionRepository() { + return mock(QuestionRepository.class); + } + + @MockBean(QuestionConceptRepository.class) + QuestionConceptRepository questionConceptRepository() { + return mock(QuestionConceptRepository.class); + } + + @Test + void readingAQuestionByIdImposesTheStudentRestrictions() { + CriteriaProbe probe = studentProbe(); + + when(questionRepository.findAll(any(QuerySpecification.class))).thenAnswer(invocation -> { + probe.resolve(invocation.getArgument(0)); + return List.of(); + }); + + questionService.getById(17L); + + assertStudentRestrictions(probe); + } + + @Test + void readingTheCatalogueImposesTheStudentRestrictions() { + CriteriaProbe probe = studentProbe(); + + when(questionRepository.findAll(any(QuerySpecification.class), any(Pageable.class))) + .thenAnswer(invocation -> { + probe.resolve(invocation.getArgument(0)); + return Page.of(List.of(), Pageable.from(0), 0L); + }); + + questionService.get(new QuestionSearchCriteria(null, null, null), Pageable.from(0)); + + assertStudentRestrictions(probe); + } + + private CriteriaProbe studentProbe() { + CriteriaProbe probe = new CriteriaProbe<>(); + + Path statusPath = mock(Path.class); + when(probe.root().get(QuestionEntity_.status)).thenReturn(statusPath); + when(probe.criteriaQuery().subquery(Long.class)).thenReturn(mock(Subquery.class, RETURNS_DEEP_STUBS)); + + return probe; + } + + private void assertStudentRestrictions(CriteriaProbe probe) { + // APPROVED only, and only when a concept link exists — the two halves of the student serving policy. + verify(probe.criteriaBuilder()).equal(probe.root().get(QuestionEntity_.status), QuestionStatus.APPROVED); + verify(probe.criteriaQuery()).subquery(Long.class); + } +} diff --git a/src/test/java/com/practiq/service/question/dto/request/QuestionSearchCriteriaTest.java b/src/test/java/com/practiq/service/question/dto/request/QuestionSearchCriteriaTest.java new file mode 100644 index 0000000..e9e3e73 --- /dev/null +++ b/src/test/java/com/practiq/service/question/dto/request/QuestionSearchCriteriaTest.java @@ -0,0 +1,42 @@ +package com.practiq.service.question.dto.request; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionType; +import java.util.List; +import org.junit.jupiter.api.Test; + +class QuestionSearchCriteriaTest { + + @Test + void criteriaHoldTheValuesTheyWereBuiltWith() { + List types = List.of(QuestionType.MCQ); + List difficulties = List.of(QuestionDifficulty.HARD); + + QuestionSearchCriteria criteria = new QuestionSearchCriteria(types, difficulties, 42L); + + assertEquals(types, criteria.types()); + assertEquals(difficulties, criteria.difficulties()); + assertEquals(42L, criteria.conceptId()); + } + + @Test + void everyFilterIsOptional() { + QuestionSearchCriteria criteria = new QuestionSearchCriteria(null, null, null); + + assertNull(criteria.types()); + assertNull(criteria.difficulties()); + assertNull(criteria.conceptId()); + } + + @Test + void criteriaCannotBeBuiltWithAConceptIdBelowOne() { + assertEquals( + "conceptId must be greater than 0", + assertThrows(IllegalArgumentException.class, () -> new QuestionSearchCriteria(null, null, 0L)) + .getMessage()); + } +} diff --git a/src/test/java/com/practiq/service/question/mapper/QuestionMapperTest.java b/src/test/java/com/practiq/service/question/mapper/QuestionMapperTest.java new file mode 100644 index 0000000..0484540 --- /dev/null +++ b/src/test/java/com/practiq/service/question/mapper/QuestionMapperTest.java @@ -0,0 +1,46 @@ +package com.practiq.service.question.mapper; + +import static com.practiq.service.question.mapper.QuestionMapper.toQuestion; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static utils.TestReflection.setField; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.query.question.QuestionWithConceptIds; +import com.practiq.service.question.dto.response.Question; +import java.time.Instant; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class QuestionMapperTest { + + @Test + void questionWithConceptIdsMapsToQuestion() { + long id = 1L; + int version = 4; + String body = "Explain why a wave bends around an obstacle."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + QuestionEntity questionEntity = + new QuestionEntity(body, QuestionDifficulty.HARD, QuestionType.EXTENDED, QuestionStatus.APPROVED); + setField(questionEntity, "id", id); + setField(questionEntity, "version", version); + setField(questionEntity, "createdAt", createdAt); + + Question question = toQuestion(new QuestionWithConceptIds(questionEntity, Set.of(10L, 11L))); + + assertThat(question.id(), equalTo(id)); + // The service model carries the lock token and the status even though no web response exposes either. + assertThat(question.version(), equalTo(version)); + assertThat(question.status(), equalTo(QuestionStatus.APPROVED)); + assertThat(question.body(), equalTo(body)); + assertThat(question.difficulty(), equalTo(QuestionDifficulty.HARD)); + assertThat(question.type(), equalTo(QuestionType.EXTENDED)); + assertThat(question.createdAt(), equalTo(createdAt)); + assertThat(question.linkedConceptIds(), containsInAnyOrder(10L, 11L)); + } +} diff --git a/src/test/java/com/practiq/domain/query/question/StudentQuestionQueryPolicyTest.java b/src/test/java/com/practiq/service/question/policy/StudentQuestionQueryPolicyTest.java similarity index 85% rename from src/test/java/com/practiq/domain/query/question/StudentQuestionQueryPolicyTest.java rename to src/test/java/com/practiq/service/question/policy/StudentQuestionQueryPolicyTest.java index 2fac565..7414f33 100644 --- a/src/test/java/com/practiq/domain/query/question/StudentQuestionQueryPolicyTest.java +++ b/src/test/java/com/practiq/service/question/policy/StudentQuestionQueryPolicyTest.java @@ -1,13 +1,14 @@ -package com.practiq.domain.query.question; +package com.practiq.service.question.policy; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.contains; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.query.question.QuestionQuery; import java.util.List; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/practiq/http/AdminKeyValidatorTest.java b/src/test/java/com/practiq/web/AdminKeyValidatorTest.java similarity index 96% rename from src/test/java/com/practiq/http/AdminKeyValidatorTest.java rename to src/test/java/com/practiq/web/AdminKeyValidatorTest.java index 59dbdf5..e6f669a 100644 --- a/src/test/java/com/practiq/http/AdminKeyValidatorTest.java +++ b/src/test/java/com/practiq/web/AdminKeyValidatorTest.java @@ -1,10 +1,10 @@ -package com.practiq.http; +package com.practiq.web; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; -import com.practiq.exception.UnauthorizedException; +import com.practiq.foundation.exception.UnauthorizedException; import org.junit.jupiter.api.Test; class AdminKeyValidatorTest { diff --git a/src/test/java/com/practiq/web/binder/PageableConfigurationGuardTest.java b/src/test/java/com/practiq/web/binder/PageableConfigurationGuardTest.java new file mode 100644 index 0000000..e1574c7 --- /dev/null +++ b/src/test/java/com/practiq/web/binder/PageableConfigurationGuardTest.java @@ -0,0 +1,65 @@ +package com.practiq.web.binder; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import io.micronaut.context.ApplicationContext; +import java.util.Map; +import org.junit.jupiter.api.Test; + +// Raw contexts rather than a component test: the contract is that the application refuses to start, which +// no tier that requires a started application can observe. +class PageableConfigurationGuardTest { + + private static final String SORT_IGNORE_CASE = "micronaut.data.pageable.sort-ignore-case"; + private static final String SORT_DELIMITER = "micronaut.data.pageable.sort-delimiter"; + + @Test + void theApplicationStartsWhenNoInertSortPropertyIsSet() { + // The properties the binder does honour are set here too, so this proves the guard objects to the + // inert pair specifically rather than to any pageable configuration at all. + try (ApplicationContext context = startWith( + Map.of("micronaut.data.pageable.max-page-size", 50, "micronaut.data.pageable.default-page-size", 10))) { + assertDoesNotThrow(() -> context.getBean(PageableQueryBinder.class)); + } + } + + @Test + void theApplicationRefusesToStartWhenSortIgnoreCaseIsSet() { + Exception thrown = assertThrows(Exception.class, () -> startWith(Map.of(SORT_IGNORE_CASE, true))); + + assertThat(rootMessageOf(thrown), containsString(SORT_IGNORE_CASE + " is set but has no effect")); + } + + @Test + void theApplicationRefusesToStartWhenSortDelimiterIsSet() { + Exception thrown = assertThrows(Exception.class, () -> startWith(Map.of(SORT_DELIMITER, ";"))); + + assertThat(rootMessageOf(thrown), containsString(SORT_DELIMITER + " is set but has no effect")); + } + + @Test + void theApplicationRefusesToStartWhenSortDelimiterIsSetToTheValueItAlreadyHas() { + // Setting a knob to the value it already carries is still setting a knob that does nothing, and the + // reader of that config would still expect it to matter. + Exception thrown = assertThrows(Exception.class, () -> startWith(Map.of(SORT_DELIMITER, ","))); + + assertThat(rootMessageOf(thrown), containsString(SORT_DELIMITER + " is set but has no effect")); + } + + // ctslice is the environment that cuts persistence, so the context comes up without Flyway, a datasource + // or Docker — the guard is reached either way. + private ApplicationContext startWith(Map properties) { + return ApplicationContext.run(properties, "ctslice"); + } + + private String rootMessageOf(Throwable thrown) { + Throwable current = thrown; + while (current.getCause() != null) { + current = current.getCause(); + } + return current.getMessage(); + } +} diff --git a/src/test/java/com/practiq/web/binder/PageableTestController.java b/src/test/java/com/practiq/web/binder/PageableTestController.java new file mode 100644 index 0000000..50499e7 --- /dev/null +++ b/src/test/java/com/practiq/web/binder/PageableTestController.java @@ -0,0 +1,33 @@ +package com.practiq.web.binder; + +import com.practiq.web.dto.response.PageResponse; +import io.micronaut.context.annotation.Requires; +import io.micronaut.data.model.Page; +import io.micronaut.data.model.Pageable; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import java.util.List; + +// Test-only controller for PagingCT: builds a paged envelope from whatever Pageable the binder produced, so +// binding and the envelope can both be exercised without a repository, a service or a query runner in the +// way. Scoped to that test via the spec.name idiom. See the README testing section. +@Requires(property = "spec.name", value = "PagingCT") +@Controller("/test/paging") +class PageableTestController { + + private static final List ROWS = List.of("first", "second"); + private static final long TOTAL_COUNT = 7L; + + @Get + public PageResponse page(Pageable pageable) { + return PageResponse.of(Page.of(ROWS, pageable, TOTAL_COUNT), ROWS); + } + + // A separate endpoint rather than a query parameter: emptiness cannot be driven through the paging + // parameters without inventing a fake-only one, which would muddy the binder cases sharing this + // controller. + @Get("/empty") + public PageResponse emptyPage(Pageable pageable) { + return PageResponse.of(Page.of(List.of(), pageable, 0L), List.of()); + } +} diff --git a/src/test/java/com/practiq/web/binder/PagingCT.java b/src/test/java/com/practiq/web/binder/PagingCT.java new file mode 100644 index 0000000..59bc7e3 --- /dev/null +++ b/src/test/java/com/practiq/web/binder/PagingCT.java @@ -0,0 +1,192 @@ +package com.practiq.web.binder; + +import static io.micronaut.http.HttpStatus.*; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.equalTo; + +import io.micronaut.context.annotation.Property; +import io.micronaut.runtime.server.EmbeddedServer; +import io.restassured.RestAssured; +import io.restassured.http.ContentType; +import jakarta.inject.Inject; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import utils.ComponentTest; + +// How this app pages, proven once against a controller that exists for the purpose: which paging parameters +// are accepted and how the rest are refused, and what the envelope carrying a page looks like on the wire. +// Every paged endpoint then owes a single test that this binder is the one in use, rather than repeating +// these cases. +@ComponentTest +@Property(name = "spec.name", value = "PagingCT") +class PagingCT { + + private static final String PAGING_PATH = "/test/paging"; + + // Restated rather than read from configuration: these pin what application.yml says, they do not follow it. + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int MAX_PAGE_SIZE = 50; + + @Inject + private EmbeddedServer embeddedServer; + + @BeforeEach + void setUp() { + RestAssured.port = embeddedServer.getPort(); + } + + @Test + void bindsTheConfiguredDefaultsWhenNoPagingIsRequested() { + given().when() + .get(PAGING_PATH) + .then() + .statusCode(OK.getCode()) + .body("page", equalTo(0)) + .body("size", equalTo(DEFAULT_PAGE_SIZE)); + } + + @Test + void bindsTheRequestedPageAndSize() { + given().when() + .get(PAGING_PATH + "?page=2&size=20") + .then() + .statusCode(OK.getCode()) + .body("page", equalTo(2)) + .body("size", equalTo(20)); + } + + @Test + void bindsSizeAtTheMaximumButRejectsOneAbove() { + given().when() + .get(PAGING_PATH + "?size=" + MAX_PAGE_SIZE) + .then() + .statusCode(OK.getCode()) + .body("size", equalTo(MAX_PAGE_SIZE)); + + // Refused rather than clamped: the message is the only place a client can learn the ceiling, which a + // silently shortened page cannot tell it. + given().when() + .get(PAGING_PATH + "?size=" + (MAX_PAGE_SIZE + 1)) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("size: must not be greater than " + MAX_PAGE_SIZE)) + .body("status", equalTo(422)); + } + + @Test + void bindsPageZeroButRejectsANegativePage() { + given().when() + .get(PAGING_PATH + "?page=0") + .then() + .statusCode(OK.getCode()) + .body("page", equalTo(0)); + + for (String page : List.of("-1", "-4002")) { + given().when() + .get(PAGING_PATH + "?page=" + page) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("page: must be greater than or equal to 0")) + .body("status", equalTo(422)); + } + } + + @Test + void bindsSizeOfOneButRejectsAnythingBelowIt() { + given().when() + .get(PAGING_PATH + "?size=1") + .then() + .statusCode(OK.getCode()) + .body("size", equalTo(1)); + + for (String size : List.of("0", "-5")) { + given().when() + .get(PAGING_PATH + "?size=" + size) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("size: must be greater than or equal to 1")) + .body("status", equalTo(422)); + } + } + + @Test + void rejectsPagingValuesThatAreNotNumbers() { + // The same 400 a non-numeric filter parameter gets, so a client cannot tell binding a page from + // binding a conceptId by the shape of the failure. + for (String parameter : List.of("page", "size")) { + given().when() + .get(PAGING_PATH + "?" + parameter + "=abc") + .then() + .statusCode(BAD_REQUEST.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo(parameter + ": invalid value")) + .body("status", equalTo(400)); + } + } + + @Test + void rejectsSortBecauseNoEndpointOffersIt() { + // Answering 200 in a different order than asked for would be a silent lie; the runners impose their + // own total order, so no sort a client sends could ever be honoured. + given().when() + .get(PAGING_PATH + "?sort=createdAt,desc") + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("sort: is not supported")) + .body("status", equalTo(422)); + } + + @Test + void bindsAPageBeyondTheDataRatherThanRejectingIt() { + // Paging past the end is a valid request with an empty answer, not a bad value: a client walking + // pages must be able to ask for one it turns out not to reach. + given().when() + .get(PAGING_PATH + "?page=999") + .then() + .statusCode(OK.getCode()) + .body("page", equalTo(999)); + } + + @Test + void wrapsRowsInAnEnvelopeCarryingThePagingPosition() { + given().when() + .get(PAGING_PATH + "?page=1&size=2") + .then() + .statusCode(OK.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("content", "page", "size", "totalCount")) + .body("content", contains("first", "second")) + .body("page", equalTo(1)) + .body("size", equalTo(2)) + // The total across all pages, not the row count of this one — that is what lets a client + // render "page 2 of N" without walking to the end. + .body("totalCount", equalTo(7)); + } + + @Test + void keepsAnEmptyContentArrayInTheEnvelope() { + // Serde omits empty collections by default, which would drop the key entirely and force a client to + // treat absent and empty as the same thing. + given().when() + .get(PAGING_PATH + "/empty") + .then() + .statusCode(OK.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("content", "page", "size", "totalCount")) + .body("content", empty()) + .body("totalCount", equalTo(0)); + } +} diff --git a/src/test/java/com/practiq/controller/AdminDocumentControllerCT.java b/src/test/java/com/practiq/web/controller/AdminDocumentControllerCT.java similarity index 76% rename from src/test/java/com/practiq/controller/AdminDocumentControllerCT.java rename to src/test/java/com/practiq/web/controller/AdminDocumentControllerCT.java index a7f2369..ea7822b 100644 --- a/src/test/java/com/practiq/controller/AdminDocumentControllerCT.java +++ b/src/test/java/com/practiq/web/controller/AdminDocumentControllerCT.java @@ -1,4 +1,4 @@ -package com.practiq.controller; +package com.practiq.web.controller; import static io.micronaut.http.HttpStatus.*; import static io.restassured.RestAssured.given; @@ -6,14 +6,11 @@ import static org.hamcrest.Matchers.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static utils.data.TestData.ADMIN_KEY; -import static utils.data.TestData.ADMIN_KEY_HEADER; -import static utils.data.TestData.MAX_UPLOAD_CONTENT_LENGTH; -import static utils.data.TestData.UPLOAD_URL_EXPIRY; - -import com.practiq.domain.Document; -import com.practiq.domain.types.DocumentStatus; -import com.practiq.repository.DocumentRepository; +import static utils.data.TestData.*; + +import com.practiq.foundation.types.DocumentStatus; +import com.practiq.persistence.DocumentEntity; +import com.practiq.persistence.repository.DocumentRepository; import com.practiq.storage.PresignedUploadRequest; import com.practiq.storage.S3DocumentStorage; import io.micronaut.objectstorage.aws.AwsS3Operations; @@ -83,7 +80,7 @@ void postDocumentSavesAnAwaitingUploadDocumentAndReturnsAPresignedUrlForItsKey() requestBody.put("contentLength", 2048); requestBody.put("sourceSpec", sourceSpec); - when(documentRepository.save(any(Document.class))) + when(documentRepository.save(any(DocumentEntity.class))) .thenAnswer(invocation -> TestReflection.setField(invocation.getArgument(0), "id", savedId)); Response response = given().when() @@ -98,10 +95,10 @@ void postDocumentSavesAnAwaitingUploadDocumentAndReturnsAPresignedUrlForItsKey() .extract() .response(); - ArgumentCaptor savedDocument = ArgumentCaptor.forClass(Document.class); + ArgumentCaptor savedDocument = ArgumentCaptor.forClass(DocumentEntity.class); verify(documentRepository).save(savedDocument.capture()); - Document document = savedDocument.getValue(); + DocumentEntity document = savedDocument.getValue(); assertThat(document.getS3Key(), matchesPattern(RegexUtils.uuidWithExtension("txt"))); assertThat(document.getFilename(), equalTo(filename)); assertThat(document.getSourceSpec(), equalTo(sourceSpec)); @@ -123,7 +120,7 @@ void postDocumentSavesAnAwaitingUploadDocumentAndReturnsAPresignedUrlForItsKey() Instant expiresAt = Instant.parse(response.path("expiresAt")); assertThat(expiresAt.isAfter(Instant.now()), equalTo(true)); - assertThat(expiresAt.isBefore(Instant.now().plus(UPLOAD_URL_EXPIRY)), equalTo(true)); + assertThat(expiresAt.isBefore(Instant.now().plus(DOCUMENT_UPLOAD_URL_EXPIRY)), equalTo(true)); } @Test @@ -133,7 +130,7 @@ void postDocumentSavesADocumentWithoutASourceSpecWhenNoneIsSupplied() { requestBody.put("contentType", "text/plain"); requestBody.put("contentLength", 2048); - when(documentRepository.save(any(Document.class))) + when(documentRepository.save(any(DocumentEntity.class))) .thenAnswer(invocation -> TestReflection.setField(invocation.getArgument(0), "id", 99L)); given().when() @@ -146,7 +143,7 @@ void postDocumentSavesADocumentWithoutASourceSpecWhenNoneIsSupplied() { .contentType(ContentType.JSON) .body("keySet()", containsInAnyOrder("id", "url", "expiresAt")); - ArgumentCaptor savedDocument = ArgumentCaptor.forClass(Document.class); + ArgumentCaptor savedDocument = ArgumentCaptor.forClass(DocumentEntity.class); verify(documentRepository).save(savedDocument.capture()); assertThat(savedDocument.getValue().getSourceSpec(), nullValue()); @@ -171,12 +168,13 @@ void postDocumentErrorsAndSavesNothingWhenThePresignFails() { .body("status", equalTo(500)); // The presign runs before the save, so a failing presigner must leave no row behind. - verify(documentRepository, never()).save(any(Document.class)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test void postDocumentErrorsAndReturnsNoUrlWhenTheSaveFails() { - when(documentRepository.save(any(Document.class))).thenThrow(new IllegalStateException("database unavailable")); + when(documentRepository.save(any(DocumentEntity.class))) + .thenThrow(new IllegalStateException("database unavailable")); given().when() .body(aValidRequestBody()) @@ -192,9 +190,25 @@ void postDocumentErrorsAndReturnsNoUrlWhenTheSaveFails() { } @Test - void postDocumentErrorsIfContentLengthExceedsTheMaximum() { + void postDocumentAcceptsContentLengthAtTheMaximumAndErrorsOneAbove() { + when(documentRepository.save(any(DocumentEntity.class))) + .thenAnswer(invocation -> TestReflection.setField(invocation.getArgument(0), "id", 99L)); + + Map atTheMaximum = aValidRequestBody(); + atTheMaximum.put("contentLength", DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH); + + given().when() + .body(atTheMaximum) + .header(new Header(ADMIN_KEY_HEADER, ADMIN_KEY)) + .contentType(ContentType.JSON) + .post(ADMIN_DOCUMENTS_PATH) + .then() + .statusCode(CREATED.getCode()); + + verify(documentRepository).save(any(DocumentEntity.class)); + Map requestBody = aValidRequestBody(); - requestBody.put("contentLength", MAX_UPLOAD_CONTENT_LENGTH + 1); + requestBody.put("contentLength", DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH + 1); given().when() .body(requestBody) @@ -205,10 +219,11 @@ void postDocumentErrorsIfContentLengthExceedsTheMaximum() { .statusCode(REQUEST_ENTITY_TOO_LARGE.getCode()) .contentType(ContentType.JSON) .body("keySet()", containsInAnyOrder("error", "status")) - .body("error", equalTo("contentLength: must not be greater than " + MAX_UPLOAD_CONTENT_LENGTH)) + .body("error", equalTo("contentLength: must not be greater than " + DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH)) .body("status", equalTo(413)); - verify(documentRepository, never()).save(any(Document.class)); + // Still the one save from the accepted request above: one over the maximum wrote nothing. + verify(documentRepository, times(1)).save(any(DocumentEntity.class)); } @Test @@ -228,7 +243,7 @@ void postDocumentErrorsIfContentTypeDoesNotMatchTheFilenameExtension() { .body("error", equalTo("contentType: 'application/pdf' does not match the '.txt' file extension")) .body("status", equalTo(422)); - verify(documentRepository, never()).save(any(Document.class)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test @@ -246,7 +261,7 @@ void postDocumentErrorsIfAdminKeyOmitted() { .body("error", equalTo("Unauthorised to access " + ADMIN_DOCUMENTS_PATH)) .body("status", equalTo(401)); - verify(documentRepository, never()).save(any(Document.class)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test @@ -276,6 +291,7 @@ void postDocumentErrorsIfAdminKeyBlank() { .body("keySet()", containsInAnyOrder("error", "status")) .body("error", equalTo("Unauthorised to access " + ADMIN_DOCUMENTS_PATH)) .body("status", equalTo(401)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test @@ -291,6 +307,7 @@ void postDocumentErrorsIfAdminKeyMismatches() { .body("keySet()", containsInAnyOrder("error", "status")) .body("error", equalTo("Unauthorised to access " + ADMIN_DOCUMENTS_PATH)) .body("status", equalTo(401)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test @@ -336,6 +353,7 @@ void postDocumentErrorsIfFilenameIsBlank() { .body("keySet()", containsInAnyOrder("error", "status")) .body("error", equalTo("filename: must not be blank")) .body("status", equalTo(422)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test @@ -367,6 +385,7 @@ void postDocumentErrorsIfContentLengthIsMissingOrBelowOne() { .body("keySet()", containsInAnyOrder("error", "status")) .body("error", equalTo("contentLength: must be greater than or equal to 1")) .body("status", equalTo(422)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); } @Test @@ -412,15 +431,69 @@ void postDocumentErrorsIfContentTypeIsBlank() { .body("keySet()", containsInAnyOrder("error", "status")) .body("error", equalTo("contentType: must not be blank")) .body("status", equalTo(422)); + verify(documentRepository, never()).save(any(DocumentEntity.class)); + } + + @Test + void postDocumentAcceptsFilenameAtTheMaximumLengthAndErrorsOneAbove() { + when(documentRepository.save(any(DocumentEntity.class))) + .thenAnswer(invocation -> TestReflection.setField(invocation.getArgument(0), "id", 99L)); + + Map atTheMaximum = aValidRequestBody(); + atTheMaximum.put("filename", "a".repeat(DOCUMENT_FILENAME_MAX_LENGTH - ".txt".length()) + ".txt"); + + given().when() + .body(atTheMaximum) + .header(new Header(ADMIN_KEY_HEADER, ADMIN_KEY)) + .contentType(ContentType.JSON) + .post(ADMIN_DOCUMENTS_PATH) + .then() + .statusCode(CREATED.getCode()); + + verify(documentRepository).save(any(DocumentEntity.class)); + + Map oneAbove = aValidRequestBody(); + oneAbove.put("filename", "a".repeat(DOCUMENT_FILENAME_MAX_LENGTH + 1 - ".txt".length()) + ".txt"); + + given().when() + .body(oneAbove) + .header(new Header(ADMIN_KEY_HEADER, ADMIN_KEY)) + .contentType(ContentType.JSON) + .post(ADMIN_DOCUMENTS_PATH) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("filename: size must be between 0 and " + DOCUMENT_FILENAME_MAX_LENGTH)) + .body("status", equalTo(422)); + + // Still the one save from the accepted request above: one over the maximum wrote nothing. + verify(documentRepository, times(1)).save(any(DocumentEntity.class)); } @Test - void postDocumentErrorsIfSourceSpecExceedsTwoHundredAndFiftyFiveCharacters() { - Map longSourceSpec = aValidRequestBody(); - longSourceSpec.put("sourceSpec", "a".repeat(256)); + void postDocumentAcceptsSourceSpecAtTheMaximumLengthAndErrorsOneAbove() { + when(documentRepository.save(any(DocumentEntity.class))) + .thenAnswer(invocation -> TestReflection.setField(invocation.getArgument(0), "id", 99L)); + + Map atTheMaximum = aValidRequestBody(); + atTheMaximum.put("sourceSpec", "a".repeat(DOCUMENT_SOURCE_SPEC_MAX_LENGTH)); given().when() - .body(longSourceSpec) + .body(atTheMaximum) + .header(new Header(ADMIN_KEY_HEADER, ADMIN_KEY)) + .contentType(ContentType.JSON) + .post(ADMIN_DOCUMENTS_PATH) + .then() + .statusCode(CREATED.getCode()); + + verify(documentRepository).save(any(DocumentEntity.class)); + + Map oneAbove = aValidRequestBody(); + oneAbove.put("sourceSpec", "a".repeat(DOCUMENT_SOURCE_SPEC_MAX_LENGTH + 1)); + + given().when() + .body(oneAbove) .header(new Header(ADMIN_KEY_HEADER, ADMIN_KEY)) .contentType(ContentType.JSON) .post(ADMIN_DOCUMENTS_PATH) @@ -428,8 +501,11 @@ void postDocumentErrorsIfSourceSpecExceedsTwoHundredAndFiftyFiveCharacters() { .statusCode(UNPROCESSABLE_ENTITY.getCode()) .contentType(ContentType.JSON) .body("keySet()", containsInAnyOrder("error", "status")) - .body("error", equalTo("sourceSpec: size must be between 0 and 255")) + .body("error", equalTo("sourceSpec: size must be between 0 and " + DOCUMENT_SOURCE_SPEC_MAX_LENGTH)) .body("status", equalTo(422)); + + // Still the one save from the accepted request above: one over the maximum wrote nothing. + verify(documentRepository, times(1)).save(any(DocumentEntity.class)); } private Map aValidRequestBody() { diff --git a/src/test/java/com/practiq/controller/ConceptControllerCT.java b/src/test/java/com/practiq/web/controller/ConceptControllerCT.java similarity index 80% rename from src/test/java/com/practiq/controller/ConceptControllerCT.java rename to src/test/java/com/practiq/web/controller/ConceptControllerCT.java index 05135c5..56f9574 100644 --- a/src/test/java/com/practiq/controller/ConceptControllerCT.java +++ b/src/test/java/com/practiq/web/controller/ConceptControllerCT.java @@ -1,4 +1,4 @@ -package com.practiq.controller; +package com.practiq.web.controller; import static io.micronaut.http.HttpStatus.*; import static io.restassured.RestAssured.given; @@ -6,8 +6,8 @@ import static org.mockito.Mockito.*; import static utils.TestReflection.setField; -import com.practiq.domain.Concept; -import com.practiq.repository.ConceptRepository; +import com.practiq.persistence.ConceptEntity; +import com.practiq.persistence.repository.ConceptRepository; import io.micronaut.runtime.server.EmbeddedServer; import io.micronaut.test.annotation.MockBean; import io.restassured.RestAssured; @@ -47,7 +47,7 @@ void getConceptsSerializesRepositoryResults() { String diffractionName = "Diffraction"; String diffractionDescription = "The spreading of waves through a gap or around an obstacle."; Instant diffractionCreatedAt = Instant.parse("2026-01-01T00:00:00Z"); - Concept diffraction = new Concept(diffractionName, diffractionDescription); + ConceptEntity diffraction = new ConceptEntity(diffractionName, diffractionDescription); setField(diffraction, "id", diffractionId); setField(diffraction, "createdAt", diffractionCreatedAt); @@ -55,7 +55,7 @@ void getConceptsSerializesRepositoryResults() { String accelerationName = "Acceleration"; String accelerationDescription = "How the velocity of an object changes over time."; Instant accelerationCreatedAt = Instant.parse("2026-01-02T00:00:00Z"); - Concept acceleration = new Concept(accelerationName, accelerationDescription); + ConceptEntity acceleration = new ConceptEntity(accelerationName, accelerationDescription); setField(acceleration, "id", accelerationId); setField(acceleration, "createdAt", accelerationCreatedAt); @@ -102,7 +102,7 @@ void getConceptByIdSerializesResult() { String name = "Diffraction"; String description = "The spreading of waves through a gap or around an obstacle."; Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - Concept concept = new Concept(name, description); + ConceptEntity concept = new ConceptEntity(name, description); setField(concept, "id", id); setField(concept, "createdAt", createdAt); @@ -142,7 +142,7 @@ void getConceptByIdReturnsNotFoundEnvelope() { } @Test - void getConceptByIdReturnsEnvelopeForIdNotBeingNaturalNumber() { + void getConceptByIdReturnsEnvelopeForIdNotBeingANumber() { String path = CONCEPTS_PATH + "/BAD"; given().when() .get(path) @@ -153,4 +153,22 @@ void getConceptByIdReturnsEnvelopeForIdNotBeingNaturalNumber() { .body("error", equalTo("id: invalid value")) .body("status", equalTo(400)); } + + @Test + void getConceptByIdReturnsEnvelopeForIdBelowOneAndNeverAsksTheRepository() { + // An id below one can never name a row, so it is refused as a bad value rather than answered 404 as + // an absent one. Both arms live in one test so the boundary cannot drift apart into separate cases. + for (String id : List.of("0", "-4002")) { + given().when() + .get(CONCEPTS_PATH + "/" + id) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("id: must be greater than or equal to 1")) + .body("status", equalTo(422)); + } + + verify(conceptRepository, never()).findById(anyLong()); + } } diff --git a/src/test/java/com/practiq/controller/MarkSchemeControllerCT.java b/src/test/java/com/practiq/web/controller/MarkSchemeControllerCT.java similarity index 72% rename from src/test/java/com/practiq/controller/MarkSchemeControllerCT.java rename to src/test/java/com/practiq/web/controller/MarkSchemeControllerCT.java index 2b0a551..8a6d80e 100644 --- a/src/test/java/com/practiq/controller/MarkSchemeControllerCT.java +++ b/src/test/java/com/practiq/web/controller/MarkSchemeControllerCT.java @@ -1,15 +1,16 @@ -package com.practiq.controller; +package com.practiq.web.controller; import static io.micronaut.http.HttpStatus.*; import static io.restassured.RestAssured.given; -import static org.hamcrest.Matchers.*; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; import static org.mockito.Mockito.*; import static utils.TestReflection.setField; -import com.practiq.domain.MarkScheme; -import com.practiq.repository.MarkSchemeRepository; -import com.practiq.repository.QuestionConceptRepository; -import com.practiq.repository.QuestionRepository; +import com.practiq.persistence.MarkSchemeEntity; +import com.practiq.persistence.repository.MarkSchemeRepository; +import com.practiq.persistence.repository.QuestionConceptRepository; +import com.practiq.persistence.repository.QuestionRepository; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import io.micronaut.runtime.server.EmbeddedServer; import io.micronaut.test.annotation.MockBean; @@ -17,13 +18,14 @@ import io.restassured.http.ContentType; import jakarta.inject.Inject; import java.time.Instant; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import utils.ComponentTest; -// Real web layer through to the real service, StudentQuestionQueryRunner, spec factory and mapper — only the +// Real web layer through to the real service, accessor, policy, query runner and mappers — only the // repositories (the persistence boundary) are mocked, so a single test exercises as much of the app as // possible: routing, path-var binding, the student-visibility gate, the mark-scheme lookup, mapping and // the serialised shape (including that version never reaches the payload). @@ -70,13 +72,13 @@ void getMarkSchemeSerialisesResponse() { // version is set to a non-zero value so its absence from the JSON proves the payload drops it, // rather than it merely happening to be zero. - MarkScheme markScheme = new MarkScheme(questionId, body); + MarkSchemeEntity markScheme = new MarkSchemeEntity(questionId, body); setField(markScheme, "id", markSchemeId); setField(markScheme, "version", 7); setField(markScheme, "createdAt", createdAt); // The visibility gate is an existence check; the mark scheme is then found for the question. The - // spec is built inside StudentQuestionQueryRunner, so it can only be matched by type. + // spec is built inside the query runner, so it can only be matched by type. when(questionRepository.exists(Mockito.any(QuerySpecification.class))).thenReturn(true); when(markSchemeRepository.findByQuestionId(questionId)).thenReturn(Optional.of(markScheme)); @@ -120,7 +122,7 @@ void getMarkSchemeSerialisesNotFoundEnvelopeWhenQuestionNotVisible() { } @Test - void getMarkSchemeReturnsBadRequestIfQuestionIdNotNaturalNumber() { + void getMarkSchemeReturnsBadRequestIfQuestionIdNotANumber() { String path = QUESTIONS_PATH + "/error/mark-scheme"; given().when() .get(path) @@ -131,4 +133,24 @@ void getMarkSchemeReturnsBadRequestIfQuestionIdNotNaturalNumber() { .body("error", equalTo("questionId: invalid value")) .body("status", equalTo(400)); } + + @Test + void getMarkSchemeReturnsEnvelopeForQuestionIdBelowOneAndNeverAsksTheRepositories() { + // A question id below one can never name a row, so it is refused as a bad value rather than answered + // 404 as an absent one. Both arms live in one test so the boundary cannot drift into separate cases. + for (String questionId : List.of("0", "-4002")) { + given().when() + .get(QUESTIONS_PATH + "/" + questionId + "/mark-scheme") + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("questionId: must be greater than or equal to 1")) + .body("status", equalTo(422)); + } + + // Refused at the web edge, so neither the visibility gate nor the mark-scheme lookup is reached. + verifyNoInteractions(questionRepository); + verifyNoInteractions(markSchemeRepository); + } } diff --git a/src/test/java/com/practiq/controller/QuestionAttemptControllerCT.java b/src/test/java/com/practiq/web/controller/QuestionAttemptControllerCT.java similarity index 77% rename from src/test/java/com/practiq/controller/QuestionAttemptControllerCT.java rename to src/test/java/com/practiq/web/controller/QuestionAttemptControllerCT.java index 062b5e4..f42decc 100644 --- a/src/test/java/com/practiq/controller/QuestionAttemptControllerCT.java +++ b/src/test/java/com/practiq/web/controller/QuestionAttemptControllerCT.java @@ -1,4 +1,4 @@ -package com.practiq.controller; +package com.practiq.web.controller; import static io.micronaut.http.HttpStatus.*; import static io.restassured.RestAssured.given; @@ -6,13 +6,14 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; import static utils.TestReflection.setField; +import static utils.data.TestData.QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH; import static utils.data.TestData.SESSION_TOKEN_HEADER; -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.query.attempt.QuestionAttemptQuery; -import com.practiq.domain.query.attempt.QuestionAttemptSpecificationFactory; -import com.practiq.repository.QuestionAttemptRepository; -import com.practiq.repository.QuestionRepository; +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.query.attempt.QuestionAttemptQuery; +import com.practiq.persistence.query.attempt.QuestionAttemptSpecificationFactory; +import com.practiq.persistence.repository.QuestionAttemptRepository; +import com.practiq.persistence.repository.QuestionRepository; import io.micronaut.data.model.Sort; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import io.micronaut.runtime.server.EmbeddedServer; @@ -35,6 +36,9 @@ public class QuestionAttemptControllerCT { private static final String QUESTION_ATTEMPTS_PATH = "/api/v1/questions/%s/attempts"; + // The product rule on the request DTO, which is deliberately lower than the entity and column guard. + private static final int ATTEMPT_BODY_MAX_LENGTH = 20000; + private static final Sort STABLE_ORDER = Sort.of(Sort.Order.desc("createdAt"), Sort.Order.asc("id")); @Inject @@ -109,7 +113,7 @@ void getQuestionAttemptsReturnsUnprocessableEntityIfSessionTokenBlank() { } @Test - void getQuestionAttemptsReturnsBadRequestIfQuestionIdNotNaturalNumber() { + void getQuestionAttemptsReturnsBadRequestIfQuestionIdNotANumber() { String questionId = "error"; String sessionToken = "test"; String path = QUESTION_ATTEMPTS_PATH.formatted(questionId); @@ -178,18 +182,18 @@ void getQuestionAttemptsSerialisesResponse() { long attemptId1 = 10L; String attempt1Body = "body 1"; Instant createdAt1 = Instant.parse("2026-01-01T00:00:00Z"); - QuestionAttempt attempt1 = new QuestionAttempt(questionId, sessionToken, attempt1Body); + QuestionAttemptEntity attempt1 = new QuestionAttemptEntity(questionId, sessionToken, attempt1Body); setField(attempt1, "createdAt", createdAt1); setField(attempt1, "id", attemptId1); long attemptId2 = 20L; String attempt2Body = "body 2"; Instant createdAt2 = Instant.parse("2026-01-02T00:00:00Z"); - QuestionAttempt attempt2 = new QuestionAttempt(questionId, sessionToken, attempt2Body); + QuestionAttemptEntity attempt2 = new QuestionAttemptEntity(questionId, sessionToken, attempt2Body); setField(attempt2, "createdAt", createdAt2); setField(attempt2, "id", attemptId2); - List attempts = List.of(attempt1, attempt2); + List attempts = List.of(attempt1, attempt2); when(questionRepository.exists(any(QuerySpecification.class))).thenReturn(true); when(questionAttemptRepository.findAll(any(QuerySpecification.class), eq(STABLE_ORDER))) @@ -280,7 +284,7 @@ void postQuestionAttemptReturnsUnprocessableEntityIfSessionTokenBlank() { } @Test - void postQuestionAttemptReturnsBadRequestIfQuestionIdNotNaturalNumber() { + void postQuestionAttemptReturnsBadRequestIfQuestionIdNotANumber() { String questionId = "error"; String sessionToken = "test"; @@ -342,25 +346,33 @@ void postQuestionAttemptReturnsUnprocessableEntityIfBodyFieldBlank() { } @Test - void postQuestionAttemptReturnsUnprocessableEntityIfBodyTooLong() { + void postQuestionAttemptAcceptsBodyAtTheMaximumLengthAndErrorsOneAbove() { String sessionToken = "test"; String path = QUESTION_ATTEMPTS_PATH.formatted(1L); - String body = RandomStringUtils.insecure().nextAlphanumeric(20001); + + when(questionRepository.exists(any(QuerySpecification.class))).thenReturn(true); + when(questionAttemptRepository.save(any(QuestionAttemptEntity.class))) + .thenAnswer(invocation -> setField(invocation.getArgument(0), "id", 1L)); + + given().contentType(ContentType.JSON) + .header(new Header(SESSION_TOKEN_HEADER, sessionToken)) + .when() + .body(Map.of("body", RandomStringUtils.insecure().nextAlphanumeric(ATTEMPT_BODY_MAX_LENGTH))) + .post(path) + .then() + .statusCode(CREATED.getCode()); given().contentType(ContentType.JSON) .header(new Header(SESSION_TOKEN_HEADER, sessionToken)) .when() - .body(Map.of("body", body)) + .body(Map.of("body", RandomStringUtils.insecure().nextAlphanumeric(ATTEMPT_BODY_MAX_LENGTH + 1))) .post(path) .then() .statusCode(UNPROCESSABLE_ENTITY.getCode()) .contentType(ContentType.JSON) .body("keySet()", containsInAnyOrder("error", "status")) - .body("error", equalTo("body: size must be between 0 and 20000")) + .body("error", equalTo("body: size must be between 0 and " + ATTEMPT_BODY_MAX_LENGTH)) .body("status", equalTo(422)); - - verifyNoInteractions(questionRepository); - verifyNoInteractions(questionAttemptRepository); } @Test @@ -394,11 +406,11 @@ void postQuestionAttemptSavesAttemptAndSerialisesResponse() { Map requestBody = new HashMap<>(); requestBody.put("body", attemptBody); - QuestionAttempt attemptToSave = new QuestionAttempt(questionId, sessionToken, attemptBody); + QuestionAttemptEntity attemptToSave = new QuestionAttemptEntity(questionId, sessionToken, attemptBody); long attemptId = 10L; Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - QuestionAttempt attemptDB = new QuestionAttempt(questionId, sessionToken, attemptBody); + QuestionAttemptEntity attemptDB = new QuestionAttemptEntity(questionId, sessionToken, attemptBody); setField(attemptDB, "createdAt", createdAt); setField(attemptDB, "id", attemptId); @@ -424,4 +436,61 @@ void postQuestionAttemptSavesAttemptAndSerialisesResponse() { verify(questionRepository).exists(any(QuerySpecification.class)); verify(questionAttemptRepository).save(attemptToSave); } + + @Test + void questionAttemptEndpointsRejectAQuestionIdBelowOneWithoutAskingTheRepositories() { + for (String questionId : List.of("0", "-4002")) { + String path = QUESTION_ATTEMPTS_PATH.formatted(questionId); + + given().header(new Header(SESSION_TOKEN_HEADER, "test")) + .when() + .get(path) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .body("error", equalTo("questionId: must be greater than or equal to 1")); + + given().contentType(ContentType.JSON) + .header(new Header(SESSION_TOKEN_HEADER, "test")) + .when() + .body(Map.of("body", "an attempt")) + .post(path) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .body("error", equalTo("questionId: must be greater than or equal to 1")); + } + + verifyNoInteractions(questionRepository); + verifyNoInteractions(questionAttemptRepository); + } + + @Test + void questionAttemptEndpointsAcceptASessionTokenAtTheMaximumLengthAndRejectOneAbove() { + String path = QUESTION_ATTEMPTS_PATH.formatted(1L); + String validToken = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH); + String tooLongToken = + RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH + 1); + + when(questionRepository.exists(any(QuerySpecification.class))).thenReturn(true); + when(questionAttemptRepository.findAll(any(QuerySpecification.class), any(Sort.class))) + .thenReturn(List.of()); + + given().header(new Header(SESSION_TOKEN_HEADER, validToken)) + .when() + .get(path) + .then() + .statusCode(OK.getCode()); + + given().header(new Header(SESSION_TOKEN_HEADER, tooLongToken)) + .when() + .get(path) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body( + "error", + equalTo("sessionToken: size must be between 0 and " + + QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH)) + .body("status", equalTo(422)); + } } diff --git a/src/test/java/com/practiq/controller/QuestionControllerCT.java b/src/test/java/com/practiq/web/controller/QuestionControllerCT.java similarity index 82% rename from src/test/java/com/practiq/controller/QuestionControllerCT.java rename to src/test/java/com/practiq/web/controller/QuestionControllerCT.java index 0c4e891..2cbb013 100644 --- a/src/test/java/com/practiq/controller/QuestionControllerCT.java +++ b/src/test/java/com/practiq/web/controller/QuestionControllerCT.java @@ -1,4 +1,4 @@ -package com.practiq.controller; +package com.practiq.web.controller; import static io.micronaut.http.HttpStatus.*; import static io.restassured.RestAssured.given; @@ -6,13 +6,13 @@ import static org.mockito.Mockito.*; import static utils.TestReflection.setField; -import com.practiq.domain.Question; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; -import com.practiq.repository.QuestionConceptRepository; -import com.practiq.repository.QuestionRepository; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.projection.QuestionConceptLinkProjection; +import com.practiq.persistence.repository.QuestionConceptRepository; +import com.practiq.persistence.repository.QuestionRepository; import io.micronaut.data.model.Page; import io.micronaut.data.model.Pageable; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; @@ -24,7 +24,6 @@ import java.time.Instant; import java.util.Collections; import java.util.List; -import java.util.Optional; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -68,7 +67,7 @@ void getQuestionsSerializesRepositoryResults() { QuestionStatus statusA = QuestionStatus.APPROVED; Instant createdAtA = Instant.parse("2026-01-01T00:00:00Z"); - Question questionA = new Question(bodyA, difficultyA, typeA, statusA); + QuestionEntity questionA = new QuestionEntity(bodyA, difficultyA, typeA, statusA); setField(questionA, "id", idA); setField(questionA, "createdAt", createdAtA); @@ -79,7 +78,7 @@ void getQuestionsSerializesRepositoryResults() { QuestionStatus statusB = QuestionStatus.APPROVED; Instant createdAtB = Instant.parse("2026-01-01T00:00:00Z"); - Question questionB = new Question(bodyB, difficultyB, typeB, statusB); + QuestionEntity questionB = new QuestionEntity(bodyB, difficultyB, typeB, statusB); setField(questionB, "id", idB); setField(questionB, "createdAt", createdAtB); @@ -91,8 +90,9 @@ void getQuestionsSerializesRepositoryResults() { when(questionRepository.findAll(Mockito.any(QuerySpecification.class), Mockito.any(Pageable.class))) .thenReturn(Page.of(List.of(questionA, questionB), Pageable.from(0), 2L)); when(questionConceptRepository.findLinksByQuestionIds(Mockito.any())) - .thenReturn( - List.of(new QuestionConceptLink(idB, conceptIdB1), new QuestionConceptLink(idB, conceptIdB2))); + .thenReturn(List.of( + new QuestionConceptLinkProjection(idB, conceptIdB1), + new QuestionConceptLinkProjection(idB, conceptIdB2))); given().when() .get(QUESTIONS_PATH) @@ -170,13 +170,13 @@ void getQuestionsSerializesAFilteredPageWithAndWithoutLinks() { String linkedBody = "Calculate the acceleration of a car."; QuestionDifficulty linkedDifficulty = QuestionDifficulty.EASY; QuestionType linkedType = QuestionType.SHORT_ANSWER; - Question linked = approvedQuestion(linkedId, linkedBody, linkedDifficulty, linkedType, createdAt); + QuestionEntity linked = approvedQuestion(linkedId, linkedBody, linkedDifficulty, linkedType, createdAt); long bareId = 2L; String bareBody = "Explain what is meant by diffraction."; QuestionDifficulty bareDifficulty = QuestionDifficulty.MEDIUM; QuestionType bareType = QuestionType.EXTENDED; - Question bare = approvedQuestion(bareId, bareBody, bareDifficulty, bareType, createdAt); + QuestionEntity bare = approvedQuestion(bareId, bareBody, bareDifficulty, bareType, createdAt); long conceptA = 10L; long conceptB = 11L; @@ -185,7 +185,8 @@ void getQuestionsSerializesAFilteredPageWithAndWithoutLinks() { // Only the linked question has concept rows; the bare one is absent, so its links serialise as []. when(questionConceptRepository.findLinksByQuestionIds(Mockito.any())) .thenReturn(List.of( - new QuestionConceptLink(linkedId, conceptA), new QuestionConceptLink(linkedId, conceptB))); + new QuestionConceptLinkProjection(linkedId, conceptA), + new QuestionConceptLinkProjection(linkedId, conceptB))); given().when() .get(QUESTIONS_PATH + "?types=SHORT_ANSWER,EXTENDED") @@ -266,6 +267,23 @@ void getQuestionsReturnsBadRequestIfQuestionTypesInvalid() { .body("status", equalTo(400)); } + // This endpoint binds Pageable through the shared PageableQueryBinder, whose whole contract is proven in + // PageableQueryBinderCT. One rejection it alone produces is enough to show it is the binder in use here, + // so those cases are not repeated per endpoint. + @Test + void getQuestionsBindsPagingThroughTheSharedBinder() { + given().when() + .get(QUESTIONS_PATH + "?page=-1") + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("page: must be greater than or equal to 0")) + .body("status", equalTo(422)); + + verifyNoInteractions(questionRepository); + } + @Test void getQuestionsReturnsBadRequestWhenConceptIdInvalid() { given().when() @@ -288,9 +306,9 @@ void getQuestionByIdSerialisesNullDifficultyAndTypeAsPresentNullKeys() { String body = "Explain what is meant by diffraction."; Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - Question question = approvedQuestion(id, body, null, null, createdAt); + QuestionEntity question = approvedQuestion(id, body, null, null, createdAt); - when(questionRepository.findOne(Mockito.any(QuerySpecification.class))).thenReturn(Optional.of(question)); + when(questionRepository.findAll(Mockito.any(QuerySpecification.class))).thenReturn(List.of(question)); when(questionConceptRepository.findLinksByQuestionIds(Set.of(id))).thenReturn(List.of()); given().when() @@ -319,7 +337,7 @@ void getQuestionByIdSerialisedQuestionResponse() { QuestionStatus status = QuestionStatus.APPROVED; Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); - Question question = new Question(body, difficulty, type, status); + QuestionEntity question = new QuestionEntity(body, difficulty, type, status); setField(question, "id", id); setField(question, "createdAt", createdAt); @@ -327,10 +345,10 @@ void getQuestionByIdSerialisedQuestionResponse() { long conceptIdA1 = 10L; long conceptIdA2 = 11L; - List links = - List.of(new QuestionConceptLink(id, conceptIdA1), new QuestionConceptLink(id, conceptIdA2)); + List links = List.of( + new QuestionConceptLinkProjection(id, conceptIdA1), new QuestionConceptLinkProjection(id, conceptIdA2)); - when(questionRepository.findOne(Mockito.any(QuerySpecification.class))).thenReturn(Optional.of(question)); + when(questionRepository.findAll(Mockito.any(QuerySpecification.class))).thenReturn(List.of(question)); when(questionConceptRepository.findLinksByQuestionIds(Set.of(id))).thenReturn(links); String path = QUESTIONS_PATH + "/" + id; @@ -350,7 +368,7 @@ void getQuestionByIdSerialisedQuestionResponse() { .body("createdAt", equalTo(createdAt.toString())) .body("linkedConceptIds", containsInAnyOrder((int) conceptIdA1, (int) conceptIdA2)); - verify(questionRepository).findOne(Mockito.any(QuerySpecification.class)); + verify(questionRepository).findAll(Mockito.any(QuerySpecification.class)); verify(questionConceptRepository).findLinksByQuestionIds(Set.of(id)); } @@ -358,7 +376,7 @@ void getQuestionByIdSerialisedQuestionResponse() { void getQuestionByIdSerialisedErrorResponseIfNotFound() { long id = 3L; - when(questionRepository.findOne(Mockito.any(QuerySpecification.class))).thenReturn(Optional.empty()); + when(questionRepository.findAll(Mockito.any(QuerySpecification.class))).thenReturn(List.of()); String path = QUESTIONS_PATH + "/" + id; given().when() @@ -370,11 +388,11 @@ void getQuestionByIdSerialisedErrorResponseIfNotFound() { .body("error", equalTo("Could not find resource for path: " + path)) .body("status", equalTo(404)); - verify(questionRepository).findOne(Mockito.any(QuerySpecification.class)); + verify(questionRepository).findAll(Mockito.any(QuerySpecification.class)); } @Test - void getQuestionByIdReturnsBadRequestIfIdNotNaturalNumber() { + void getQuestionByIdReturnsBadRequestIfIdNotANumber() { String path = QUESTIONS_PATH + "/error"; given().when() .get(path) @@ -388,11 +406,27 @@ void getQuestionByIdReturnsBadRequestIfIdNotNaturalNumber() { // An APPROVED question with id and created_at set (both DB-assigned in production, so set by reflection // here). created_at must be non-null or Serde omits the key and the keySet assertions break. - private static Question approvedQuestion( + private static QuestionEntity approvedQuestion( long id, String body, QuestionDifficulty difficulty, QuestionType type, Instant createdAt) { - Question question = new Question(body, difficulty, type, QuestionStatus.APPROVED); + QuestionEntity question = new QuestionEntity(body, difficulty, type, QuestionStatus.APPROVED); setField(question, "id", id); setField(question, "createdAt", createdAt); return question; } + + @Test + void getQuestionByIdReturnsEnvelopeForIdBelowOneAndNeverAsksTheRepository() { + for (String id : List.of("0", "-4002")) { + given().when() + .get(QUESTIONS_PATH + "/" + id) + .then() + .statusCode(UNPROCESSABLE_ENTITY.getCode()) + .contentType(ContentType.JSON) + .body("keySet()", containsInAnyOrder("error", "status")) + .body("error", equalTo("id: must be greater than or equal to 1")) + .body("status", equalTo(422)); + } + + verifyNoInteractions(questionRepository); + } } diff --git a/src/test/java/com/practiq/dto/converter/QuestionDifficultyTypeConverterTest.java b/src/test/java/com/practiq/web/dto/converter/QuestionDifficultyTypeConverterTest.java similarity index 94% rename from src/test/java/com/practiq/dto/converter/QuestionDifficultyTypeConverterTest.java rename to src/test/java/com/practiq/web/dto/converter/QuestionDifficultyTypeConverterTest.java index 268086c..6d0377e 100644 --- a/src/test/java/com/practiq/dto/converter/QuestionDifficultyTypeConverterTest.java +++ b/src/test/java/com/practiq/web/dto/converter/QuestionDifficultyTypeConverterTest.java @@ -1,11 +1,11 @@ -package com.practiq.dto.converter; +package com.practiq.web.dto.converter; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.*; -import com.practiq.domain.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionDifficulty; import io.micronaut.core.convert.ConversionContext; import java.util.Optional; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/practiq/web/dto/mapper/ConceptResponseMapperTest.java b/src/test/java/com/practiq/web/dto/mapper/ConceptResponseMapperTest.java new file mode 100644 index 0000000..977384c --- /dev/null +++ b/src/test/java/com/practiq/web/dto/mapper/ConceptResponseMapperTest.java @@ -0,0 +1,32 @@ +package com.practiq.web.dto.mapper; + +import static com.practiq.web.dto.mapper.ConceptResponseMapper.toConceptResponse; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.practiq.service.concept.dto.response.Concept; +import com.practiq.web.dto.response.ConceptResponse; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class ConceptResponseMapperTest { + + @Test + void conceptMapsToConceptResponse() { + long id = 1L; + String name = "Diffraction"; + String description = "The spreading of waves through a gap or around an obstacle."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + // A non-zero version the response has no field for: the web mapper selects a subset, so the lock + // token must be dropped here rather than reaching a client. + Concept concept = new Concept(id, 9, name, description, createdAt); + + ConceptResponse conceptResponse = toConceptResponse(concept); + + assertThat(conceptResponse.id(), equalTo(id)); + assertThat(conceptResponse.name(), equalTo(name)); + assertThat(conceptResponse.description(), equalTo(description)); + assertThat(conceptResponse.createdAt(), equalTo(createdAt)); + } +} diff --git a/src/test/java/com/practiq/dto/mapper/ErrorResponseMapperTest.java b/src/test/java/com/practiq/web/dto/mapper/ErrorResponseMapperTest.java similarity index 88% rename from src/test/java/com/practiq/dto/mapper/ErrorResponseMapperTest.java rename to src/test/java/com/practiq/web/dto/mapper/ErrorResponseMapperTest.java index 8c3b69f..cc51eae 100644 --- a/src/test/java/com/practiq/dto/mapper/ErrorResponseMapperTest.java +++ b/src/test/java/com/practiq/web/dto/mapper/ErrorResponseMapperTest.java @@ -1,11 +1,11 @@ -package com.practiq.dto.mapper; +package com.practiq.web.dto.mapper; import static io.micronaut.http.HttpStatus.REQUEST_ENTITY_TOO_LARGE; import static io.micronaut.http.HttpStatus.UNPROCESSABLE_ENTITY; import static org.junit.jupiter.api.Assertions.assertEquals; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.EntityValidationError; +import com.practiq.foundation.exception.EntityValidationError; +import com.practiq.web.dto.response.ErrorResponse; import org.junit.jupiter.api.Test; class ErrorResponseMapperTest { diff --git a/src/test/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapperTest.java b/src/test/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapperTest.java new file mode 100644 index 0000000..6d2b8f4 --- /dev/null +++ b/src/test/java/com/practiq/web/dto/mapper/MarkSchemeResponseMapperTest.java @@ -0,0 +1,32 @@ +package com.practiq.web.dto.mapper; + +import static com.practiq.web.dto.mapper.MarkSchemeResponseMapper.toMarkSchemeResponse; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.practiq.service.markscheme.dto.response.MarkScheme; +import com.practiq.web.dto.response.MarkSchemeResponse; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class MarkSchemeResponseMapperTest { + + @Test + void markSchemeMapsToMarkSchemeResponse() { + long id = 1L; + long questionId = 10L; + String body = "Award 1 mark for stating the wave bends around the edge of the gap."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + // A non-zero version the response has no field for: the web mapper selects a subset, so the lock + // token must be dropped here rather than reaching a client. + MarkScheme markScheme = new MarkScheme(id, 8, questionId, body, createdAt); + + MarkSchemeResponse markSchemeResponse = toMarkSchemeResponse(markScheme); + + assertThat(markSchemeResponse.id(), equalTo(id)); + assertThat(markSchemeResponse.questionId(), equalTo(questionId)); + assertThat(markSchemeResponse.body(), equalTo(body)); + assertThat(markSchemeResponse.createdAt(), equalTo(createdAt)); + } +} diff --git a/src/test/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapperTest.java b/src/test/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapperTest.java new file mode 100644 index 0000000..6943c1d --- /dev/null +++ b/src/test/java/com/practiq/web/dto/mapper/QuestionAttemptResponseMapperTest.java @@ -0,0 +1,45 @@ +package com.practiq.web.dto.mapper; + +import static com.practiq.web.dto.mapper.QuestionAttemptResponseMapper.toQuestionAttemptResponse; +import static com.practiq.web.dto.mapper.QuestionAttemptResponseMapper.toQuestionAttemptResponses; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; + +import com.practiq.service.attempt.dto.response.QuestionAttempt; +import com.practiq.web.dto.response.QuestionAttemptResponse; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +class QuestionAttemptResponseMapperTest { + + @Test + void questionAttemptMapsToQuestionAttemptResponse() { + long id = 1L; + long questionId = 10L; + String body = "Because the wave bends around the edge of the gap."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + + // The session token the caller supplied: the response has no field for it, so it cannot be echoed + // back into bodies, logs or caches. + QuestionAttempt questionAttempt = new QuestionAttempt(id, questionId, "a-session-token", body, createdAt); + + QuestionAttemptResponse response = toQuestionAttemptResponse(questionAttempt); + + assertThat(response.id(), equalTo(id)); + assertThat(response.questionId(), equalTo(questionId)); + assertThat(response.body(), equalTo(body)); + assertThat(response.createdAt(), equalTo(createdAt)); + } + + @Test + void questionAttemptsMapToQuestionAttemptResponsesInOrder() { + QuestionAttempt first = new QuestionAttempt(1L, 10L, "token", "First", Instant.parse("2026-01-02T00:00:00Z")); + QuestionAttempt second = new QuestionAttempt(2L, 10L, "token", "Second", Instant.parse("2026-01-01T00:00:00Z")); + + List responses = toQuestionAttemptResponses(List.of(first, second)); + + assertThat(responses.stream().map(QuestionAttemptResponse::body).toList(), contains("First", "Second")); + } +} diff --git a/src/test/java/com/practiq/web/dto/mapper/QuestionResponseMapperTest.java b/src/test/java/com/practiq/web/dto/mapper/QuestionResponseMapperTest.java new file mode 100644 index 0000000..6955433 --- /dev/null +++ b/src/test/java/com/practiq/web/dto/mapper/QuestionResponseMapperTest.java @@ -0,0 +1,82 @@ +package com.practiq.web.dto.mapper; + +import static com.practiq.web.dto.mapper.QuestionResponseMapper.toQuestionResponse; +import static com.practiq.web.dto.mapper.QuestionResponseMapper.toQuestionResponses; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.nullValue; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.service.question.dto.response.Question; +import com.practiq.web.dto.response.QuestionResponse; +import java.time.Instant; +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; + +class QuestionResponseMapperTest { + + @Test + void questionMapsToQuestionResponse() { + long id = 1L; + String body = "Explain why a wave bends around an obstacle."; + Instant createdAt = Instant.parse("2026-01-01T00:00:00Z"); + Set linkedConceptIds = Set.of(10L, 11L); + + // A non-zero version and a status the response has no field for: the web mapper selects a subset, so + // neither may reach a client. + Question question = new Question( + id, + 7, + body, + QuestionDifficulty.HARD, + QuestionType.EXTENDED, + QuestionStatus.APPROVED, + createdAt, + linkedConceptIds); + + QuestionResponse questionResponse = toQuestionResponse(question); + + assertThat(questionResponse.id(), equalTo(id)); + assertThat(questionResponse.body(), equalTo(body)); + assertThat(questionResponse.difficulty().getValue(), equalTo(QuestionDifficulty.HARD.value())); + assertThat(questionResponse.difficulty().getCode(), equalTo(QuestionDifficulty.HARD.name())); + assertThat(questionResponse.type(), equalTo(QuestionType.EXTENDED)); + assertThat(questionResponse.createdAt(), equalTo(createdAt)); + assertThat(questionResponse.linkedConceptIds(), containsInAnyOrder(10L, 11L)); + } + + @Test + void anUnratedQuestionMapsToANullDifficultyRatherThanAPartiallyPopulatedOne() { + Question question = new Question( + 1L, + 0, + "A question.", + null, + null, + QuestionStatus.APPROVED, + Instant.parse("2026-01-01T00:00:00Z"), + Set.of()); + + QuestionResponse questionResponse = toQuestionResponse(question); + + assertThat(questionResponse.difficulty(), nullValue()); + assertThat(questionResponse.type(), nullValue()); + } + + @Test + void questionsMapToQuestionResponsesInOrder() { + Question first = new Question( + 1L, 0, "First", null, null, QuestionStatus.APPROVED, Instant.parse("2026-01-01T00:00:00Z"), Set.of()); + Question second = new Question( + 2L, 0, "Second", null, null, QuestionStatus.APPROVED, Instant.parse("2026-01-02T00:00:00Z"), Set.of()); + + List questionResponses = toQuestionResponses(List.of(first, second)); + + assertThat(questionResponses.stream().map(QuestionResponse::body).toList(), contains("First", "Second")); + } +} diff --git a/src/test/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapperTest.java b/src/test/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapperTest.java new file mode 100644 index 0000000..ea2c814 --- /dev/null +++ b/src/test/java/com/practiq/web/dto/mapper/UploadDocumentResponseMapperTest.java @@ -0,0 +1,28 @@ +package com.practiq.web.dto.mapper; + +import static com.practiq.web.dto.mapper.UploadDocumentResponseMapper.toUploadDocumentResponse; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; + +import com.practiq.service.document.dto.response.DocumentPresignUpload; +import com.practiq.web.dto.response.UploadDocumentResponse; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class UploadDocumentResponseMapperTest { + + @Test + void documentPresignUploadMapsToUploadDocumentResponse() { + long id = 99L; + String url = "https://s3.example.com/cf5c1c1e-8a7d-4b0e-9f2a-6d3b7c8e1f04.pdf?X-Amz-Signature=abc"; + Instant expiresAt = Instant.parse("2026-08-12T18:10:00Z"); + + DocumentPresignUpload documentPresignUpload = new DocumentPresignUpload(id, url, expiresAt); + + UploadDocumentResponse uploadDocumentResponse = toUploadDocumentResponse(documentPresignUpload); + + assertThat(uploadDocumentResponse.id(), equalTo(id)); + assertThat(uploadDocumentResponse.url(), equalTo(url)); + assertThat(uploadDocumentResponse.expiresAt(), equalTo(expiresAt)); + } +} diff --git a/src/test/java/com/practiq/exception/handler/ConstraintViolationExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/ConstraintViolationExceptionHandlerTest.java similarity index 97% rename from src/test/java/com/practiq/exception/handler/ConstraintViolationExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/ConstraintViolationExceptionHandlerTest.java index 840bca3..09700ef 100644 --- a/src/test/java/com/practiq/exception/handler/ConstraintViolationExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/ConstraintViolationExceptionHandlerTest.java @@ -1,4 +1,4 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.UNPROCESSABLE_ENTITY; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -6,7 +6,7 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import jakarta.validation.ConstraintViolation; diff --git a/src/test/java/com/practiq/exception/handler/ContentLengthExceededHandlerTest.java b/src/test/java/com/practiq/web/handler/ContentLengthExceededHandlerTest.java similarity index 92% rename from src/test/java/com/practiq/exception/handler/ContentLengthExceededHandlerTest.java rename to src/test/java/com/practiq/web/handler/ContentLengthExceededHandlerTest.java index 0e45a5f..595ea18 100644 --- a/src/test/java/com/practiq/exception/handler/ContentLengthExceededHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/ContentLengthExceededHandlerTest.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.REQUEST_ENTITY_TOO_LARGE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import io.micronaut.http.exceptions.ContentLengthExceededException; diff --git a/src/test/java/com/practiq/exception/handler/ContentTooLargeExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/ContentTooLargeExceptionHandlerTest.java similarity index 88% rename from src/test/java/com/practiq/exception/handler/ContentTooLargeExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/ContentTooLargeExceptionHandlerTest.java index a6e2591..7462cf8 100644 --- a/src/test/java/com/practiq/exception/handler/ContentTooLargeExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/ContentTooLargeExceptionHandlerTest.java @@ -1,11 +1,11 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.REQUEST_ENTITY_TOO_LARGE; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.ContentTooLargeException; +import com.practiq.foundation.exception.ContentTooLargeException; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/practiq/exception/handler/ConversionErrorExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/ConversionErrorExceptionHandlerTest.java similarity index 95% rename from src/test/java/com/practiq/exception/handler/ConversionErrorExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/ConversionErrorExceptionHandlerTest.java index 96255b0..b0465fb 100644 --- a/src/test/java/com/practiq/exception/handler/ConversionErrorExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/ConversionErrorExceptionHandlerTest.java @@ -1,11 +1,11 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.domain.types.QuestionType; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.foundation.types.QuestionType; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.core.convert.ConversionError; import io.micronaut.core.convert.exceptions.ConversionErrorException; import io.micronaut.core.type.Argument; diff --git a/src/test/java/com/practiq/exception/handler/EntityValidationExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/EntityValidationExceptionHandlerTest.java similarity index 88% rename from src/test/java/com/practiq/exception/handler/EntityValidationExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/EntityValidationExceptionHandlerTest.java index 1ab545b..64859d0 100644 --- a/src/test/java/com/practiq/exception/handler/EntityValidationExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/EntityValidationExceptionHandlerTest.java @@ -1,11 +1,11 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.UNPROCESSABLE_ENTITY; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.EntityValidationException; +import com.practiq.foundation.exception.EntityValidationException; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/practiq/exception/handler/ErrorHandlingCT.java b/src/test/java/com/practiq/web/handler/ErrorHandlingCT.java similarity index 99% rename from src/test/java/com/practiq/exception/handler/ErrorHandlingCT.java rename to src/test/java/com/practiq/web/handler/ErrorHandlingCT.java index a798262..5a773b3 100644 --- a/src/test/java/com/practiq/exception/handler/ErrorHandlingCT.java +++ b/src/test/java/com/practiq/web/handler/ErrorHandlingCT.java @@ -1,4 +1,4 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.*; import static io.restassured.RestAssured.given; diff --git a/src/test/java/com/practiq/exception/handler/ErrorHandlingTestController.java b/src/test/java/com/practiq/web/handler/ErrorHandlingTestController.java similarity index 97% rename from src/test/java/com/practiq/exception/handler/ErrorHandlingTestController.java rename to src/test/java/com/practiq/web/handler/ErrorHandlingTestController.java index 2a81e13..d0ecbea 100644 --- a/src/test/java/com/practiq/exception/handler/ErrorHandlingTestController.java +++ b/src/test/java/com/practiq/web/handler/ErrorHandlingTestController.java @@ -1,4 +1,4 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import io.micronaut.context.annotation.Requires; import io.micronaut.http.MediaType; diff --git a/src/test/java/com/practiq/web/handler/GenericExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/GenericExceptionHandlerTest.java new file mode 100644 index 0000000..7e7b81e --- /dev/null +++ b/src/test/java/com/practiq/web/handler/GenericExceptionHandlerTest.java @@ -0,0 +1,29 @@ +package com.practiq.web.handler; + +import static io.micronaut.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import com.practiq.web.dto.response.ErrorResponse; +import io.micronaut.http.HttpRequest; +import io.micronaut.http.HttpResponse; +import org.junit.jupiter.api.Test; + +class GenericExceptionHandlerTest { + + private final GenericExceptionHandler handler = new GenericExceptionHandler(); + + @Test + void handleBuildsTheServerErrorEnvelopeWithoutLeakingTheCause() { + HttpRequest request = HttpRequest.GET("/api/v1/questions"); + + HttpResponse response = + handler.handle(request, new IllegalStateException("connection pool exhausted at 10.0.0.4:5432")); + + assertEquals(INTERNAL_SERVER_ERROR.getCode(), response.getStatus().getCode()); + ErrorResponse body = response.body(); + assertNotNull(body); + assertEquals("An unspecified error occurred.", body.error()); + assertEquals(INTERNAL_SERVER_ERROR.getCode(), body.status()); + } +} diff --git a/src/test/java/com/practiq/exception/handler/NotFoundExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/NotFoundExceptionHandlerTest.java similarity index 92% rename from src/test/java/com/practiq/exception/handler/NotFoundExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/NotFoundExceptionHandlerTest.java index 69812ad..ddfd82d 100644 --- a/src/test/java/com/practiq/exception/handler/NotFoundExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/NotFoundExceptionHandlerTest.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.NOT_FOUND; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import io.micronaut.http.server.exceptions.NotFoundException; diff --git a/src/test/java/com/practiq/exception/handler/UnauthorizedExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/UnauthorizedExceptionHandlerTest.java similarity index 87% rename from src/test/java/com/practiq/exception/handler/UnauthorizedExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/UnauthorizedExceptionHandlerTest.java index 77ea329..5e09c71 100644 --- a/src/test/java/com/practiq/exception/handler/UnauthorizedExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/UnauthorizedExceptionHandlerTest.java @@ -1,11 +1,11 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.UNAUTHORIZED; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; -import com.practiq.exception.UnauthorizedException; +import com.practiq.foundation.exception.UnauthorizedException; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; import org.junit.jupiter.api.Test; diff --git a/src/test/java/com/practiq/exception/handler/UnsatisfiedArgumentExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/UnsatisfiedArgumentExceptionHandlerTest.java similarity index 93% rename from src/test/java/com/practiq/exception/handler/UnsatisfiedArgumentExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/UnsatisfiedArgumentExceptionHandlerTest.java index d20e9bf..cd31c27 100644 --- a/src/test/java/com/practiq/exception/handler/UnsatisfiedArgumentExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/UnsatisfiedArgumentExceptionHandlerTest.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.core.bind.exceptions.UnsatisfiedArgumentException; import io.micronaut.core.type.Argument; import io.micronaut.http.HttpRequest; diff --git a/src/test/java/com/practiq/exception/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java similarity index 93% rename from src/test/java/com/practiq/exception/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java index 1e68cf3..6701413 100644 --- a/src/test/java/com/practiq/exception/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/UnsatisfiedBodyRouteExceptionHandlerTest.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.core.type.Argument; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/test/java/com/practiq/exception/handler/UnsatisfiedRouteExceptionHandlerTest.java b/src/test/java/com/practiq/web/handler/UnsatisfiedRouteExceptionHandlerTest.java similarity index 96% rename from src/test/java/com/practiq/exception/handler/UnsatisfiedRouteExceptionHandlerTest.java rename to src/test/java/com/practiq/web/handler/UnsatisfiedRouteExceptionHandlerTest.java index 37d249f..5e769ab 100644 --- a/src/test/java/com/practiq/exception/handler/UnsatisfiedRouteExceptionHandlerTest.java +++ b/src/test/java/com/practiq/web/handler/UnsatisfiedRouteExceptionHandlerTest.java @@ -1,10 +1,10 @@ -package com.practiq.exception.handler; +package com.practiq.web.handler; import static io.micronaut.http.HttpStatus.BAD_REQUEST; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; -import com.practiq.dto.response.ErrorResponse; +import com.practiq.web.dto.response.ErrorResponse; import io.micronaut.core.type.Argument; import io.micronaut.http.HttpRequest; import io.micronaut.http.HttpResponse; diff --git a/src/test/java/integration/db/ConceptDatabaseIT.java b/src/test/java/integration/db/ConceptDatabaseIT.java index 90140dc..9bfe4a3 100644 --- a/src/test/java/integration/db/ConceptDatabaseIT.java +++ b/src/test/java/integration/db/ConceptDatabaseIT.java @@ -3,6 +3,8 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.CONCEPT_DESCRIPTION_MAX_LENGTH; +import static utils.data.TestData.CONCEPT_NAME_MAX_LENGTH; import static utils.data.TestDatabase.*; import jakarta.inject.Inject; @@ -100,14 +102,14 @@ void ensureThatNameMustBeUnique() { @Test void ensureThatNameMustNotBeTooLong() { - String name = RandomStringUtils.insecure().nextAlphanumeric(200); + String name = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_NAME_MAX_LENGTH); data.concept().name(name).description("description").insert(); List concepts = data.retrieveConcepts(); DBRow concept = concepts.getFirst(); concept.assertThat("name", equalTo(name)); - String nameTooLong = RandomStringUtils.insecure().nextAlphanumeric(201); + String nameTooLong = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_NAME_MAX_LENGTH + 1); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> data.concept() .name(nameTooLong) .description("description") @@ -116,6 +118,28 @@ void ensureThatNameMustNotBeTooLong() { assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); assertThat( thrown.getCause().getMessage(), - containsString("ERROR: value too long for type character varying(200)")); + containsString("ERROR: value too long for type character varying(" + CONCEPT_NAME_MAX_LENGTH + ")")); + } + + @Test + void ensureThatDescriptionMustNotBeTooLong() { + String description = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_DESCRIPTION_MAX_LENGTH); + data.concept().name("name").description(description).insert(); + + List concepts = data.retrieveConcepts(); + DBRow concept = concepts.getFirst(); + concept.assertThat("description", equalTo(description)); + + String descriptionTooLong = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_DESCRIPTION_MAX_LENGTH + 1); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> data.concept() + .name("another name") + .description(descriptionTooLong) + .insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString( + "ERROR: value too long for type character varying(" + CONCEPT_DESCRIPTION_MAX_LENGTH + ")")); } } diff --git a/src/test/java/integration/db/DocumentDatabaseIT.java b/src/test/java/integration/db/DocumentDatabaseIT.java index bb77bfd..3331c7c 100644 --- a/src/test/java/integration/db/DocumentDatabaseIT.java +++ b/src/test/java/integration/db/DocumentDatabaseIT.java @@ -3,9 +3,10 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.*; import static utils.data.TestDatabase.*; -import com.practiq.domain.types.DocumentStatus; +import com.practiq.foundation.types.DocumentStatus; import jakarta.inject.Inject; import java.time.Instant; import java.util.List; @@ -116,14 +117,14 @@ void ensureThatS3KeyMustBeUnique() { @Test void ensureThatSourceSpecMustNotBeTooLong() { - String sourceSpec = RandomStringUtils.insecure().nextAlphanumeric(255); + String sourceSpec = RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_SOURCE_SPEC_MAX_LENGTH); data.document("s3:path1", "file:path1").sourceSpec(sourceSpec).insert(); List documents = data.retrieveDocuments(); DBRow document = documents.getFirst(); document.assertThat("source_spec", equalTo(sourceSpec)); - String sourceSpecTooLong = RandomStringUtils.insecure().nextAlphanumeric(256); + String sourceSpecTooLong = RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_SOURCE_SPEC_MAX_LENGTH + 1); IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> data.document("s3:path2", "file:path2") .sourceSpec(sourceSpecTooLong) @@ -132,6 +133,48 @@ void ensureThatSourceSpecMustNotBeTooLong() { assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); assertThat( thrown.getCause().getMessage(), - containsString("ERROR: value too long for type character varying(255)")); + containsString( + "ERROR: value too long for type character varying(" + DOCUMENT_SOURCE_SPEC_MAX_LENGTH + ")")); + } + + @Test + void ensureThatFilenameMustNotBeTooLong() { + String filename = RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_FILENAME_MAX_LENGTH); + data.document("s3:path1", filename).insert(); + + List documents = data.retrieveDocuments(); + DBRow document = documents.getFirst(); + document.assertThat("filename", equalTo(filename)); + + String filenameTooLong = RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_FILENAME_MAX_LENGTH + 1); + IllegalStateException thrown = + assertThrows(IllegalStateException.class, () -> data.document("s3:path2", filenameTooLong) + .insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString( + "ERROR: value too long for type character varying(" + DOCUMENT_FILENAME_MAX_LENGTH + ")")); + } + + @Test + void ensureThatS3KeyMustNotBeTooLong() { + String s3Key = RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_S3_KEY_MAX_LENGTH); + data.document(s3Key, "file:path1").insert(); + + List documents = data.retrieveDocuments(); + DBRow document = documents.getFirst(); + document.assertThat("s3_key", equalTo(s3Key)); + + String s3KeyTooLong = RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_S3_KEY_MAX_LENGTH + 1); + IllegalStateException thrown = + assertThrows(IllegalStateException.class, () -> data.document(s3KeyTooLong, "file:path2") + .insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString("ERROR: value too long for type character varying(" + DOCUMENT_S3_KEY_MAX_LENGTH + ")")); } } diff --git a/src/test/java/integration/db/MarkSchemeDatabaseIT.java b/src/test/java/integration/db/MarkSchemeDatabaseIT.java index 598a985..4bade5f 100644 --- a/src/test/java/integration/db/MarkSchemeDatabaseIT.java +++ b/src/test/java/integration/db/MarkSchemeDatabaseIT.java @@ -3,11 +3,13 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.MARK_SCHEME_BODY_MAX_LENGTH; import static utils.data.TestDatabase.*; import jakarta.inject.Inject; import java.time.Instant; import java.util.List; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import utils.IntegrationTest; @@ -116,4 +118,31 @@ void ensureThatBodyMustBeSet() { assertThat(sqlStateOf(thrown), equalTo(NOT_NULL_VIOLATION)); assertThat(thrown.getCause().getMessage(), containsString("\"body\"")); } + + @Test + void ensureThatBodyMustNotBeTooLong() { + long questionId = 1L; + data.question(questionId).insert(); + + String body = RandomStringUtils.insecure().nextAlphanumeric(MARK_SCHEME_BODY_MAX_LENGTH); + data.markScheme(questionId, body).insert(); + + List markSchemes = data.retrieveMarkSchemes(); + DBRow markScheme = markSchemes.getFirst(); + markScheme.assertThat("body", equalTo(body)); + + long otherQuestionId = 2L; + data.question(otherQuestionId).insert(); + + String bodyTooLong = RandomStringUtils.insecure().nextAlphanumeric(MARK_SCHEME_BODY_MAX_LENGTH + 1); + IllegalStateException thrown = + assertThrows(IllegalStateException.class, () -> data.markScheme(otherQuestionId, bodyTooLong) + .insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString( + "ERROR: value too long for type character varying(" + MARK_SCHEME_BODY_MAX_LENGTH + ")")); + } } diff --git a/src/test/java/integration/db/QuestionAttemptDatabaseIT.java b/src/test/java/integration/db/QuestionAttemptDatabaseIT.java index e2d9147..8ba59dd 100644 --- a/src/test/java/integration/db/QuestionAttemptDatabaseIT.java +++ b/src/test/java/integration/db/QuestionAttemptDatabaseIT.java @@ -3,11 +3,14 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.QUESTION_ATTEMPT_BODY_MAX_LENGTH; +import static utils.data.TestData.QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH; import static utils.data.TestDatabase.*; import jakarta.inject.Inject; import java.time.Instant; import java.util.List; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import utils.IntegrationTest; @@ -122,4 +125,53 @@ void ensureThatSessionTokenMustBeSet() { assertThat(sqlStateOf(thrown), equalTo(NOT_NULL_VIOLATION)); assertThat(thrown.getCause().getMessage(), containsString("\"session_token\"")); } + + @Test + void ensureThatBodyMustNotBeTooLong() { + long questionId = 1L; + data.question(questionId).insert(); + + String body = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_BODY_MAX_LENGTH); + data.questionAttempt(questionId, "session-token", body).insert(); + + List attempts = data.retrieveQuestionAttempts(); + DBRow attempt = attempts.getFirst(); + attempt.assertThat("body", equalTo(body)); + + String bodyTooLong = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_BODY_MAX_LENGTH + 1); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> data.questionAttempt( + questionId, "another-session-token", bodyTooLong) + .insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString( + "ERROR: value too long for type character varying(" + QUESTION_ATTEMPT_BODY_MAX_LENGTH + ")")); + } + + @Test + void ensureThatSessionTokenMustNotBeTooLong() { + long questionId = 1L; + data.question(questionId).insert(); + + String sessionToken = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH); + data.questionAttempt(questionId, sessionToken, "body").insert(); + + List attempts = data.retrieveQuestionAttempts(); + DBRow attempt = attempts.getFirst(); + attempt.assertThat("session_token", equalTo(sessionToken)); + + String sessionTokenTooLong = + RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH + 1); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, () -> data.questionAttempt(questionId, sessionTokenTooLong, "body") + .insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString("ERROR: value too long for type character varying(" + + QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH + ")")); + } } diff --git a/src/test/java/integration/db/QuestionDatabaseIT.java b/src/test/java/integration/db/QuestionDatabaseIT.java index 38159bf..78a7636 100644 --- a/src/test/java/integration/db/QuestionDatabaseIT.java +++ b/src/test/java/integration/db/QuestionDatabaseIT.java @@ -3,12 +3,14 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.QUESTION_BODY_MAX_LENGTH; import static utils.data.TestDatabase.*; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import jakarta.inject.Inject; import java.time.Instant; import java.util.List; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import utils.IntegrationTest; @@ -87,4 +89,24 @@ void ensureThatDifficultyMustBeWithinRange() { assertThat(sqlStateOf(thrown), equalTo(CHECK_VIOLATION)); assertThat(thrown.getCause().getMessage(), containsString("difficulty")); } + + @Test + void ensureThatBodyMustNotBeTooLong() { + String body = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_BODY_MAX_LENGTH); + data.question().body(body).insert(); + + List questions = data.retrieveQuestions(); + DBRow question = questions.getFirst(); + question.assertThat("body", equalTo(body)); + + String bodyTooLong = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_BODY_MAX_LENGTH + 1); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> data.question().body(bodyTooLong).insert()); + + assertThat(sqlStateOf(thrown), equalTo(VALUE_TOO_LONG)); + assertThat( + thrown.getCause().getMessage(), + containsString("ERROR: value too long for type character varying(" + QUESTION_BODY_MAX_LENGTH + ")")); + } } diff --git a/src/test/java/integration/db/QuestionOriginDatabaseIT.java b/src/test/java/integration/db/QuestionOriginDatabaseIT.java index a6a56c9..0b3e126 100644 --- a/src/test/java/integration/db/QuestionOriginDatabaseIT.java +++ b/src/test/java/integration/db/QuestionOriginDatabaseIT.java @@ -5,7 +5,7 @@ import static org.junit.jupiter.api.Assertions.assertThrows; import static utils.data.TestDatabase.*; -import com.practiq.domain.types.QuestionAuthorship; +import com.practiq.foundation.types.QuestionAuthorship; import jakarta.inject.Inject; import java.time.Instant; import java.util.List; diff --git a/src/test/java/integration/e2e/AdminDocumentControllerIT.java b/src/test/java/integration/e2e/AdminDocumentControllerIT.java index b5446c7..81cbd13 100644 --- a/src/test/java/integration/e2e/AdminDocumentControllerIT.java +++ b/src/test/java/integration/e2e/AdminDocumentControllerIT.java @@ -7,11 +7,9 @@ import static io.restassured.config.EncoderConfig.encoderConfig; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; -import static utils.data.TestData.ADMIN_KEY; -import static utils.data.TestData.ADMIN_KEY_HEADER; -import static utils.data.TestData.UPLOAD_URL_EXPIRY; +import static utils.data.TestData.*; -import com.practiq.domain.types.DocumentStatus; +import com.practiq.foundation.types.DocumentStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import io.restassured.http.ContentType; @@ -118,7 +116,7 @@ void canPostDocumentThenPutDocumentInS3UsingPresignResponse() throws IOException // The caller is told how long the URL it was handed lasts, so it can fail fast rather than // discovering an expired signature mid-upload. assertThat(expiresAt.isAfter(Instant.now()), equalTo(true)); - assertThat(expiresAt.isBefore(Instant.now().plus(UPLOAD_URL_EXPIRY)), equalTo(true)); + assertThat(expiresAt.isBefore(Instant.now().plus(DOCUMENT_UPLOAD_URL_EXPIRY)), equalTo(true)); given().urlEncodingEnabled(false) // RestAssured appends "; charset=..." to text/* by default, which would not match the signed diff --git a/src/test/java/integration/e2e/MarkSchemeControllerIT.java b/src/test/java/integration/e2e/MarkSchemeControllerIT.java index 59caf6d..122f302 100644 --- a/src/test/java/integration/e2e/MarkSchemeControllerIT.java +++ b/src/test/java/integration/e2e/MarkSchemeControllerIT.java @@ -6,7 +6,7 @@ import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.matchesPattern; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import io.restassured.http.ContentType; @@ -39,8 +39,12 @@ void setUp() { void getMarkSchemeForQuestionIdReturnsNotFoundIfNoQuestionExistsForId() { long conceptId = 10L; data.concept(conceptId).insert(); - servableQuestionWithMarkScheme(7L, conceptId, "Mark scheme for seven."); - servableQuestionWithMarkScheme(8L, conceptId, "Mark scheme for eight."); + data.question(7L).status(QuestionStatus.APPROVED).insert(); + data.link(7L, conceptId).insert(); + data.markScheme(7L, "Mark scheme for seven.").insert(); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); + data.markScheme(8L, "Mark scheme for eight.").insert(); // 9 is neither of the two rows present, so a handler returning any row is caught. String path = MARK_SCHEME_PATH.formatted(9L); @@ -63,7 +67,9 @@ void getMarkSchemeForQuestionIdReturnsNotFoundIfNoApprovedQuestionExistsForId() data.link(rejectedId, conceptId).insert(); data.markScheme(rejectedId, "Mark scheme for the rejected question.").insert(); - servableQuestionWithMarkScheme(8L, conceptId, "Mark scheme for eight."); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); + data.markScheme(8L, "Mark scheme for eight.").insert(); String path = MARK_SCHEME_PATH.formatted(rejectedId); given().when() @@ -86,7 +92,9 @@ void getMarkSchemeForQuestionIdReturnsNotFoundIfNoLinkExistsForId() { data.question(unlinkedId).status(QuestionStatus.APPROVED).insert(); data.markScheme(unlinkedId, "Mark scheme for the unlinked question.").insert(); - servableQuestionWithMarkScheme(8L, conceptId, "Mark scheme for eight."); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); + data.markScheme(8L, "Mark scheme for eight.").insert(); String path = MARK_SCHEME_PATH.formatted(unlinkedId); given().when() @@ -109,7 +117,9 @@ void getMarkSchemeForQuestionIdReturnsNotFoundIfQuestionValidButMarkSchemeDoesNo data.question(noSchemeId).status(QuestionStatus.APPROVED).insert(); data.link(noSchemeId, conceptId).insert(); - servableQuestionWithMarkScheme(8L, conceptId, "Mark scheme for eight."); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); + data.markScheme(8L, "Mark scheme for eight.").insert(); String path = MARK_SCHEME_PATH.formatted(noSchemeId); given().when() @@ -148,10 +158,4 @@ void getMarkSchemeForQuestionIdReturnsMarkScheme() { .body("body", equalTo(body)) .body("createdAt", matchesPattern(data.getInstantPattern())); } - - private void servableQuestionWithMarkScheme(long questionId, long conceptId, String markSchemeBody) { - data.question(questionId).status(QuestionStatus.APPROVED).insert(); - data.link(questionId, conceptId).insert(); - data.markScheme(questionId, markSchemeBody).insert(); - } } diff --git a/src/test/java/integration/e2e/QuestionAttemptControllerIT.java b/src/test/java/integration/e2e/QuestionAttemptControllerIT.java index aefc266..2b022f6 100644 --- a/src/test/java/integration/e2e/QuestionAttemptControllerIT.java +++ b/src/test/java/integration/e2e/QuestionAttemptControllerIT.java @@ -6,7 +6,7 @@ import static org.hamcrest.Matchers.*; import static utils.data.TestData.SESSION_TOKEN_HEADER; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import io.restassured.http.ContentType; diff --git a/src/test/java/integration/e2e/QuestionControllerIT.java b/src/test/java/integration/e2e/QuestionControllerIT.java index 585b541..7fbc72a 100644 --- a/src/test/java/integration/e2e/QuestionControllerIT.java +++ b/src/test/java/integration/e2e/QuestionControllerIT.java @@ -5,9 +5,9 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.*; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import io.restassured.http.ContentType; @@ -199,11 +199,36 @@ void getPagesInStableCreatedAtThenIdOrderAcrossMultiplePages() { // created_at leads; id breaks ties within an equal timestamp. earliest has the highest id but the // earliest time, so it still sorts first — and the day2/day3 pairs prove the id tiebreak. Full // order: [5, 1, 2, 3, 4]. - approvedLinkedQuestion(5L, "Earliest by time.", day1, conceptId); - approvedLinkedQuestion(1L, "Day two, lower id.", day2, conceptId); - approvedLinkedQuestion(2L, "Day two, higher id.", day2, conceptId); - approvedLinkedQuestion(3L, "Day three, lower id.", day3, conceptId); - approvedLinkedQuestion(4L, "Day three, higher id.", day3, conceptId); + data.question(5L) + .status(QuestionStatus.APPROVED) + .body("Earliest by time.") + .createdAt(day1) + .insert(); + data.link(5L, conceptId).insert(); + data.question(1L) + .status(QuestionStatus.APPROVED) + .body("Day two, lower id.") + .createdAt(day2) + .insert(); + data.link(1L, conceptId).insert(); + data.question(2L) + .status(QuestionStatus.APPROVED) + .body("Day two, higher id.") + .createdAt(day2) + .insert(); + data.link(2L, conceptId).insert(); + data.question(3L) + .status(QuestionStatus.APPROVED) + .body("Day three, lower id.") + .createdAt(day3) + .insert(); + data.link(3L, conceptId).insert(); + data.question(4L) + .status(QuestionStatus.APPROVED) + .body("Day three, higher id.") + .createdAt(day3) + .insert(); + data.link(4L, conceptId).insert(); // Walk all three pages at size 2. Each is a contiguous, non-overlapping slice of the one total // order — including the last, partial page at index 2 — so a row can't straddle, repeat or vanish. @@ -249,9 +274,24 @@ void getReturnsTheSameOrderedResultOnRepeatedCalls() { OffsetDateTime sameInstant = OffsetDateTime.parse("2026-01-01T00:00:00Z"); // Equal created_at across all three, so the order is decided purely by the id tiebreak. Repeated // calls must reproduce it identically rather than return an arbitrary (DB-dependent) order. - approvedLinkedQuestion(1L, "One.", sameInstant, conceptId); - approvedLinkedQuestion(2L, "Two.", sameInstant, conceptId); - approvedLinkedQuestion(3L, "Three.", sameInstant, conceptId); + data.question(1L) + .status(QuestionStatus.APPROVED) + .body("One.") + .createdAt(sameInstant) + .insert(); + data.link(1L, conceptId).insert(); + data.question(2L) + .status(QuestionStatus.APPROVED) + .body("Two.") + .createdAt(sameInstant) + .insert(); + data.link(2L, conceptId).insert(); + data.question(3L) + .status(QuestionStatus.APPROVED) + .body("Three.") + .createdAt(sameInstant) + .insert(); + data.link(3L, conceptId).insert(); for (int call = 0; call < 2; call++) { given().when() @@ -269,8 +309,16 @@ void getReturnsTheSameOrderedResultOnRepeatedCalls() { void getQuestionByIdReturnsNotFoundIfNoQuestionExistsForId() { long conceptId = 100L; data.concept(conceptId).insert(); - approvedLinkedQuestion(7L, "Servable question seven.", conceptId); - approvedLinkedQuestion(8L, "Servable question eight.", conceptId); + data.question(7L) + .status(QuestionStatus.APPROVED) + .body("Servable question seven.") + .insert(); + data.link(7L, conceptId).insert(); + data.question(8L) + .status(QuestionStatus.APPROVED) + .body("Servable question eight.") + .insert(); + data.link(8L, conceptId).insert(); // 9 is neither of the two rows present. String path = QUESTIONS_PATH + "/" + 9L; @@ -292,7 +340,11 @@ void getQuestionByIdReturnsNotFoundIfNoApprovedQuestionExistsForId() { data.question(targetId).status(QuestionStatus.REJECTED).insert(); data.link(targetId, conceptId).insert(); - approvedLinkedQuestion(8L, "Servable question eight.", conceptId); + data.question(8L) + .status(QuestionStatus.APPROVED) + .body("Servable question eight.") + .insert(); + data.link(8L, conceptId).insert(); String path = QUESTIONS_PATH + "/" + targetId; given().when() @@ -314,7 +366,11 @@ void getQuestionByIdReturnsNotFoundIfNoLinkExistsForId() { long targetId = 7L; data.question(targetId).status(QuestionStatus.APPROVED).insert(); - approvedLinkedQuestion(8L, "Servable question eight.", conceptId); + data.question(8L) + .status(QuestionStatus.APPROVED) + .body("Servable question eight.") + .insert(); + data.link(8L, conceptId).insert(); String path = QUESTIONS_PATH + "/" + targetId; given().when() @@ -343,7 +399,11 @@ void getQuestionByIdReturnsQuestionIfApprovedAndLinkedQuestionExistsForId() { .insert(); data.link(id, conceptId).insert(); - approvedLinkedQuestion(8L, "Servable question eight.", conceptId); + data.question(8L) + .status(QuestionStatus.APPROVED) + .body("Servable question eight.") + .insert(); + data.link(8L, conceptId).insert(); given().when() .get(QUESTIONS_PATH + "/" + id) @@ -358,18 +418,4 @@ void getQuestionByIdReturnsQuestionIfApprovedAndLinkedQuestionExistsForId() { .body("createdAt", matchesPattern(data.getInstantPattern())) .body("linkedConceptIds", contains((int) conceptId)); } - - private void approvedLinkedQuestion(long id, String body, long conceptId) { - data.question(id).status(QuestionStatus.APPROVED).body(body).insert(); - data.link(id, conceptId).insert(); - } - - private void approvedLinkedQuestion(long id, String body, OffsetDateTime createdAt, long conceptId) { - data.question(id) - .status(QuestionStatus.APPROVED) - .body(body) - .createdAt(createdAt) - .insert(); - data.link(id, conceptId).insert(); - } } diff --git a/src/test/java/integration/repository/ConceptRepositoryIT.java b/src/test/java/integration/repository/ConceptRepositoryIT.java index c6fc887..e9d3412 100644 --- a/src/test/java/integration/repository/ConceptRepositoryIT.java +++ b/src/test/java/integration/repository/ConceptRepositoryIT.java @@ -1,15 +1,14 @@ package integration.repository; import static org.hamcrest.MatcherAssert.assertThat; -import static org.hamcrest.Matchers.contains; -import static org.hamcrest.Matchers.equalTo; -import static org.hamcrest.Matchers.instanceOf; -import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; import static utils.TestReflection.setField; +import static utils.data.TestData.CONCEPT_DESCRIPTION_MAX_LENGTH; +import static utils.data.TestData.CONCEPT_NAME_MAX_LENGTH; -import com.practiq.domain.Concept; -import com.practiq.repository.ConceptRepository; +import com.practiq.persistence.ConceptEntity; +import com.practiq.persistence.repository.ConceptRepository; import jakarta.inject.Inject; import jakarta.persistence.OptimisticLockException; import jakarta.validation.ConstraintViolation; @@ -49,22 +48,22 @@ void listOrderByCreatedAtAscReturnsConceptsOldestFirst() { data.concept().name("Earlier").description("d").createdAt(earlier).insert(); - List concepts = conceptRepository.listOrderByCreatedAtAsc(); + List concepts = conceptRepository.listOrderByCreatedAtAsc(); - assertThat(concepts.stream().map(Concept::getName).toList(), contains("Earlier", "Later")); + assertThat(concepts.stream().map(ConceptEntity::getName).toList(), contains("Earlier", "Later")); } @Test void ensureVersionIncrements() { data.concept(1L).insert(); - Concept concept = conceptRepository.findAll().getFirst(); + ConceptEntity concept = conceptRepository.findAll().getFirst(); assertThat(concept.getVersion(), equalTo(0)); setField(concept, "description", "modified description"); conceptRepository.update(concept); - Concept modifiedConcept = conceptRepository.findAll().getFirst(); + ConceptEntity modifiedConcept = conceptRepository.findAll().getFirst(); assertThat(modifiedConcept.getVersion(), equalTo(1)); } @@ -75,10 +74,10 @@ void ensureVersionIncrements() { void ensureStaleVersionUpdateIsRejected() { data.concept(1L).insert(); - Concept stale = conceptRepository.findAll().getFirst(); + ConceptEntity stale = conceptRepository.findAll().getFirst(); // A concurrent editor wins the race: the row moves to version 1. - Concept current = conceptRepository.findAll().getFirst(); + ConceptEntity current = conceptRepository.findAll().getFirst(); setField(current, "description", "Updated first."); conceptRepository.update(current); @@ -86,27 +85,42 @@ void ensureStaleVersionUpdateIsRejected() { setField(stale, "description", "Updated second, from stale state."); assertThrows(OptimisticLockException.class, () -> conceptRepository.update(stale)); - Concept survivor = conceptRepository.findAll().getFirst(); + ConceptEntity survivor = conceptRepository.findAll().getFirst(); assertThat(survivor.getDescription(), equalTo("Updated first.")); assertThat(survivor.getVersion(), equalTo(1)); } @Test void cannotSaveConceptWithTooLongName() { - // Check 200 chars saves - String validName = RandomStringUtils.insecure().nextAlphanumeric(200); - Concept validConcept = conceptRepository.save(new Concept(validName, "description")); + String validName = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_NAME_MAX_LENGTH); + ConceptEntity validConcept = conceptRepository.save(new ConceptEntity(validName, "description")); assertThat(validConcept.getId(), instanceOf(Long.class)); - // Check 201 chars doesn't save - String name = RandomStringUtils.insecure().nextAlphanumeric(201); - Concept invalidConcept = new Concept(name, "description"); + String name = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_NAME_MAX_LENGTH + 1); + ConceptEntity invalidConcept = new ConceptEntity(name, "description"); ConstraintViolationException thrown = assertThrows(ConstraintViolationException.class, () -> conceptRepository.save(invalidConcept)); Set> constraintViolations = thrown.getConstraintViolations(); assertThat(constraintViolations.size(), is(1)); String message = constraintViolations.stream().findFirst().get().getMessage(); - assertThat(message, equalTo("size must be between 0 and 200")); + assertThat(message, equalTo("size must be between 0 and " + CONCEPT_NAME_MAX_LENGTH)); + } + + @Test + void cannotSaveConceptWithTooLongDescription() { + String validDescription = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_DESCRIPTION_MAX_LENGTH); + ConceptEntity validConcept = conceptRepository.save(new ConceptEntity("name", validDescription)); + assertThat(validConcept.getId(), instanceOf(Long.class)); + + String description = RandomStringUtils.insecure().nextAlphanumeric(CONCEPT_DESCRIPTION_MAX_LENGTH + 1); + ConceptEntity invalidConcept = new ConceptEntity("another name", description); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> conceptRepository.save(invalidConcept)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("size must be between 0 and " + CONCEPT_DESCRIPTION_MAX_LENGTH)); } } diff --git a/src/test/java/integration/repository/DocumentRepositoryIT.java b/src/test/java/integration/repository/DocumentRepositoryIT.java index a622b3e..20c217d 100644 --- a/src/test/java/integration/repository/DocumentRepositoryIT.java +++ b/src/test/java/integration/repository/DocumentRepositoryIT.java @@ -5,9 +5,10 @@ import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; import static utils.TestReflection.setField; +import static utils.data.TestData.*; -import com.practiq.domain.Document; -import com.practiq.repository.DocumentRepository; +import com.practiq.persistence.DocumentEntity; +import com.practiq.persistence.repository.DocumentRepository; import jakarta.inject.Inject; import jakarta.persistence.OptimisticLockException; import jakarta.validation.ConstraintViolation; @@ -37,13 +38,13 @@ void setUp() { void ensureVersionIncrements() { data.document("s3key", "filename").insert(); - Document document = documentRepository.findAll().getFirst(); + DocumentEntity document = documentRepository.findAll().getFirst(); assertThat(document.getVersion(), equalTo(0)); setField(document, "filename", "modified filename"); documentRepository.update(document); - Document modifiedDocument = documentRepository.findAll().getFirst(); + DocumentEntity modifiedDocument = documentRepository.findAll().getFirst(); assertThat(modifiedDocument.getVersion(), equalTo(1)); } @@ -51,10 +52,10 @@ void ensureVersionIncrements() { void ensureStaleVersionUpdateIsRejected() { data.document("s3key", "filename").insert(); - Document stale = documentRepository.findAll().getFirst(); + DocumentEntity stale = documentRepository.findAll().getFirst(); // A concurrent editor wins the race: the row moves to version 1. - Document current = documentRepository.findAll().getFirst(); + DocumentEntity current = documentRepository.findAll().getFirst(); setField(current, "filename", "Updated first."); documentRepository.update(current); @@ -62,7 +63,7 @@ void ensureStaleVersionUpdateIsRejected() { setField(stale, "filename", "Updated second, from stale state."); assertThrows(OptimisticLockException.class, () -> documentRepository.update(stale)); - Document survivor = documentRepository.findAll().getFirst(); + DocumentEntity survivor = documentRepository.findAll().getFirst(); assertThat(survivor.getFilename(), equalTo("Updated first.")); assertThat(survivor.getVersion(), equalTo(1)); } @@ -72,20 +73,81 @@ void cannotSaveDocumentWithTooLongSourceSpec() { data.document("s3key", "filename").insert(); // Check 255 chars saves - Document valid = documentRepository.findAll().getFirst(); - setField(valid, "sourceSpec", RandomStringUtils.insecure().nextAlphanumeric(255)); + DocumentEntity valid = documentRepository.findAll().getFirst(); + setField(valid, "sourceSpec", RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_SOURCE_SPEC_MAX_LENGTH)); documentRepository.update(valid); assertThat(documentRepository.findAll().getFirst().getVersion(), equalTo(1)); // Check 256 chars doesn't save - Document invalid = documentRepository.findAll().getFirst(); - setField(invalid, "sourceSpec", RandomStringUtils.insecure().nextAlphanumeric(256)); + DocumentEntity invalid = documentRepository.findAll().getFirst(); + setField( + invalid, + "sourceSpec", + RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_SOURCE_SPEC_MAX_LENGTH + 1)); ConstraintViolationException thrown = assertThrows(ConstraintViolationException.class, () -> documentRepository.update(invalid)); Set> constraintViolations = thrown.getConstraintViolations(); assertThat(constraintViolations.size(), is(1)); String message = constraintViolations.stream().findFirst().get().getMessage(); - assertThat(message, equalTo("size must be between 0 and 255")); + assertThat(message, equalTo("size must be between 0 and " + DOCUMENT_SOURCE_SPEC_MAX_LENGTH)); + } + + @Test + void cannotSaveDocumentWithTooLongFilename() { + data.document("s3key", "filename").insert(); + + // Check 255 chars saves + DocumentEntity valid = documentRepository.findAll().getFirst(); + setField(valid, "filename", RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_FILENAME_MAX_LENGTH)); + documentRepository.update(valid); + assertThat(documentRepository.findAll().getFirst().getVersion(), equalTo(1)); + + // Check 256 chars doesn't save + DocumentEntity invalid = documentRepository.findAll().getFirst(); + setField(invalid, "filename", RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_FILENAME_MAX_LENGTH + 1)); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> documentRepository.update(invalid)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("size must be between 0 and " + DOCUMENT_FILENAME_MAX_LENGTH)); + } + + @Test + void cannotSaveDocumentWithTooLongS3Key() { + data.document("s3key", "filename").insert(); + + // Check 255 chars saves + DocumentEntity valid = documentRepository.findAll().getFirst(); + setField(valid, "s3Key", RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_S3_KEY_MAX_LENGTH)); + documentRepository.update(valid); + assertThat(documentRepository.findAll().getFirst().getVersion(), equalTo(1)); + + // Check 256 chars doesn't save + DocumentEntity invalid = documentRepository.findAll().getFirst(); + setField(invalid, "s3Key", RandomStringUtils.insecure().nextAlphanumeric(DOCUMENT_S3_KEY_MAX_LENGTH + 1)); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> documentRepository.update(invalid)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("size must be between 0 and " + DOCUMENT_S3_KEY_MAX_LENGTH)); + } + + @Test + void cannotSaveDocumentWithoutStatus() { + DocumentEntity invalidDocument = DocumentEntity.newUpload("s3key", "filename", null); + setField(invalidDocument, "status", null); + + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> documentRepository.save(invalidDocument)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("must not be null")); } } diff --git a/src/test/java/integration/repository/MarkSchemeRepositoryIT.java b/src/test/java/integration/repository/MarkSchemeRepositoryIT.java index 806fdda..a53f7f1 100644 --- a/src/test/java/integration/repository/MarkSchemeRepositoryIT.java +++ b/src/test/java/integration/repository/MarkSchemeRepositoryIT.java @@ -5,12 +5,17 @@ import static org.hamcrest.Matchers.is; import static org.junit.jupiter.api.Assertions.assertThrows; import static utils.TestReflection.setField; +import static utils.data.TestData.MARK_SCHEME_BODY_MAX_LENGTH; -import com.practiq.domain.MarkScheme; -import com.practiq.repository.MarkSchemeRepository; +import com.practiq.persistence.MarkSchemeEntity; +import com.practiq.persistence.repository.MarkSchemeRepository; import jakarta.inject.Inject; import jakarta.persistence.OptimisticLockException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; import java.util.Optional; +import java.util.Set; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import utils.IntegrationTest; @@ -36,13 +41,13 @@ void ensureVersionIncrements() { data.question(questionId).insert(); data.markScheme(questionId, "body").insert(); - MarkScheme markScheme = markSchemeRepository.findAll().getFirst(); + MarkSchemeEntity markScheme = markSchemeRepository.findAll().getFirst(); assertThat(markScheme.getVersion(), equalTo(0)); setField(markScheme, "body", "A modified mark scheme."); markSchemeRepository.update(markScheme); - MarkScheme modifiedMarkScheme = markSchemeRepository.findAll().getFirst(); + MarkSchemeEntity modifiedMarkScheme = markSchemeRepository.findAll().getFirst(); assertThat(modifiedMarkScheme.getVersion(), equalTo(1)); } @@ -52,10 +57,10 @@ void ensureStaleVersionUpdateIsRejected() { data.question(questionId).insert(); data.markScheme(questionId, "body").insert(); - MarkScheme stale = markSchemeRepository.findAll().getFirst(); + MarkSchemeEntity stale = markSchemeRepository.findAll().getFirst(); // A concurrent editor wins the race: the row moves to version 1. - MarkScheme current = markSchemeRepository.findAll().getFirst(); + MarkSchemeEntity current = markSchemeRepository.findAll().getFirst(); setField(current, "body", "Updated first."); markSchemeRepository.update(current); @@ -63,7 +68,7 @@ void ensureStaleVersionUpdateIsRejected() { setField(stale, "body", "Updated second, from stale state."); assertThrows(OptimisticLockException.class, () -> markSchemeRepository.update(stale)); - MarkScheme survivor = markSchemeRepository.findAll().getFirst(); + MarkSchemeEntity survivor = markSchemeRepository.findAll().getFirst(); assertThat(survivor.getBody(), equalTo("Updated first.")); assertThat(survivor.getVersion(), equalTo(1)); } @@ -91,10 +96,33 @@ void findByQuestionIdReturnsMarkSchemeIfMatch() { data.question(8L).insert(); data.markScheme(8L, "Mark scheme for eight.").insert(); - Optional markScheme = markSchemeRepository.findByQuestionId(questionId); + Optional markScheme = markSchemeRepository.findByQuestionId(questionId); assertThat(markScheme.isPresent(), is(true)); assertThat(markScheme.get().getQuestionId(), equalTo(questionId)); assertThat(markScheme.get().getBody(), equalTo(body)); } + + @Test + void cannotSaveMarkSchemeWithTooLongBody() { + long questionId = 7L; + data.question(questionId).insert(); + + String validBody = RandomStringUtils.insecure().nextAlphanumeric(MARK_SCHEME_BODY_MAX_LENGTH); + MarkSchemeEntity validMarkScheme = markSchemeRepository.save(new MarkSchemeEntity(questionId, validBody)); + assertThat(validMarkScheme.getBody(), equalTo(validBody)); + + long otherQuestionId = 8L; + data.question(otherQuestionId).insert(); + + String body = RandomStringUtils.insecure().nextAlphanumeric(MARK_SCHEME_BODY_MAX_LENGTH + 1); + MarkSchemeEntity invalidMarkScheme = new MarkSchemeEntity(otherQuestionId, body); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> markSchemeRepository.save(invalidMarkScheme)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("size must be between 0 and " + MARK_SCHEME_BODY_MAX_LENGTH)); + } } diff --git a/src/test/java/integration/repository/QuestionAttemptRepositoryIT.java b/src/test/java/integration/repository/QuestionAttemptRepositoryIT.java index b5c620e..6dea9f7 100644 --- a/src/test/java/integration/repository/QuestionAttemptRepositoryIT.java +++ b/src/test/java/integration/repository/QuestionAttemptRepositoryIT.java @@ -4,11 +4,13 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; +import static utils.data.TestData.QUESTION_ATTEMPT_BODY_MAX_LENGTH; +import static utils.data.TestData.QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH; -import com.practiq.domain.QuestionAttempt; -import com.practiq.domain.query.attempt.QuestionAttemptQuery; -import com.practiq.domain.query.attempt.QuestionAttemptSpecificationFactory; -import com.practiq.repository.QuestionAttemptRepository; +import com.practiq.persistence.QuestionAttemptEntity; +import com.practiq.persistence.query.attempt.QuestionAttemptQuery; +import com.practiq.persistence.query.attempt.QuestionAttemptSpecificationFactory; +import com.practiq.persistence.repository.QuestionAttemptRepository; import io.micronaut.data.model.Sort; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import jakarta.inject.Inject; @@ -111,9 +113,9 @@ void findAllAppliesTheStableNewestThenIdOrder() { .insert(); QuestionAttemptQuery query = new QuestionAttemptQuery(questionId, sessionToken); - QuerySpecification spec = questionAttemptSpecificationFactory.forQuery(query); + QuerySpecification spec = questionAttemptSpecificationFactory.forQuery(query); - List attempts = questionAttemptRepository.findAll(spec, STABLE_ORDER); + List attempts = questionAttemptRepository.findAll(spec, STABLE_ORDER); // Newest created_at first; the day1 tie is broken by ascending id. assertThat(ids(attempts), contains(day2Attempt, day1LowId, day1HighId)); @@ -127,8 +129,8 @@ void savingAttemptSetsAndReturnsDBDelegatedFields() { String sessionToken = "session-token"; String body = "attempt"; - QuestionAttempt incomingAttempt = new QuestionAttempt(questionId, sessionToken, body); - QuestionAttempt attempt = questionAttemptRepository.save(incomingAttempt); + QuestionAttemptEntity incomingAttempt = new QuestionAttemptEntity(questionId, sessionToken, body); + QuestionAttemptEntity attempt = questionAttemptRepository.save(incomingAttempt); assertThat(attempt.getId(), instanceOf(Long.class)); assertThat(attempt.getQuestionId(), is(questionId)); @@ -145,19 +147,19 @@ void cannotSaveAttemptWithEmptyBody() { String sessionToken = "session-token"; // Check 1 char saves - QuestionAttempt validAttempt = new QuestionAttempt(questionId, sessionToken, "a"); - QuestionAttempt attempt = questionAttemptRepository.save(validAttempt); + QuestionAttemptEntity validAttempt = new QuestionAttemptEntity(questionId, sessionToken, "a"); + QuestionAttemptEntity attempt = questionAttemptRepository.save(validAttempt); assertThat(attempt.getId(), instanceOf(Long.class)); // Check empty doesn't save - QuestionAttempt invalidAttempt = new QuestionAttempt(questionId, sessionToken, ""); + QuestionAttemptEntity invalidAttempt = new QuestionAttemptEntity(questionId, sessionToken, ""); ConstraintViolationException thrown = assertThrows(ConstraintViolationException.class, () -> questionAttemptRepository.save(invalidAttempt)); Set> constraintViolations = thrown.getConstraintViolations(); assertThat(constraintViolations.size(), is(1)); String message = constraintViolations.stream().findFirst().get().getMessage(); - assertThat(message, equalTo("size must be between 1 and 100000")); + assertThat(message, equalTo("must not be blank")); } @Test @@ -167,29 +169,62 @@ void cannotSaveAttemptWithTooLargeBody() { String sessionToken = "session-token"; - // Check 100000 char saves - String validBody = RandomStringUtils.insecure().nextAlphanumeric(100000); - QuestionAttempt validAttempt = new QuestionAttempt(questionId, sessionToken, validBody); - QuestionAttempt attempt = questionAttemptRepository.save(validAttempt); + String validBody = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_BODY_MAX_LENGTH); + QuestionAttemptEntity validAttempt = new QuestionAttemptEntity(questionId, sessionToken, validBody); + QuestionAttemptEntity attempt = questionAttemptRepository.save(validAttempt); assertThat(attempt.getId(), instanceOf(Long.class)); - // Check 100001 chars doesn't save - String body = RandomStringUtils.insecure().nextAlphanumeric(100001); - QuestionAttempt invalidAttempt = new QuestionAttempt(questionId, sessionToken, body); + String body = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_BODY_MAX_LENGTH + 1); + QuestionAttemptEntity invalidAttempt = new QuestionAttemptEntity(questionId, sessionToken, body); ConstraintViolationException thrown = assertThrows(ConstraintViolationException.class, () -> questionAttemptRepository.save(invalidAttempt)); Set> constraintViolations = thrown.getConstraintViolations(); assertThat(constraintViolations.size(), is(1)); String message = constraintViolations.stream().findFirst().get().getMessage(); - assertThat(message, equalTo("size must be between 1 and 100000")); + assertThat(message, equalTo("size must be between 0 and " + QUESTION_ATTEMPT_BODY_MAX_LENGTH)); } - private List findAttempts(QuestionAttemptQuery query) { + private List findAttempts(QuestionAttemptQuery query) { return questionAttemptRepository.findAll(questionAttemptSpecificationFactory.forQuery(query)); } - private static List ids(List attempts) { - return attempts.stream().map(QuestionAttempt::getId).collect(toList()); + private static List ids(List attempts) { + return attempts.stream().map(QuestionAttemptEntity::getId).collect(toList()); + } + + @Test + void cannotSaveAttemptWithBlankSessionToken() { + long questionId = 5L; + data.question(questionId).insert(); + + QuestionAttemptEntity invalidAttempt = new QuestionAttemptEntity(questionId, " ", "body"); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> questionAttemptRepository.save(invalidAttempt)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("must not be blank")); + } + + @Test + void cannotSaveAttemptWithTooLongSessionToken() { + long questionId = 5L; + data.question(questionId).insert(); + + String validToken = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH); + QuestionAttemptEntity validAttempt = new QuestionAttemptEntity(questionId, validToken, "body"); + assertThat(questionAttemptRepository.save(validAttempt).getSessionToken(), equalTo(validToken)); + + String token = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH + 1); + QuestionAttemptEntity invalidAttempt = new QuestionAttemptEntity(questionId, token, "body"); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> questionAttemptRepository.save(invalidAttempt)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("size must be between 0 and " + QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH)); } } diff --git a/src/test/java/integration/repository/QuestionConceptRepositoryIT.java b/src/test/java/integration/repository/QuestionConceptRepositoryIT.java index 584c900..de1e784 100644 --- a/src/test/java/integration/repository/QuestionConceptRepositoryIT.java +++ b/src/test/java/integration/repository/QuestionConceptRepositoryIT.java @@ -4,9 +4,9 @@ import static org.hamcrest.Matchers.containsInAnyOrder; import static org.hamcrest.Matchers.empty; -import com.practiq.domain.projection.QuestionConceptLink; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.repository.QuestionConceptRepository; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.persistence.projection.QuestionConceptLinkProjection; +import com.practiq.persistence.repository.QuestionConceptRepository; import jakarta.inject.Inject; import java.util.List; import java.util.Set; @@ -49,7 +49,7 @@ void findIdsByQuestionIdsReturnsTheConceptPairsForOnlyTheGivenQuestions() { data.link(singleLinked, conceptA).insert(); data.link(outsideTheSet, conceptB).insert(); - List links = + List links = questionConceptRepository.findLinksByQuestionIds(Set.of(multiLinked, singleLinked, unlinked)); // Exactly the pairs for the requested questions: both of the multi-linked question's concepts and @@ -58,9 +58,9 @@ void findIdsByQuestionIdsReturnsTheConceptPairsForOnlyTheGivenQuestions() { assertThat( links, containsInAnyOrder( - new QuestionConceptLink(multiLinked, conceptA), - new QuestionConceptLink(multiLinked, conceptB), - new QuestionConceptLink(singleLinked, conceptA))); + new QuestionConceptLinkProjection(multiLinked, conceptA), + new QuestionConceptLinkProjection(multiLinked, conceptB), + new QuestionConceptLinkProjection(singleLinked, conceptA))); } @Test @@ -70,7 +70,7 @@ void findIdsByQuestionIdsReturnsEmptyWhenNoneOfTheQuestionsAreLinked() { data.question(unlinkedOne).status(QuestionStatus.APPROVED).insert(); data.question(unlinkedTwo).status(QuestionStatus.APPROVED).insert(); - List links = + List links = questionConceptRepository.findLinksByQuestionIds(Set.of(unlinkedOne, unlinkedTwo)); assertThat(links, empty()); diff --git a/src/test/java/integration/repository/QuestionRepositoryIT.java b/src/test/java/integration/repository/QuestionRepositoryIT.java index 47ee91f..35929dc 100644 --- a/src/test/java/integration/repository/QuestionRepositoryIT.java +++ b/src/test/java/integration/repository/QuestionRepositoryIT.java @@ -5,22 +5,27 @@ import static org.hamcrest.Matchers.*; import static org.junit.jupiter.api.Assertions.assertThrows; import static utils.TestReflection.setField; - -import com.practiq.domain.Question; -import com.practiq.domain.query.question.QuestionQuery; -import com.practiq.domain.query.question.QuestionSpecificationFactory; -import com.practiq.domain.types.QuestionDifficulty; -import com.practiq.domain.types.QuestionStatus; -import com.practiq.domain.types.QuestionType; -import com.practiq.repository.QuestionRepository; +import static utils.data.TestData.QUESTION_BODY_MAX_LENGTH; + +import com.practiq.foundation.types.QuestionDifficulty; +import com.practiq.foundation.types.QuestionStatus; +import com.practiq.foundation.types.QuestionType; +import com.practiq.persistence.QuestionEntity; +import com.practiq.persistence.query.question.QuestionQuery; +import com.practiq.persistence.query.question.QuestionSpecificationFactory; +import com.practiq.persistence.repository.QuestionRepository; import io.micronaut.data.model.Pageable; import io.micronaut.data.model.Sort; import io.micronaut.data.repository.jpa.criteria.QuerySpecification; import jakarta.inject.Inject; import jakarta.persistence.OptimisticLockException; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; import java.time.OffsetDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import utils.IntegrationTest; @@ -49,13 +54,13 @@ void setUp() { void ensureVersionIncrements() { data.question().body("A question.").insert(); - Question question = questionRepository.findAll().getFirst(); + QuestionEntity question = questionRepository.findAll().getFirst(); assertThat(question.getVersion(), equalTo(0)); setField(question, "body", "A modified question."); questionRepository.update(question); - Question modifiedQuestion = questionRepository.findAll().getFirst(); + QuestionEntity modifiedQuestion = questionRepository.findAll().getFirst(); assertThat(modifiedQuestion.getVersion(), equalTo(1)); } @@ -66,10 +71,10 @@ void ensureVersionIncrements() { void ensureStaleVersionUpdateIsRejected() { data.question().body("A question.").insert(); - Question stale = questionRepository.findAll().getFirst(); + QuestionEntity stale = questionRepository.findAll().getFirst(); // A concurrent editor wins the race: the row moves to version 1. - Question current = questionRepository.findAll().getFirst(); + QuestionEntity current = questionRepository.findAll().getFirst(); setField(current, "body", "Updated first."); questionRepository.update(current); @@ -77,7 +82,7 @@ void ensureStaleVersionUpdateIsRejected() { setField(stale, "body", "Updated second, from stale state."); assertThrows(OptimisticLockException.class, () -> questionRepository.update(stale)); - Question survivor = questionRepository.findAll().getFirst(); + QuestionEntity survivor = questionRepository.findAll().getFirst(); assertThat(survivor.getBody(), equalTo("Updated first.")); assertThat(survivor.getVersion(), equalTo(1)); } @@ -114,10 +119,10 @@ void pagedQueryReturnsQuestionsInStableCreatedAtThenIdOrder() { QuestionQuery query = baseQuery().conceptId(conceptId).build(); - QuerySpecification spec = questionSpecificationFactory.forQuery(query); + QuerySpecification spec = questionSpecificationFactory.forQuery(query); Pageable ordered = Pageable.from(0, 10, STABLE_ORDER); - List results = questionRepository.findAll(spec, ordered).getContent(); + List results = questionRepository.findAll(spec, ordered).getContent(); assertThat(ids(results), contains(earliestByTime, sameTimeLowId, sameTimeHighId)); } @@ -296,8 +301,10 @@ void forQueryAppliesAllFiltersConjunctively() { void findByIdAndStatusReturnsOptionalEmptyIfNoMatchOnId() { long conceptId = 10L; data.concept(conceptId).insert(); - servableQuestion(7L, conceptId); - servableQuestion(8L, conceptId); + data.question(7L).status(QuestionStatus.APPROVED).insert(); + data.link(7L, conceptId).insert(); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); // 9 is neither of the two rows present. assertThat(findForQuestionId(9L).isPresent(), is(false)); @@ -312,7 +319,8 @@ void findByIdAndStatusReturnsOptionalEmptyIfNotApproved() { data.question(rejectedId).status(QuestionStatus.REJECTED).insert(); data.link(rejectedId, conceptId).insert(); - servableQuestion(8L, conceptId); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); assertThat(findForQuestionId(rejectedId).isPresent(), is(false)); } @@ -325,7 +333,8 @@ void findByIdAndStatusReturnsOptionalEmptyIfNoQuestionConceptLinks() { long unlinkedId = 7L; data.question(unlinkedId).status(QuestionStatus.APPROVED).insert(); - servableQuestion(8L, conceptId); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); assertThat(findForQuestionId(unlinkedId).isPresent(), is(false)); } @@ -336,10 +345,12 @@ void findByIdAndStatusReturnsOptionalQuestionIfMatched() { data.concept(conceptId).insert(); long id = 7L; - servableQuestion(id, conceptId); - servableQuestion(8L, conceptId); + data.question(id).status(QuestionStatus.APPROVED).insert(); + data.link(id, conceptId).insert(); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); - Optional question = findForQuestionId(id); + Optional question = findForQuestionId(id); assertThat(question.isPresent(), is(true)); assertThat(question.get().getId(), is(id)); @@ -354,8 +365,10 @@ void findByIdAndStatusReturnsOptionalQuestionIfMatched() { void existsReturnsTrueIfQuestionIsApprovedAndLinked() { long conceptId = 10L; data.concept(conceptId).insert(); - servableQuestion(7L, conceptId); - servableQuestion(8L, conceptId); + data.question(7L).status(QuestionStatus.APPROVED).insert(); + data.link(7L, conceptId).insert(); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); assertThat(existsForQuestionId(7L), is(true)); } @@ -364,8 +377,10 @@ void existsReturnsTrueIfQuestionIsApprovedAndLinked() { void existsReturnsFalseIfNoQuestionForId() { long conceptId = 10L; data.concept(conceptId).insert(); - servableQuestion(7L, conceptId); - servableQuestion(8L, conceptId); + data.question(7L).status(QuestionStatus.APPROVED).insert(); + data.link(7L, conceptId).insert(); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); assertThat(existsForQuestionId(9L), is(false)); } @@ -379,7 +394,8 @@ void existsReturnsFalseIfQuestionNotApproved() { data.question(rejectedId).status(QuestionStatus.REJECTED).insert(); data.link(rejectedId, conceptId).insert(); - servableQuestion(8L, conceptId); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); assertThat(existsForQuestionId(rejectedId), is(false)); } @@ -393,21 +409,17 @@ void existsReturnsFalseIfQuestionHasNoConceptLinks() { long unlinkedId = 7L; data.question(unlinkedId).status(QuestionStatus.APPROVED).insert(); - servableQuestion(8L, conceptId); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); assertThat(existsForQuestionId(unlinkedId), is(false)); } - private void servableQuestion(long id, long conceptId) { - data.question(id).status(QuestionStatus.APPROVED).insert(); - data.link(id, conceptId).insert(); - } - - private List findQuestions(QuestionQuery query) { + private List findQuestions(QuestionQuery query) { return questionRepository.findAll(questionSpecificationFactory.forQuery(query)); } - private Optional findForQuestionId(long questionId) { + private Optional findForQuestionId(long questionId) { QuestionQuery query = baseQuery().questionId(questionId).build(); return questionRepository.findOne(questionSpecificationFactory.forQuery(query)); } @@ -422,7 +434,39 @@ private static QuestionQuery.QuestionQueryBuilder baseQuery() { return QuestionQuery.builder().status(QuestionStatus.APPROVED).requiresConceptLink(true); } - private static List ids(List questions) { - return questions.stream().map(Question::getId).collect(toList()); + private static List ids(List questions) { + return questions.stream().map(QuestionEntity::getId).collect(toList()); + } + + @Test + void cannotSaveQuestionWithTooLongBody() { + String validBody = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_BODY_MAX_LENGTH); + QuestionEntity validQuestion = questionRepository.save( + new QuestionEntity(validBody, QuestionDifficulty.EASY, QuestionType.MCQ, QuestionStatus.APPROVED)); + assertThat(validQuestion.getBody(), equalTo(validBody)); + + String body = RandomStringUtils.insecure().nextAlphanumeric(QUESTION_BODY_MAX_LENGTH + 1); + QuestionEntity invalidQuestion = + new QuestionEntity(body, QuestionDifficulty.EASY, QuestionType.MCQ, QuestionStatus.APPROVED); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> questionRepository.save(invalidQuestion)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("size must be between 0 and " + QUESTION_BODY_MAX_LENGTH)); + } + + @Test + void cannotSaveQuestionWithoutStatus() { + QuestionEntity invalidQuestion = + new QuestionEntity("A question.", QuestionDifficulty.EASY, QuestionType.MCQ, null); + ConstraintViolationException thrown = + assertThrows(ConstraintViolationException.class, () -> questionRepository.save(invalidQuestion)); + + Set> constraintViolations = thrown.getConstraintViolations(); + assertThat(constraintViolations.size(), is(1)); + String message = constraintViolations.stream().findFirst().get().getMessage(); + assertThat(message, equalTo("must not be null")); } } diff --git a/src/test/java/performance/AdminDocumentPostPT.java b/src/test/java/performance/AdminDocumentPostPT.java index 7e973c7..e65c40e 100644 --- a/src/test/java/performance/AdminDocumentPostPT.java +++ b/src/test/java/performance/AdminDocumentPostPT.java @@ -26,8 +26,6 @@ public class AdminDocumentPostPT { private static final String ADMIN_DOCUMENTS_PATH = "/api/v1/admin/documents"; - // One INSERT. The @Generated read-back of id/created_at rides Postgres's `insert … returning`, so a - // second statement appearing means that mechanism broke. Presigning is local and touches no database. private static final long EXPECTED_STATEMENTS = 1L; @Inject diff --git a/src/test/java/performance/ConceptByIdPT.java b/src/test/java/performance/ConceptByIdPT.java index c12aa5b..b16c761 100644 --- a/src/test/java/performance/ConceptByIdPT.java +++ b/src/test/java/performance/ConceptByIdPT.java @@ -15,13 +15,11 @@ import utils.StatementCounter; import utils.data.TestData; -// Pins the JDBC statement count for serving a single concept by id. @PerformanceTest public class ConceptByIdPT { private static final String CONCEPTS_PATH = "/api/v1/concepts"; - // A single findById. private static final long EXPECTED_STATEMENTS = 1L; @Inject diff --git a/src/test/java/performance/ConceptCataloguePT.java b/src/test/java/performance/ConceptCataloguePT.java index 704e30e..dbcf28e 100644 --- a/src/test/java/performance/ConceptCataloguePT.java +++ b/src/test/java/performance/ConceptCataloguePT.java @@ -23,7 +23,6 @@ public class ConceptCataloguePT { private static final String CONCEPTS_PATH = "/api/v1/concepts"; - // A single unpaged SELECT — no count query, and no associations to resolve. private static final long EXPECTED_STATEMENTS = 1L; @Inject @@ -46,7 +45,9 @@ void setUp() { @Test void servingTheConceptListFiresAConstantNumberOfStatements() { - insertConcepts(3); + for (long id = 1; id <= 3; id++) { + data.concept(id).insert(); + } long count = statements.countDuring( () -> given().when().get(CONCEPTS_PATH).then().statusCode(OK.getCode())); @@ -56,24 +57,21 @@ void servingTheConceptListFiresAConstantNumberOfStatements() { @Test void servingMoreConceptsDoesNotFireMoreStatements() { - insertConcepts(2); + for (long id = 1; id <= 2; id++) { + data.concept(id).insert(); + } long fewer = statements.countDuring( () -> given().when().get(CONCEPTS_PATH).then().statusCode(OK.getCode())); data.clear(); - insertConcepts(6); + for (long id = 1; id <= 6; id++) { + data.concept(id).insert(); + } long more = statements.countDuring( () -> given().when().get(CONCEPTS_PATH).then().statusCode(OK.getCode())); - // Nothing caps this endpoint's rows, so cost must be independent of how many concepts exist. assertThat(more, equalTo(fewer)); } - - private void insertConcepts(int count) { - for (long id = 1; id <= count; id++) { - data.concept(id).insert(); - } - } } diff --git a/src/test/java/performance/MarkSchemePT.java b/src/test/java/performance/MarkSchemePT.java index ea99c43..c4ae745 100644 --- a/src/test/java/performance/MarkSchemePT.java +++ b/src/test/java/performance/MarkSchemePT.java @@ -5,7 +5,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import jakarta.inject.Inject; @@ -16,13 +16,11 @@ import utils.StatementCounter; import utils.data.TestData; -// Pins the JDBC statement count for serving a mark scheme. @PerformanceTest public class MarkSchemePT { private static final String MARK_SCHEME_PATH = "/api/v1/questions/%s/mark-scheme"; - // The exists(spec) visibility gate + findByQuestionId. private static final long EXPECTED_STATEMENTS = 2L; @Inject @@ -51,8 +49,12 @@ void servingAMarkSchemeFiresAConstantNumberOfStatements() { // Two servable rows, each with a mark scheme: the statement count must be a property of the query // plan, not of the target happening to be the only row in the table. long questionId = 7L; - servableQuestionWithMarkScheme(questionId, conceptId, "Award 1 mark for stating the law."); - servableQuestionWithMarkScheme(8L, conceptId, "Mark scheme for eight."); + data.question(questionId).status(QuestionStatus.APPROVED).insert(); + data.link(questionId, conceptId).insert(); + data.markScheme(questionId, "Award 1 mark for stating the law.").insert(); + data.question(8L).status(QuestionStatus.APPROVED).insert(); + data.link(8L, conceptId).insert(); + data.markScheme(8L, "Mark scheme for eight.").insert(); long count = statements.countDuring(() -> given().when() .get(MARK_SCHEME_PATH.formatted(questionId)) @@ -61,10 +63,4 @@ void servingAMarkSchemeFiresAConstantNumberOfStatements() { assertThat(count, equalTo(EXPECTED_STATEMENTS)); } - - private void servableQuestionWithMarkScheme(long id, long conceptId, String markSchemeBody) { - data.question(id).status(QuestionStatus.APPROVED).body("Question " + id).insert(); - data.link(id, conceptId).insert(); - data.markScheme(id, markSchemeBody).insert(); - } } diff --git a/src/test/java/performance/QuestionAttemptPT.java b/src/test/java/performance/QuestionAttemptPT.java index 3e52e19..371d6bd 100644 --- a/src/test/java/performance/QuestionAttemptPT.java +++ b/src/test/java/performance/QuestionAttemptPT.java @@ -6,7 +6,7 @@ import static org.hamcrest.Matchers.equalTo; import static utils.data.TestData.SESSION_TOKEN_HEADER; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import io.restassured.http.Header; @@ -18,15 +18,11 @@ import utils.StatementCounter; import utils.data.TestData; -// Pins the JDBC statement count for serving a session's question attempts — a row-scaling path. Beyond the -// fixed happy-path count it asserts the count does NOT grow with the number of attempts, which is the -// property an eager association (N+1) would break. @PerformanceTest public class QuestionAttemptPT { private static final String QUESTION_ATTEMPTS_PATH = "/api/v1/questions/%s/attempts"; - // The exists(spec) visibility gate + the findAll(spec, sort) attempt fetch. private static final long EXPECTED_STATEMENTS = 2L; @Inject @@ -97,7 +93,6 @@ void servingMoreAttemptsDoesNotFireMoreStatements() { .then() .statusCode(OK.getCode())); - // The count is a property of the query plan, not the row count: more attempts, same statements. assertThat(more, equalTo(fewer)); } } diff --git a/src/test/java/performance/QuestionAttemptPostPT.java b/src/test/java/performance/QuestionAttemptPostPT.java index 9b2448b..ad56f3e 100644 --- a/src/test/java/performance/QuestionAttemptPostPT.java +++ b/src/test/java/performance/QuestionAttemptPostPT.java @@ -6,7 +6,7 @@ import static org.hamcrest.Matchers.equalTo; import static utils.data.TestData.SESSION_TOKEN_HEADER; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import io.restassured.http.ContentType; diff --git a/src/test/java/performance/QuestionByIdPT.java b/src/test/java/performance/QuestionByIdPT.java index 377045c..cb44fdf 100644 --- a/src/test/java/performance/QuestionByIdPT.java +++ b/src/test/java/performance/QuestionByIdPT.java @@ -5,7 +5,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import jakarta.inject.Inject; @@ -16,13 +16,11 @@ import utils.StatementCounter; import utils.data.TestData; -// Pins the JDBC statement count for serving a single question by id. @PerformanceTest public class QuestionByIdPT { private static final String QUESTIONS_PATH = "/api/v1/questions"; - // findOne(spec) + the concept-link stitch. private static final long EXPECTED_STATEMENTS = 2L; @Inject @@ -51,17 +49,20 @@ void servingAQuestionByIdFiresAConstantNumberOfStatements() { // Two servable rows: the statement count must be a property of the query plan, not of the target // happening to be the only row in the table. long questionId = 7L; - servableQuestion(questionId, conceptId, "State Newton's first law."); - servableQuestion(8L, conceptId, "Servable question eight."); + data.question(questionId) + .status(QuestionStatus.APPROVED) + .body("State Newton's first law.") + .insert(); + data.link(questionId, conceptId).insert(); + data.question(8L) + .status(QuestionStatus.APPROVED) + .body("Servable question eight.") + .insert(); + data.link(8L, conceptId).insert(); long count = statements.countDuring(() -> given().when().get(QUESTIONS_PATH + "/" + questionId).then().statusCode(OK.getCode())); assertThat(count, equalTo(EXPECTED_STATEMENTS)); } - - private void servableQuestion(long id, long conceptId, String body) { - data.question(id).status(QuestionStatus.APPROVED).body(body).insert(); - data.link(id, conceptId).insert(); - } } diff --git a/src/test/java/performance/QuestionCataloguePT.java b/src/test/java/performance/QuestionCataloguePT.java index a2ff97a..8a153a3 100644 --- a/src/test/java/performance/QuestionCataloguePT.java +++ b/src/test/java/performance/QuestionCataloguePT.java @@ -5,7 +5,7 @@ import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.equalTo; -import com.practiq.domain.types.QuestionStatus; +import com.practiq.foundation.types.QuestionStatus; import io.micronaut.runtime.server.EmbeddedServer; import io.restassured.RestAssured; import jakarta.inject.Inject; @@ -16,15 +16,11 @@ import utils.StatementCounter; import utils.data.TestData; -// Pins the JDBC statement count for serving the catalogue — the hot, row-scaling path. As well as a fixed -// happy-path count it asserts the count does NOT grow with the number of rows, which is the property an -// eager association (N+1) would break. @PerformanceTest public class QuestionCataloguePT { private static final String QUESTIONS_PATH = "/api/v1/questions"; - // Paged content SELECT + the page COUNT + the single concept-link stitch query. private static final long EXPECTED_STATEMENTS = 3L; @Inject @@ -49,7 +45,13 @@ void setUp() { void servingTheCatalogueFiresAConstantNumberOfStatements() { long conceptId = 100L; data.concept(conceptId).insert(); - insertApprovedLinkedQuestions(3, conceptId); + for (long id = 1; id <= 3; id++) { + data.question(id) + .status(QuestionStatus.APPROVED) + .body("Question " + id) + .insert(); + data.link(id, conceptId).insert(); + } long count = statements.countDuring( () -> given().when().get(QUESTIONS_PATH).then().statusCode(OK.getCode())); @@ -61,29 +63,30 @@ void servingTheCatalogueFiresAConstantNumberOfStatements() { void servingMoreRowsDoesNotFireMoreStatements() { long conceptId = 100L; data.concept(conceptId).insert(); - insertApprovedLinkedQuestions(2, conceptId); + for (long id = 1; id <= 2; id++) { + data.question(id) + .status(QuestionStatus.APPROVED) + .body("Question " + id) + .insert(); + data.link(id, conceptId).insert(); + } long fewer = statements.countDuring( () -> given().when().get(QUESTIONS_PATH + "?size=50").then().statusCode(OK.getCode())); data.clear(); data.concept(conceptId).insert(); - insertApprovedLinkedQuestions(6, conceptId); - - long more = statements.countDuring( - () -> given().when().get(QUESTIONS_PATH + "?size=50").then().statusCode(OK.getCode())); - - // The count is a property of the query plan, not the row count: three times the rows, same statements. - assertThat(more, equalTo(fewer)); - } - - private void insertApprovedLinkedQuestions(int count, long conceptId) { - for (long id = 1; id <= count; id++) { + for (long id = 1; id <= 6; id++) { data.question(id) .status(QuestionStatus.APPROVED) .body("Question " + id) .insert(); data.link(id, conceptId).insert(); } + + long more = statements.countDuring( + () -> given().when().get(QUESTIONS_PATH + "?size=50").then().statusCode(OK.getCode())); + + assertThat(more, equalTo(fewer)); } } diff --git a/src/test/java/utils/data/TestData.java b/src/test/java/utils/data/TestData.java index f9418e4..40e0049 100644 --- a/src/test/java/utils/data/TestData.java +++ b/src/test/java/utils/data/TestData.java @@ -1,6 +1,6 @@ package utils.data; -import com.practiq.domain.types.*; +import com.practiq.foundation.types.*; import jakarta.inject.Singleton; import java.time.Duration; import java.time.OffsetDateTime; @@ -17,10 +17,19 @@ public class TestData { public static final String ADMIN_KEY = "b10ef800-3a3a-4395-9bbd-bfe2fa316872"; - // Stated independently of the application's DocumentUploadRules so a change to either side is a - // test failure rather than a silent move: these pin the product limits, they do not follow them. - public static final int MAX_UPLOAD_CONTENT_LENGTH = 26214400; // 25Mb - public static final Duration UPLOAD_URL_EXPIRY = Duration.ofMinutes(10); + // Stated independently of the application's own constants so a change to either side is a test + // failure rather than a silent move: these pin the limits, they do not follow them. + public static final int DOCUMENT_UPLOAD_MAX_CONTENT_LENGTH = 26214400; // 25Mb + public static final Duration DOCUMENT_UPLOAD_URL_EXPIRY = Duration.ofMinutes(10); + public static final int DOCUMENT_FILENAME_MAX_LENGTH = 255; + public static final int DOCUMENT_SOURCE_SPEC_MAX_LENGTH = 255; + public static final int DOCUMENT_S3_KEY_MAX_LENGTH = 255; + public static final int CONCEPT_NAME_MAX_LENGTH = 200; + public static final int CONCEPT_DESCRIPTION_MAX_LENGTH = 500; + public static final int MARK_SCHEME_BODY_MAX_LENGTH = 10000; + public static final int QUESTION_BODY_MAX_LENGTH = 10000; + public static final int QUESTION_ATTEMPT_BODY_MAX_LENGTH = 25000; + public static final int QUESTION_ATTEMPT_SESSION_TOKEN_MAX_LENGTH = 64; private static final String CONCEPT_TABLE = "concept"; private static final String QUESTION_TABLE = "question"; From ff4c97ab01e362f1fb5f33348f36838d07d295de Mon Sep 17 00:00:00 2001 From: jamiewmeldrum Date: Sun, 16 Aug 2026 19:42:21 +0100 Subject: [PATCH 2/2] Bring the project context in line with the layering refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md described a codebase that no longer exists: api/ for controllers, root dto/, entities without the Entity suffix, LinkedQuestion as a projection and a runner that owned its policy. A standing instruction that is wrong is worse than one that is missing, because the next session rebuilds what was removed. Two rules change rather than move. Editing a migration is bounded by the merge and not the commit, so an unmerged branch appends to its newest V file — a local dev database is not somewhere that matters. And a performance pin no longer owes a comment saying what its number is made of: that restates the code and goes stale, leaving a correct number with a false explanation after one refactor. --- CLAUDE.md | 163 ++++++++++++++++++++++++++++++++---------------------- 1 file changed, 98 insertions(+), 65 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5a00430..d9c9510 100755 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -113,39 +113,49 @@ TARGET (Phase 4+ only): API → SQS → practiq-processor → extractor Lambda ``` com.practiq - api/ REST controllers (thin — no business logic) - service/ business logic (@Singleton) - domain/ JPA entities - domain/projection/ read projections (LinkedQuestion, QuestionConceptLink) — never entities - domain/query/ QuestionQuery (value object) + specification factory + query runner + policies - repository/ Micronaut Data repositories - ai/ AIService interface + StubAIService + ClaudeAIService - ingestion/ async ingestion job + extractor HTTP client (Sprint 1.2+) - config/ configuration - util/ cross-cutting helpers used across layers (e.g. formatting, ids) - dto/ request/response records — never expose entities - dto/mapper/ static mapper methods (ConceptResponseMapper, QuestionResponseMapper, …) + web/ HTTP adapter — controller, dto/request, dto/response, dto/mapper, dto/converter, + handler (the 11 exception handlers), binder, plus AdminKeyValidator and HttpConstants + service/ business logic (@Singleton) — UserRef, plus a package per feature (concept, + markscheme, question, attempt, document), each with dto/request, dto/response and a + mapper as needed; question also has a policy package + persistence/ the *Entity types at the top level, plus converter, projection, query, repository + storage/ S3 access (presign, put/get); no product knowledge — endpoint/expiry arrive as data + foundation/ cross-cutting, no layer of its own — types (enums), exception, util + + Import direction (the point of the layering): web -> service -> persistence. + persistence and storage import nothing of ours except foundation; foundation imports + nothing of ours at all. (ai/ and ingestion/ — the AIService and the async job — land with + the extractor/pipeline in Sprint 1.1/1.2, under service.) ``` -- **`StudentQuestionQueryRunner` owns student question retrieval.** Question repository calls live in the - runner, never in services; a service that needs question data injects the concrete runner. The split: - - **`QuestionQueryRunner

`** (`domain/query`, abstract, package-private) owns - the *mechanics* once — `exists` / `findById` / paged retrieval, the two-query concept stitch, the stable - `(created_at, id)` order. Returns domain projections, never DTOs. - - **`StudentQuestionQueryPolicy`** (`domain/query`, package-private) owns the *rule* — imposes - `status = APPROVED` + concept-link; request filters pass through. - - **`StudentQuestionQueryRunner`** (`@Singleton`) binds runner to policy; services inject this concrete type. - - **`QuestionQuery`** is a neutral value object with a **public** builder — see the D-024 note below. - The admin review path (Sprint 1.3) is a new policy + a ~10-line runner subclass, not a parallel manager. -- **D-024 enforcement is conventional, not structural — say so.** The runner is the blessed constructor and its - name makes the intended path obvious, but `QuestionSpecificationFactory` is public and takes a `QuestionQuery`, - so a caller in `service` *can* hand-build a policy-free query and reach the factory directly. That path is - prevented by the runner's naming and by code review, **not by the compiler**. Full insulation (package-gated - builder) was affordable and declined: it fought test ergonomics (tests need diverse query shapes) to defend - against a caller review already catches. A deliberate trade — but the guarantee is conventional, and the doc - must not call it structural (D-024 already made that error once). -- **`LinkedQuestion`** (`domain/projection/`) — `Question` + `Set`; the read path's - assembly type, consumed by `QuestionResponseMapper`. +- **Entities live in `persistence` and take an `Entity` suffix** (`ConceptEntity`, `QuestionEntity`, + `DocumentEntity`, …); the bare noun (`Concept`, `Question`, …) is the service-layer response record. **The service + never exposes a persisted type** — no carve-out for "flat" entities, because an exception list is what people fail to + remember. +- **The question read path:** + `QuestionController → QuestionService → QuestionAccessor → QuestionQueryRunner → QuestionRepository`. + - **`QuestionQueryRunner`** (`persistence/query/question`, concrete `@Singleton`, no generic parameter, no policy) + owns the query mechanics once — `exists(query)`, `findAll(query)`, `findPage(query, pageable)`, the specification, + the stable `(created_at, id)` order and the two-query concept stitch. Returns `QuestionWithConceptIds` (a + persistence read type beside the runner that builds it), never a DTO. `findOne` became `findAll` because the + runner can no longer assume cardinality. + - **`QuestionAccessor`** (`service/question`) is the **only** place a policy is applied. It holds a + `QuestionQueryPolicy`, built per audience by `QuestionAccessorFactory` and injected by qualifier ( + `@Named("student")`), and it enforces the cardinality expectation — throwing if more than one row comes back. Mark + scheme and attempts reach question visibility through this same shared accessor, never by calling + `QuestionService` (which would be a bidirectional service dependency that fails at construction). + - The admin review path (Sprint 1.3) is a new policy + a factory binding, not a parallel manager. +- **Accessors exist only where there is a policy to apply or a cardinality expectation to enforce.** Exactly one + exists (`QuestionAccessor`). Concept and mark scheme use named repository finders directly; attempts build a two-field + query in the service. Don't add an accessor where there is nothing to guard. +- **The D-024 guarantee (the student serving policy can't be bypassed) is *conventional*, not structural, and cannot be + otherwise.** Java can't express "only this class in another package may construct you" without a module system, so the + choice was rule-in-persistence (structural, wrong owner) or rule-in-service (right owner, conventional). The service + won; the per-DoD integration tests are the net. It is now conventional *in the service*, not conventional *in a + runner's name*. +- **Projections:** `QuestionConceptLinkProjection` is the only genuine projection. `LinkedQuestion` is gone — no query + returned its shape, so it was an assembly wearing a projection's name; the runner returns `QuestionWithConceptIds` + instead. - **Mappers are static methods, no interface.** A method reference already satisfies `Function` and every call site knows both types statically, so polymorphism has no call site. **Trigger to revisit:** the day a mapper needs a collaborator (e.g. resolving `{{s3:key}}` to presigned URLs) it must become an injected @@ -157,13 +167,13 @@ com.practiq exam_board: id, name -- AQA | OCR | Edexcel | Generic specification: id, exam_board_id, subject, level, name, version -- level: gcse|a_level spec_section: id, specification_id, parent_section_id NULL, name, section_ref, sort_order -concept: id, name, description, created_at -- board-agnostic, granular ("Diffraction") +concept: id, name, description varchar(500), created_at -- board-agnostic, granular ("Diffraction") spec_section_concept: spec_section_id, concept_id -- "AQA 6.2 covers Diffraction…" question_concept: question_id, concept_id note_concept: note_id, concept_id -question: id, version (optimistic lock), body (TEXT, not null — Markdown, images via {{s3:key}}, MCQ via - [ ]/- [x]), +question: id, version (optimistic lock), body (varchar(10000), not null — Markdown, images via {{s3:key}}, MCQ via - [ ]/- [x]), difficulty int (nullable, 1-5), type (nullable — SHORT_ANSWER|EXTENDED|MCQ), status (not null, default PENDING — PENDING|APPROVED|REJECTED), @@ -209,7 +219,7 @@ question_origin: id, question_id (FK → question, not null, UNIQUE, ON DELETE C -- Field named `authorship` (not kind/source) — D-041. Shipped Sprint 0.3 (tickets 2+3). mark_scheme: id, question_id (FK, not null, UNIQUE — 1:1), version (optimistic lock), - body (TEXT, not null — Markdown, {{s3:key}} refs), created_at + body (varchar(10000), not null — Markdown, {{s3:key}} refs), created_at -- Separate entity from question (D-018): different edit lifecycle. -- Mark schemes get tweaked in normal review; question type/status are frozen once set. Don't share a row / lock token / -- contact surface across two workflows that don't move together. @@ -218,10 +228,12 @@ mark_scheme: id, question_id (FK, not null, UNIQUE — 1:1), version (optimistic note: id, title, s3_key, level, status, created_at -question_attempt: id, question_id, session_token, attempt_body, created_at +question_attempt: id, question_id, session_token varchar(64), attempt_body varchar(25000), created_at -- NO unique constraint on (question_id, session_token). -- Repeated attempts are the revision loop, not a duplicate. See D-021. - -- Built in Sprint 0.2. + -- Built in Sprint 0.2. Widths (V10): attempt_body varchar(25000), + -- session_token varchar(64) — each mirrored by @Size + a test pin; + -- session_token bounded because it is client-supplied and indexed. question_attempt_feedback: id, question_attempt_id, source (SELF|AI), self_score int NULL, max_score int NULL, -- SELF only @@ -239,7 +251,9 @@ question_attempt_feedback: id, question_attempt_id, source (SELF|AI), **Flyway conventions:** - Migrations live in `src/main/resources/db/migration/`, named `V__description.sql` -- Never edit a migration that has already been applied — add a new one +- Never edit a migration that has run somewhere that matters — but the boundary is the **merge, not the commit**: on an + unmerged feature branch, append to the newest `V` file rather than adding another (a local dev database is not + somewhere that matters). Once merged, a migration is immutable — add a new one. - Flyway owns the schema; it never owns content data - `src/main/resources/db/seed_local.sql` exists for manual local data loading only — it is intentionally outside `db/migration/` so Flyway ignores it. Load it manually via `docker exec`. It will eventually move to a dedicated @@ -273,8 +287,13 @@ Conventions: versioned routes, validated DTOs, correct status codes (200/201/400 `{"error": "...", "status": n}` on **all** failure paths, never leak internals. Anonymous sessions = client-generated UUID via `X-Session-Token`; no server session state. -**Outstanding (Sprint 0.1):** only 404 currently returns the structured envelope. 400/422/500 need a global exception -handler before the DoD is met. +**Request validation (applied on every route):** a path `id` below 1 → **422** (not 404); non-numeric `page`/`size` → * +*400**; out-of-range paging values → **422**; a `size` above the configured max → **422** with the ceiling named (never +silently clamped); any `sort` parameter → **422** refused (the runners impose a total order, so no client sort can be +honoured — a reordered 200 was a silent lie); a blank or over-64-char `X-Session-Token` → **422** at the controller. +Paging past the end stays a valid request with an empty envelope. Nothing consumes the API yet, so none of these break a +client. (The full 400/404/422/500 structured envelope is in place — 11 exception handlers under `web/handler`, error +shape composed once in `web/dto/mapper`.) ## 8. Ingestion pipeline (in-process for now) @@ -335,7 +354,7 @@ which is true of all three; "integration" is a conceptual promise the suffix nev - **ITs are the DoD in executable form.** An endpoint owes **one IT per clause of what you said you'd build** — not one per predicate, code path, or data condition. If the DoD doesn't name a case, it doesn't earn an IT. (D-018's DoD names both mark-scheme 404 arms, so both get one.) -- **The runner is the unit-test entry point.** A runner (e.g. `StudentQuestionQueryRunner`) is tested as one +- **The runner is the unit-test entry point.** A runner (e.g. `QuestionQueryRunner`) is tested as one collaborative unit with its **real** internal parts inside it — policy, specification factory — mocking only the database boundary. Internal classes are implementation details tested *through* the runner, not seams that each earn a test file. **A seam is not automatically a test boundary**: a test file per internal @@ -371,6 +390,13 @@ which is true of all three; "integration" is a conceptual promise the suffix nev complete a symmetry. - **No data setup in `setUp()`** beyond `data.clear()` and port/counter wiring. Each test inserts exactly what it depends on — globally-seeded data hides what a test actually relies on. +- **Test fixtures insert their own rows inline.** No test helper inserts anything — a helper may build and return a + value, but the `insert` belongs at the call site, where the test can see what it depends on. +- **Proving a policy-bound collaborator was actually wired in.** A unit test constructs the collaborator itself, so it + can't prove the *right* one was injected. `QuestionServiceAccessorCT` starts the real context, mocks only the + repository, and resolves the specification the service caused through `utils.CriteriaProbe`, asserting the + restrictions it carries. `CriteriaProbe` proves which criteria calls were made, never which rows come back — which is + why the specification factories are proven in the repository ITs instead. - **Statics only for values that must change together** (endpoint paths, pinned statement counts, stub sentinels). Test data values are per-test locals. - **Tests never import an application constant.** A test asserting against a production constant (e.g. @@ -410,7 +436,8 @@ which is true of all three; "integration" is a conceptual promise the suffix nev - **Both assertions are needed.** Invariance catches an N+1 (count scales with rows). The absolute pin catches a fetch-join reintroduction — which invariance sails past (count stays constant) and the correctness ITs also pass (in-memory paging still returns correct rows). The magic numbers are deliberate friction: update the pin - consciously when a query is legitimately added, with a comment saying what the number is made of. Don't + consciously when a query is legitimately added, and no more — do NOT add a comment restating what the number is made + of; it restates the code and goes stale, leaving a correct number with a false explanation after one refactor. Don't decompose it into named constants — that asserts a composition which stops being true once branching exists. - **Current pins:** `/concepts` 1 · `/concepts/{id}` 1 · `/questions` 3 · `/questions/{id}` 2 · `/questions/{id}/mark-scheme` 2 · `/questions/{id}/attempts` GET 2 (+ row-count invariance) · POST 2. @@ -455,10 +482,11 @@ tests are owed per *new query*, not per endpoint. stitch: paged root query, then a keyed id-pair projection, stitched onto DTOs. See D-025. - **Use a correlated `EXISTS` subquery, not a join, to filter on a to-many.** A join multiplies rows and corrupts `totalCount`. See D-026. -- **The student serving policy lives in `StudentQuestionQueryPolicy`, not in a controller filter.** It imposes - `status=APPROVED` + concept-linked; request filters pass through. `status` is never a client parameter. Filters - narrow, never widen. *(The `QuestionQuery.studentCatalogue(...)` named constructors are gone — removed in the D-033 - refactor. Enforcement is **conventional**, not structural: see the D-024 note in §5.)* See D-024 as amended, D-033. +- **The student serving policy lives in `StudentQuestionQueryPolicy` (`service/question/policy`), applied + in `QuestionAccessor`, not in a controller filter.** It imposes `status=APPROVED` + concept-linked; request filters + pass through. `status` is never a client parameter. Filters narrow, never widen. *( + The `QuestionQuery.studentCatalogue(...)` named constructors are gone — removed in the D-033 refactor. Enforcement + is **conventional**, not structural: see the D-024 note in §5.)* See D-024 as amended, D-033. - **Write endpoints bind their payload with `@Body`, never `@RequestBean`.** `@RequestBean` binds fields individually, so a write endpoint would silently accept its payload as a query parameter — putting user content into access logs, proxy logs, browser history and `Referer` headers. `@RequestBean` stays correct for read endpoints assembling filter @@ -468,22 +496,24 @@ tests are owed per *new query*, not per endpoint. D-023. - **Framework paging behaviour is pinned once in `PagingCT`.** A new paginated endpoint adds an *ordering* IT only — never a fresh paging suite. See D-029. -- **Cross-aggregate references are by scalar id, never associations.** `MarkScheme` holds `long questionId`, not - `@OneToOne Question`; `Question` holds no reference to `MarkScheme` at all. The relationship is expressed exactly - once — the `mark_scheme.question_id` FK and its DB constraint. Navigation is a repository query by id. `@OneToOne` - defaults to EAGER and its inverse side can't be lazy without bytecode enhancement, so both directions fire wasted - selects. **Carve-out:** `Question.conceptLinks` `@OneToMany` stays — that's *within* the aggregate, it's lazy, and the - admin concepts write path needs it. See D-031. +- **Cross-aggregate references are by scalar id, never associations — no exceptions.** `MarkSchemeEntity` holds + `long questionId`, not `@OneToOne QuestionEntity`; no entity holds an object reference across an aggregate boundary. + The relationship is expressed once — the FK and its DB constraint. Navigation is a repository query by id. `@OneToOne` + defaults to EAGER and its inverse can't be lazy without bytecode enhancement, so both directions fire wasted selects. + **The former `QuestionEntity.conceptLinks` `@OneToMany` carve-out was deleted** — it had zero readers and its cascade + merely duplicated the database's own `ON DELETE CASCADE`. See D-031. - **Entities never carry value defaults on fields.** An entity's initial state is set by a full-body constructor or a named factory method; the no-arg constructor is protected (Hibernate's only). A field initialiser makes "I forgot to set this" and "I meant this value" indistinguishable, and silently owns a decision that a second creation path would want to make for itself. **Collection initialisers are not value defaults and stay** — an unset collection is an NPE, not a lifecycle decision. **The DB column default stays and serves a different caller:** Hibernate lists every mapped column in its INSERT, so a column default is never reachable from application writes — it guards non-JPA writers - only (`TestData`, seed scripts) and is pinned by the table's `*DatabaseIT`. *Applied:* `Question.status` lost - `= PENDING`; `Question`/`QuestionAttempt` no-arg constructors dropped to protected. `Document` and `QuestionOrigin` - are not yet compliant — implicit public no-arg constructor, no populating constructor — which is the document branch's - work. + only (`TestData`, seed scripts) and is pinned by the table's `*DatabaseIT`. *Applied across every entity:* the + bare-noun value defaults are gone; each entity has a protected no-arg constructor (Hibernate's only), a private + populating constructor and named factory methods. `DocumentEntity` and `QuestionOriginEntity` are compliant too. +- **Entities take an `Entity` suffix and live in `persistence`; the bare noun is the service-layer response record.** + The service never returns a persisted type. **`version` is `int` everywhere** — column, entity and service record — + and it never reaches a web response. - **Origin is a separate per-type table, never columns on the content.** A question's provenance (`AUTHORED`/ `EXTRACTED`/`GENERATED`, and the source document if extracted) lives in `question_origin`, not on `question`. Reasons: origin is *cold* (read <1% of the time — keep it off the hot catalogue row) and *polymorphic in shape but not in @@ -516,21 +546,23 @@ is no demo — the artefact is the repo + a conversation (D-035).** > **Sprint 0.2 closed.** Question read API, mark scheme, and attempts all delivered. > > **Sprint 0.3 in progress:** schema done (`document`, `question_origin`; `source`/`source_spec` dropped — D-036/D-041). -**Current work: the document upload piece**, sliced 1–5 (see PRACTIQ_MASTER Sprint 0.3): (1) LocalStack S3 + CI -> wiring → (2) presigned-upload endpoint + `AWAITING_UPLOAD` record → (3) server-owned reconcile (a run-resource on an -> internal surface) → (4) trigger script → (5) Terraform-scheduled trigger (EventBridge→Lambda, LocalStack). No client -`/complete` signal; no in-process scheduler (D-044/D-045). ⚠ `Document` has no status default — fixed in slice 2 (its -> first construct-and-save). After this piece: review/approve (PATCH, 1.3) and extraction. +**Document upload piece, slices 1–2 built** — LocalStack S3 + CI wiring; presigned-upload endpoint + `AWAITING_UPLOAD` +> record (the `Document` status-default defect fixed via the create-path constructor). **Layering refactor merged (PR +#32):** packages are now `web`/`service`/`persistence`/`storage`/`foundation`; entities carry an `Entity` suffix and the +> bare noun is the service record; per-route request validation applied (id<1→422, bad paging→400/422, `sort` refused, +> session-token bounds→422). **Outstanding Sprint 0.3 — slices 3–5:** server-owned reconcile (run-resource on an internal +> surface) → trigger script → Terraform/EventBridge schedule (D-044/D-045). After this piece: review/approve (PATCH, 1.3) +> and extraction. > > **Done:** `question`/`question_concept` migrations · `@Version` on content entities · `GET /api/v1/questions` (paged, > filterable, serving policy enforced) · full 400/404/422/500 error envelope · `PageResponse` · two-query concept > stitch · JPA static metamodel · `GET /api/v1/questions/{id}` · `mark_scheme` entity + `V3__mark_scheme.sql` + `GET /api/v1/questions/{id}/mark-scheme` (ungated) — **D-018 closed in code** · `QuestionQueryRunner`/ -`StudentQuestionQueryPolicy` (replaced `QuestionQueryManager`) + `LinkedQuestion` projection · performance tier ( +`StudentQuestionQueryPolicy` + `QuestionWithConceptIds` · performance tier ( `*PT`) · `question_attempt` table + `GET /api/v1/questions/{id}/attempts` (per-session, newest-first, visibility-gated > via the runner) · `X-Session-Token` handling (absent → 400, blank → 422) · `POST /api/v1/questions/{id}/attempts` (201 + -> created attempt, `@Body`-bound, visibility-gated via the same runner `exists`; body length 20k on the DTO, 100k -> entity-level backstop) — **attempts feature complete**. +> created attempt, `@Body`-bound, visibility-gated via the same runner `exists`; body length 20k on the DTO, 25k entity + +> column cap (V10)) — **attempts feature complete**. > > **Build & CI workstream** (cross-cutting — D-037/D-038): merge-to-main pipeline, security scanning, badge, timeouts > all shipped. **CodeQL caveat (D-038):** no Micronaut taint model, so injection-class flaws in controllers are *not* @@ -544,8 +576,9 @@ is no demo — the artefact is the repo + a conversation (D-035).** > **Deliberately out of scope:** `question_attempt_feedback` — whole table deferred to Phase 3 (D-019 amendment; no > consumer for a self-score yet) · MCQ auto-marking (separate feature, unscheduled) · idempotency keys (D-021) · unique > constraint on attempts (would break the revision loop) · server-issued session tokens (D-020) · async grading / `202` / -> polling · batch submission · strict `Pageable` binder (D-028) · DB `CHECK` on attempt body length (the backstop lives at -> the entity as `@Size(max = 100000)`; a loose `CHECK` owes a migration only if non-JPA writers ever appear — D-034). +> polling · batch submission · strict `Pageable` binder (D-028) · the attempt body cap is now `varchar(25000)` at the +> column (V10) with a matching `@Size(max = 25000)` on the entity — the earlier "a loose `CHECK` owes a migration only if +> non-JPA writers appear" caveat no longer applies, since the column carries the cap (D-034). Full sprint briefs live in PRACTIQ_MASTER.md (the planning doc, kept outside the repo). If a sprint brief is pasted, it governs the session's scope. \ No newline at end of file