Chapter 13 Companion: Code, Explanations and Solutions
Code explanations and solutions for Chapter 13 Companion: Code, Explanations and Solutions.
Integration, Delivery and Feedback OO Development and Agile Practices
1. Scope and execution
This file accompanies Chapter 13, Integration, Delivery and Feedback. It contains the visiting-researcher change that Chapter 12 specified (CR-12), the two real boundaries it adds (an approval file and an HTTP directory client), the tests at three scopes, the pipeline and release scripts, a hosted-runner workflow, a decision record, the two tests of the AI activity, model solutions and teaching notes.
Input version. The starting point is the Chapter 11 version: the Chapter 10 companion plus the new and changed files of the Chapter 11 companion. To keep verified listings from being copied a third time, this file lists only the files that are new or changed. Every other file is exactly as in those two companions. Chapter 12 changes no code: its companion is not part of the version.
What the change does. A visiting researcher (borrower category VISITOR) may reserve CAM1 or CAM2 for one to three days when a sponsor approval, read from a tab-separated register, matches the exact borrower, item and period and is valid at the request instant. The behaviour is behind one flag that defaults to off. A missing approval is an expected rejection; an unreadable register or an unreachable directory is an operational failure that stops the request. Student and staff behaviour is unchanged. The decision record is docs/ADR-013-visiting-researchers.md.
The two boundaries. The chapter’s two real boundaries are a local file and a local HTTP stub service built on the JDK. If a different boundary were chosen (an embedded database, for example), the policy table of Section 2.3 and the tests would stay, and the adapters would change.
1.1 Reconstruct the version
Create the directory after. Save every listing of the Chapter 10 companion at its path.
Save the new and changed listings of the Chapter 11 companion (its Sections 3 to 6) at their paths. Where a path already exists, that listing replaces it.
Save every listing in Sections 3 to 6 of this file at its path. Where a path already exists (pom.xml and the three regression tests are edited in place by the hunks shown; the other files are replaced), this file wins. Apply the hunks of Sections 4.7 and 5.1 with patch -p1 from inside after.
The files under ai-activity/ are not compiled by Maven. To run them, copy them into src/test/java/edu/se506/lending/aifixture/.
Use a full Java 17 JDK, including the jdk.jdeps module, and Maven 3.9.11. The pinned versions are unchanged (JUnit Jupiter 5.13.4, Mockito 5.20.0, Compiler Plugin 3.14.1, Surefire 3.5.4); only the project version changes. Initial builds need dependency access. Then, from inside after, run the whole pipeline or the single command:
bash scripts/pipeline.sh
mvn --batch-mode verify
1.2 Expected output
The pipeline prints a banner at the start and the end of each stage and stops at the first failing one. A passing run looks like this:
The times depend on the machine and on what Maven has cached; read the counts, not the seconds. The three end-to-end journeys check these lines from the separate process (flag off, then flag on):
Visitors enabled: false
Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Visitor V1: Rejected: visitor reservations are not enabled.
Visitor V2: Rejected: visitor reservations are not enabled.
Unknown U9: Rejected: borrower not found or inactive.
Outage BROKEN: Unavailable: Borrower directory unavailable
Reservations: 1
Notices: 1
Visitors enabled: true
Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).
Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22).
Visitor V2: Rejected: no valid sponsor approval.
Unknown U9: Rejected: borrower not found or inactive.
Outage BROKEN: Unavailable: Borrower directory unavailable
Reservations: 2
Notices: 2
Chapter13Demo needs a directory service and an approval file, so it is started by the journeys, which supply both. The demonstrations of earlier chapters print what they printed before.
1.3 Run one stage, or one test
mvn --batch-mode test -DexcludedGroups=integration,e2e # stage 2: unit tests and architecture checksmvn --batch-mode test -Dgroups=integration # stage 3mvn --batch-mode test -Dgroups=e2e # stage 4mvn --batch-mode test -Dtest=VisitorAcceptanceTest # one class
A tag that selects nothing fails the stage. mvn test -Dgroups=nosuchtag ends with No tests were executed! and BUILD FAILURE, because the POM sets failIfNoTests to true.
This is a simulation: staging is the folder build/staging, delivering is copying the jar, and the smoke test is the three end-to-end journeys run against the copied jar.
Policy: one attempt, a bounded wait, no retry and no fallback answer. The wait bounds the whole lookup, including the time to receive the body. It is a constructor argument; the demonstration passes its own lending.directory.timeout.millis.
2.4 The outcomes of a visitor request
Situation
Result
Kind
Flag off
Rejected: visitor reservations are not enabled.; the register is not read
Expected rejection
Item not CAM1 or CAM2
Rejected; the register is not read
Expected rejection
Fewer than one or more than three days, or three reservations already retained
Rejected by the existing policy and count rules
Expected rejection
No record, a denied record, or a record for a different borrower, item or period
Rejected: no valid sponsor approval.
Expected rejection
Record valid at the request instant (validFrom inclusive, validUntil exclusive)
Continues to the existing availability, reservation and notification steps
Success
Register missing, unreadable, malformed or with a duplicate key
ApprovalSourceException; nothing is reserved or sent
Operational failure
Directory unreachable, slow or nonsensical
DirectoryException; nothing is reserved or sent
Operational failure
2.5 Regression carry-forward
Adding VISITOR to the enum has one deliberate consequence: the coordinator requires a policy for every category, so a construction that lists every category needs one more entry. This happened in four constructions in three test files (Section 4.7). No assertion changed. The inherited 159 invocations passed unchanged after those four edits. The assertions carried forward from Chapters 3–9 (Section 2.5 of the Chapter 10 companion) are untouched.
packageedu.se506.lending.domain;/**
* Illustrative teaching rule for visiting researchers (change request CR-12): fewer than three
* retained reservations, and one to three calendar days. It decides count and duration only;
* sponsor approval is checked elsewhere, because this contract has no identity, item or clock.
*/publicfinalclassVisitorReservationRulesimplementsBorrowingPolicy{@OverridepublicbooleanmayRequest(intactiveReservations,intrequestedDays){returnactiveReservations>=0&&activeReservations<3&&requestedDays>=1&&requestedDays<=3;}}
packageedu.se506.lending.domain;/** A sponsor's decision on one request. Any other word in the register is a data error. */publicenumApprovalStatus{APPROVED,DENIED}
packageedu.se506.lending.domain;importjava.time.Instant;importjava.time.LocalDate;importjava.util.Objects;/**
* A trusted sponsor decision for one exact request: borrower, item and half-open loan period.
* The record is valid for requests made at or after {@code validFrom} and strictly before
* {@code validUntil}. The loan period itself need not lie inside that window.
*/publicrecordApprovalRecord(StringborrowerId,StringitemId,LocalDatestart,LocalDateendExclusive,StringsponsorId,ApprovalStatusstatus,InstantvalidFrom,InstantvalidUntil){publicApprovalRecord{requireText(borrowerId,"borrowerId");requireText(itemId,"itemId");requireText(sponsorId,"sponsorId");Objects.requireNonNull(start,"start");Objects.requireNonNull(endExclusive,"endExclusive");Objects.requireNonNull(status,"status");Objects.requireNonNull(validFrom,"validFrom");Objects.requireNonNull(validUntil,"validUntil");if(!start.isBefore(endExclusive)){thrownewIllegalArgumentException("Loan period must end after it starts.");}if(!validFrom.isBefore(validUntil)){thrownewIllegalArgumentException("Validity must end after it starts.");}}/** True only for the exact borrower, item and period; approval for anything else is not enough. */publicbooleanmatches(Stringborrower,Stringitem,LocalDateperiodStart,LocalDateperiodEnd){returnborrowerId.equals(borrower)&&itemId.equals(item)&&start.equals(periodStart)&&endExclusive.equals(periodEnd);}/** Equality at the start of the window is valid; equality at its end is not. */publicbooleanvalidAt(Instantnow){Objects.requireNonNull(now,"now");return!now.isBefore(validFrom)&&now.isBefore(validUntil);}publicbooleanapprovesAt(Instantnow){returnstatus==ApprovalStatus.APPROVED&&validAt(now);}privatestaticvoidrequireText(Stringvalue,Stringname){if(value==null||value.isBlank()){thrownewIllegalArgumentException(name+" must not be blank.");}}}
packageedu.se506.lending.application.port;importedu.se506.lending.domain.ApprovalRecord;importjava.time.LocalDate;importjava.util.Optional;/**
* Finds the sponsor approval recorded for one exact request. Empty means that no record exists
* for it. A source that cannot give a trustworthy answer (unreadable, malformed, ambiguous) must
* throw {@link ApprovalSourceException}, never return empty.
*/@FunctionalInterfacepublicinterfaceApprovalSource{Optional<ApprovalRecord>find(StringborrowerId,StringitemId,LocalDatestart,LocalDateendExclusive);}
packageedu.se506.lending.application.port;/** The approval register could not be read or trusted. This is not a sponsor's rejection. */publicfinalclassApprovalSourceExceptionextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicApprovalSourceException(Stringmessage){super(message);}publicApprovalSourceException(Stringmessage,Throwablecause){super(message,cause);}}
packageedu.se506.lending.application;importedu.se506.lending.application.port.ApprovalSource;importedu.se506.lending.domain.ApprovalRecord;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.util.Objects;importjava.util.Optional;importjava.util.Set;/**
* The extra checks for a visiting researcher, kept apart from the count and duration policy: the
* feature flag, the item allowlist and a matching sponsor approval that is valid at one request-time
* instant. It returns a rejection text, or empty when the request may go on to the coordinator.
* An unreadable or untrustworthy approval register is not a rejection: the exception from the
* source is not caught here, so no reservation can follow it.
*/publicfinalclassVisitorGate{publicstaticfinalStringDISABLED="Rejected: visitor reservations are not enabled.";publicstaticfinalStringITEM_NOT_ALLOWED="Rejected: item not available to visitors.";publicstaticfinalStringNOT_APPROVED="Rejected: no valid sponsor approval.";privatestaticfinalSet<String>ALLOWED_ITEMS=Set.of("CAM1","CAM2");privatefinalApprovalSourcesource;privatefinalClockclock;privatefinalbooleanenabled;publicVisitorGate(ApprovalSourcesource,Clockclock,booleanenabled){this.source=Objects.requireNonNull(source,"source");this.clock=Objects.requireNonNull(clock,"clock");this.enabled=enabled;}/** A gate for applications that have not enabled visitors: the approval source is never read. */publicstaticVisitorGatedisabled(){returnnewVisitorGate((borrowerId,itemId,start,end)->{thrownewIllegalStateException("The approval source must not be read while visitors are disabled.");},Clock.systemUTC(),false);}publicOptional<String>rejection(StringborrowerId,StringitemId,LocalDatestart,intdays){if(!enabled){returnOptional.of(DISABLED);}if(!ALLOWED_ITEMS.contains(itemId)){returnOptional.of(ITEM_NOT_ALLOWED);}if(days<1){returnOptional.empty();// there is no period to look up; the policy rejects it}Instantnow=clock.instant();// one request-time instant for the whole decisionLocalDateendExclusive=start.plusDays(days);Optional<ApprovalRecord>record=source.find(borrowerId,itemId,start,endExclusive);// The source should return only the exact request, but approval for anything else is never enough.if(record.isEmpty()||!record.get().matches(borrowerId,itemId,start,endExclusive)||!record.get().approvesAt(now)){returnOptional.of(NOT_APPROVED);}returnOptional.empty();}}
packageedu.se506.lending.application;importedu.se506.lending.application.port.BorrowerDirectory;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.domain.Borrower;importedu.se506.lending.domain.BorrowerCategory;importjava.time.LocalDate;importjava.util.Objects;importjava.util.Optional;/** Directory-backed entry point; this does not authenticate the caller. */publicfinalclassDirectoryReservationService{privatefinalBorrowerDirectorydirectory;privatefinalReservationCoordinatorcoordinator;privatefinalVisitorGatevisitorGate;publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator){this(directory,coordinator,VisitorGate.disabled());}publicDirectoryReservationService(BorrowerDirectorydirectory,ReservationCoordinatorcoordinator,VisitorGatevisitorGate){this.directory=Objects.requireNonNull(directory,"directory");this.coordinator=Objects.requireNonNull(coordinator,"coordinator");this.visitorGate=Objects.requireNonNull(visitorGate,"visitorGate");}publicStringreserve(StringborrowerId,StringitemId,LocalDatestart,intdays){if(borrowerId==null||borrowerId.isBlank()||itemId==null||itemId.isBlank()){thrownewIllegalArgumentException("Identifiers must not be blank.");}Objects.requireNonNull(start,"start");varfound=directory.find(borrowerId);if(found.isEmpty())return"Rejected: borrower not found or inactive.";Borrowerborrower=found.get();if(!borrowerId.equals(borrower.id())){thrownewDirectoryException("Directory returned a different borrower");}if(borrower.category()==BorrowerCategory.VISITOR){Optional<String>rejection=visitorGate.rejection(borrower.id(),itemId,start,days);if(rejection.isPresent())returnrejection.get();}returncoordinator.reserve(borrower.id(),itemId,start,days,borrower.category());}}
packageedu.se506.lending.infrastructure.approvals;importedu.se506.lending.application.port.ApprovalSource;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Instant;importjava.time.LocalDate;importjava.time.format.DateTimeParseException;importjava.util.ArrayList;importjava.util.HashSet;importjava.util.List;importjava.util.Objects;importjava.util.Optional;importjava.util.Set;/**
* Reads the sponsor-approval register: a local UTF-8 tab-separated file with exactly eight columns
* (borrowerId, itemId, start, endExclusive, sponsorId, status, validFrom, validUntil) and a header.
* The file is read afresh on every call and is rejected as a whole if any row is invalid or if two
* rows share a request key. It is a trusted, sequential fixture, not a concurrent approval database.
*/publicfinalclassFileApprovalSourceimplementsApprovalSource{staticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatefinalPathfile;publicFileApprovalSource(Pathfile){this.file=Objects.requireNonNull(file,"file");}@OverridepublicOptional<ApprovalRecord>find(StringborrowerId,StringitemId,LocalDatestart,LocalDateendExclusive){List<ApprovalRecord>records=readAll();for(ApprovalRecordrecord:records){if(record.matches(borrowerId,itemId,start,endExclusive)){returnOptional.of(record);}}returnOptional.empty();}privateList<ApprovalRecord>readAll(){List<String>lines;try{lines=Files.readAllLines(file,StandardCharsets.UTF_8);}catch(IOExceptionfailure){thrownewApprovalSourceException("Approval register cannot be read: "+file.getFileName(),failure);}if(lines.isEmpty()||!HEADER.equals(lines.get(0))){thrownewApprovalSourceException("Approval register has no valid header row.");}List<ApprovalRecord>records=newArrayList<>();Set<String>keys=newHashSet<>();for(inti=1;i<lines.size();i++){introw=i+1;ApprovalRecordrecord=parse(lines.get(i),row);Stringkey=record.borrowerId()+"|"+record.itemId()+"|"+record.start()+"|"+record.endExclusive();if(!keys.add(key)){thrownewApprovalSourceException("Duplicate approval request key at row "+row+".");}records.add(record);}returnList.copyOf(records);}privatestaticApprovalRecordparse(Stringline,introw){String[]columns=line.split("\t",-1);if(columns.length!=8){thrownewApprovalSourceException("Row "+row+" has "+columns.length+" columns, not 8.");}try{returnnewApprovalRecord(columns[0],columns[1],LocalDate.parse(columns[2]),LocalDate.parse(columns[3]),columns[4],status(columns[5],row),Instant.parse(columns[6]),Instant.parse(columns[7]));}catch(DateTimeParseException|IllegalArgumentExceptionbad){thrownewApprovalSourceException("Row "+row+" is not a valid approval record.",bad);}}privatestaticApprovalStatusstatus(Stringtext,introw){returnswitch(text){case"APPROVED"->ApprovalStatus.APPROVED;case"DENIED"->ApprovalStatus.DENIED;default->thrownewApprovalSourceException("Row "+row+" has an unknown status.");};}}
packageedu.se506.lending.infrastructure.directory;/** Illustrative provider contract, not an actual university API. See HttpLegacyDirectoryClient for a network client. */@FunctionalInterfacepublicinterfaceLegacyDirectoryClient{// null means no row; codes are exact and case-sensitive.Personlookup(Stringkey);recordPerson(Stringkey,Stringkind,Stringstatus){}finalclassFailureextendsRuntimeException{privatestaticfinallongserialVersionUID=1L;publicFailure(Stringmessage){super(message);}publicFailure(Stringmessage,Throwablecause){super(message,cause);}}}
packageedu.se506.lending.infrastructure.directory;importjava.net.URI;importjava.net.URLEncoder;importjava.net.http.HttpClient;importjava.net.http.HttpRequest;importjava.net.http.HttpResponse;importjava.nio.charset.StandardCharsets;importjava.time.Duration;importjava.util.Objects;importjava.util.concurrent.CompletableFuture;importjava.util.concurrent.ExecutionException;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.TimeoutException;/**
* Asks a directory service over HTTP: {@code GET <base>/borrowers/<key>}. A 200 response carries one
* line, the provider's kind and status separated by a tab. A 404 means there is no such row. Everything
* else is a failure: a timeout, a refused connection, any other status and a body that is not exactly
* two fields. Policy: one attempt, a bounded wait, no retry and no fallback answer. The wait bounds the
* whole lookup, including the time to receive the body: the request timeout of the JDK client is not
* documented to cover the body, and a server that sends its headers and then stalls would otherwise hold
* the caller for as long as it likes. A caller that cannot get an answer must fail, because an outage
* must never look like a missing borrower.
*/publicfinalclassHttpLegacyDirectoryClientimplementsLegacyDirectoryClient{privatefinalHttpClienthttp;privatefinalURIbase;privatefinalDurationrequestTimeout;publicHttpLegacyDirectoryClient(URIbase,DurationconnectTimeout,DurationrequestTimeout){this.base=Objects.requireNonNull(base,"base");this.requestTimeout=Objects.requireNonNull(requestTimeout,"requestTimeout");this.http=HttpClient.newBuilder().connectTimeout(Objects.requireNonNull(connectTimeout,"connectTimeout")).build();}@OverridepublicPersonlookup(Stringkey){URIuri=URI.create(base.toString().replaceAll("/+$","")+"/borrowers/"+URLEncoder.encode(key,StandardCharsets.UTF_8));HttpRequestrequest=HttpRequest.newBuilder(uri).timeout(requestTimeout).GET().build();CompletableFuture<HttpResponse<String>>pending=http.sendAsync(request,HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));HttpResponse<String>response;try{response=pending.get(requestTimeout.toNanos(),TimeUnit.NANOSECONDS);}catch(TimeoutExceptionlate){pending.cancel(true);// asks the client to drop the exchange; the caller does not wait for itthrownewFailure("Directory did not answer within "+requestTimeout.toMillis()+" ms.",late);}catch(InterruptedExceptioninterrupted){pending.cancel(true);Thread.currentThread().interrupt();thrownewFailure("Directory request was interrupted.",interrupted);}catch(ExecutionExceptionfailed){Throwablecause=failed.getCause()==null?failed:failed.getCause();thrownewFailure("Directory unavailable: "+cause.getClass().getSimpleName(),cause);}if(response.statusCode()==404){returnnull;}if(response.statusCode()!=200){thrownewFailure("Directory answered with status "+response.statusCode()+".");}String[]fields=response.body().strip().split("\t",-1);if(fields.length!=2){thrownewFailure("Directory answered with a malformed row.");}returnnewPerson(key,fields[0],fields[1]);}}
packageedu.se506.lending.bootstrap;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importedu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;importedu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.net.URI;importjava.nio.file.Path;importjava.time.Clock;importjava.time.Duration;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;/**
* The runnable system for Chapter 13: a directory service reached over HTTP, a sponsor-approval file
* and one feature flag. Settings are system properties so that a test can start it as a separate process:
* lending.directory.url (required), lending.approvals.file (required), lending.visitors (default false),
* lending.now (an ISO instant, default the system clock) and lending.directory.timeout.millis (default 1000).
*/publicfinalclassChapter13Demo{publicstaticvoidmain(String[]args){URIdirectoryUrl=URI.create(required("lending.directory.url"));Pathapprovals=Path.of(required("lending.approvals.file"));booleanvisitorsEnabled=Boolean.parseBoolean(System.getProperty("lending.visitors","false"));Stringnow=System.getProperty("lending.now");Clockclock=now==null?Clock.systemUTC():Clock.fixed(Instant.parse(now),ZoneOffset.UTC);longtimeoutMillis=Long.parseLong(System.getProperty("lending.directory.timeout.millis","1000"));varsender=newRecordingNotificationSender();ReservationCoordinatorcoordinator=ReservationApplication.create(sender);vardirectory=newLegacyDirectoryAdapter(newHttpLegacyDirectoryClient(directoryUrl,Duration.ofMillis(timeoutMillis),Duration.ofMillis(timeoutMillis)));vargate=newVisitorGate(newFileApprovalSource(approvals),clock,visitorsEnabled);varservice=newDirectoryReservationService(directory,coordinator,gate);LocalDatestart=LocalDate.of(2026,10,20);System.out.println("Visitors enabled: "+visitorsEnabled);attempt("Student B1",service,"B1","CAM1",start,2);attempt("Visitor V1",service,"V1","CAM2",start,2);attempt("Visitor V2",service,"V2","CAM2",start,2);attempt("Unknown U9",service,"U9","MIC1",start,1);attempt("Outage BROKEN",service,"BROKEN","MIC1",start,1);System.out.println("Reservations: "+coordinator.reservations().size());System.out.println("Notices: "+sender.received().size());}privatestaticvoidattempt(Stringlabel,DirectoryReservationServiceservice,Stringborrower,Stringitem,LocalDatestart,intdays){Stringoutcome;try{outcome=service.reserve(borrower,item,start,days);}catch(DirectoryExceptionfailure){outcome="Unavailable: "+failure.getMessage();}catch(ApprovalSourceExceptionfailure){outcome="Approval register unavailable: "+failure.getMessage();}System.out.println(label+": "+outcome);}privatestaticStringrequired(Stringproperty){Stringvalue=System.getProperty(property);if(value==null||value.isBlank()){thrownewIllegalStateException("Missing system property "+property);}returnvalue;}}
packageedu.se506.lending.visitor;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importedu.se506.lending.domain.VisitorReservationRules;importjava.time.Instant;importjava.time.LocalDate;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.params.ParameterizedTest;importorg.junit.jupiter.params.provider.CsvSource;/** Unit tests for the visitor count and duration rule and for the approval record's own rules. */classVisitorRulesTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalInstantFROM=Instant.parse("2026-10-18T00:00:00Z");privatestaticfinalInstantUNTIL=Instant.parse("2026-10-20T00:00:00Z");privatestaticApprovalRecordrecord(ApprovalStatusstatus){returnnewApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",status,FROM,UNTIL);}@ParameterizedTest@CsvSource({"0,1,true","2,3,true","0,2,true","3,1,false","0,4,false","0,0,false","-1,1,false","0,-1,false"})voidcountAndDurationBoundaries(intcount,intdays,booleanexpected){assertEquals(expected,newVisitorReservationRules().mayRequest(count,days));}@TestvoidequalityAtTheStartOfTheWindowIsValidAndAtTheEndIsNot(){ApprovalRecordapproved=record(ApprovalStatus.APPROVED);assertTrue(approved.validAt(FROM));assertTrue(approved.validAt(UNTIL.minusSeconds(1)));assertFalse(approved.validAt(UNTIL));assertFalse(approved.validAt(FROM.minusSeconds(1)));}@TestvoidonlyAnApprovedStatusApprovesAndOnlyInsideTheWindow(){Instantinside=Instant.parse("2026-10-19T12:00:00Z");assertTrue(record(ApprovalStatus.APPROVED).approvesAt(inside));assertFalse(record(ApprovalStatus.DENIED).approvesAt(inside));assertFalse(record(ApprovalStatus.APPROVED).approvesAt(UNTIL));}@TestvoidaRecordMatchesOnlyTheExactBorrowerItemAndPeriod(){ApprovalRecordapproved=record(ApprovalStatus.APPROVED);LocalDateend=START.plusDays(2);assertTrue(approved.matches("V1","CAM1",START,end));assertFalse(approved.matches("V2","CAM1",START,end));assertFalse(approved.matches("V1","CAM2",START,end));assertFalse(approved.matches("V1","CAM1",START.plusDays(1),end));assertFalse(approved.matches("V1","CAM1",START,end.plusDays(1)));}@TestvoidanInvalidRecordCannotBeBuilt(){assertThrows(IllegalArgumentException.class,()->newApprovalRecord("V1","CAM1",START,START.plusDays(2)," ",ApprovalStatus.APPROVED,FROM,UNTIL));assertThrows(IllegalArgumentException.class,()->newApprovalRecord("V1","CAM1",START,START,"S1",ApprovalStatus.APPROVED,FROM,UNTIL));assertThrows(IllegalArgumentException.class,()->newApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",ApprovalStatus.APPROVED,UNTIL,UNTIL));assertThrows(NullPointerException.class,()->newApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",null,FROM,UNTIL));}}
packageedu.se506.lending.visitor;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSource;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneId;importjava.time.ZoneOffset;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.Test;/** Unit tests for the visitor gate with a controlled approval source and clock. */classVisitorGateTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalInstantFROM=Instant.parse("2026-10-18T00:00:00Z");privatestaticfinalInstantUNTIL=Instant.parse("2026-10-20T00:00:00Z");privatestaticfinalInstantNOW=Instant.parse("2026-10-19T12:00:00Z");privatefinalAtomicIntegerreads=newAtomicInteger();privateApprovalSourcesourceOf(ApprovalRecordrecord){return(borrower,item,start,end)->{reads.incrementAndGet();returnOptional.ofNullable(record);};}privatestaticApprovalRecordrecord(ApprovalStatusstatus){returnnewApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",status,FROM,UNTIL);}privateVisitorGategate(ApprovalRecordrecord,Instantnow,booleanenabled){returnnewVisitorGate(sourceOf(record),Clock.fixed(now,ZoneOffset.UTC),enabled);}@TestvoidaMatchingApprovedRecordInsideItsWindowLetsTheRequestContinue(){assertEquals(Optional.empty(),gate(record(ApprovalStatus.APPROVED),NOW,true).rejection("V1","CAM1",START,2));assertEquals(1,reads.get());}@TestvoidaDisabledGateRejectsWithoutReadingTheSource(){assertEquals(Optional.of(VisitorGate.DISABLED),gate(record(ApprovalStatus.APPROVED),NOW,false).rejection("V1","CAM1",START,2));assertEquals(0,reads.get());}@TestvoidanItemOutsideTheAllowlistIsRejectedWithoutReadingTheSource(){assertEquals(Optional.of(VisitorGate.ITEM_NOT_ALLOWED),gate(record(ApprovalStatus.APPROVED),NOW,true).rejection("V1","MIC1",START,2));assertEquals(0,reads.get());}@TestvoidaNonPositiveDurationIsLeftToThePolicyWithoutReadingTheSource(){VisitorGategate=gate(record(ApprovalStatus.APPROVED),NOW,true);assertEquals(Optional.empty(),gate.rejection("V1","CAM1",START,0));assertEquals(Optional.empty(),gate.rejection("V1","CAM1",START,-2));assertEquals(0,reads.get());}@TestvoidaMissingOrDeniedRecordIsAnExpectedRejection(){assertEquals(Optional.of(VisitorGate.NOT_APPROVED),gate(null,NOW,true).rejection("V1","CAM1",START,2));assertEquals(Optional.of(VisitorGate.NOT_APPROVED),gate(record(ApprovalStatus.DENIED),NOW,true).rejection("V1","CAM1",START,2));}@TestvoidtheWindowIncludesItsStartAndExcludesItsEnd(){ApprovalRecordapproved=record(ApprovalStatus.APPROVED);assertEquals(Optional.empty(),gate(approved,FROM,true).rejection("V1","CAM1",START,2));assertEquals(Optional.of(VisitorGate.NOT_APPROVED),gate(approved,UNTIL,true).rejection("V1","CAM1",START,2));assertEquals(Optional.of(VisitorGate.NOT_APPROVED),gate(approved,FROM.minusSeconds(1),true).rejection("V1","CAM1",START,2));}@TestvoidaSourceThatReturnsADifferentRequestDoesNotApprove(){ApprovalRecordother=newApprovalRecord("V2","CAM1",START,START.plusDays(2),"S1",ApprovalStatus.APPROVED,FROM,UNTIL);assertEquals(Optional.of(VisitorGate.NOT_APPROVED),gate(other,NOW,true).rejection("V1","CAM1",START,2));}@TestvoidanOperationalFailureOfTheSourceIsNotCaught(){ApprovalSourceExceptionfailure=newApprovalSourceException("register unreadable");VisitorGategate=newVisitorGate((b,i,s,e)->{throwfailure;},Clock.fixed(NOW,ZoneOffset.UTC),true);assertSame(failure,assertThrows(ApprovalSourceException.class,()->gate.rejection("V1","CAM1",START,2)));}@TestvoidoneRequestReadsTheClockOnce(){AtomicIntegerinstants=newAtomicInteger();Clockcounting=newClock(){@OverridepublicZoneIdgetZone(){returnZoneOffset.UTC;}@OverridepublicClockwithZone(ZoneIdzone){returnthis;}@OverridepublicInstantinstant(){instants.incrementAndGet();returnNOW;}};newVisitorGate(sourceOf(record(ApprovalStatus.APPROVED)),counting,true).rejection("V1","CAM1",START,2);assertEquals(1,instants.get());}@TestvoidtheDisabledFactoryNeverReadsASource(){assertEquals(Optional.of(VisitorGate.DISABLED),VisitorGate.disabled().rejection("V1","CAM1",START,2));}}
packageedu.se506.lending.visitor;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.LoanService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.application.port.BorrowerDirectory;importedu.se506.lending.application.port.NotificationException;importedu.se506.lending.bootstrap.LoanSnapshotRows;importedu.se506.lending.bootstrap.OverdueReportService;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importedu.se506.lending.domain.Borrower;importedu.se506.lending.domain.BorrowerCategory;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importjava.util.ArrayList;importjava.util.List;importjava.util.Locale;importjava.util.Optional;importjava.util.concurrent.atomic.AtomicInteger;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Test;/**
* The acceptance matrix of change request CR-12 (examples AC1 to AC18), run through the real
* directory service, coordinator, repository and loan service with a controlled directory,
* approval source and clock. Each test builds its own fixture so one failure cannot mask another.
*/classVisitorAcceptanceTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalInstantFROM=Instant.parse("2026-10-18T00:00:00Z");privatestaticfinalInstantUNTIL=Instant.parse("2026-10-20T00:00:00Z");privatestaticfinalInstantNOW=Instant.parse("2026-10-19T12:00:00Z");privatestaticfinalStringSUCCESS_V1_CAM1="Confirmed: R1 for V1 on CAM1 [2026-10-20, 2026-10-22).";privatestaticfinalStringLIMIT="Rejected: borrower limit or duration.";privateLocalesavedLocale;@BeforeEachvoidpinTheLocale(){savedLocale=Locale.getDefault();Locale.setDefault(Locale.ROOT);}@AfterEachvoidrestoreTheLocale(){Locale.setDefault(savedLocale);}/** A complete small system with a controlled directory, approval list, clock and sender. */privatestaticfinalclassWorld{finalList<ApprovalRecord>approvals=newArrayList<>();finalAtomicIntegerapprovalReads=newAtomicInteger();finalRecordingNotificationSendersender=newRecordingNotificationSender();finalReservationCoordinatorcoordinator;finalDirectoryReservationServiceservice;World(booleanenabled,Instantnow,edu.se506.lending.application.port.NotificationSendernotifier){coordinator=ReservationApplication.create(notifier);BorrowerDirectorydirectory=id->switch(id){case"V1","V2"->Optional.of(newBorrower(id,BorrowerCategory.VISITOR));case"S1"->Optional.of(newBorrower(id,BorrowerCategory.STUDENT));case"T1"->Optional.of(newBorrower(id,BorrowerCategory.STAFF));default->Optional.empty();};VisitorGategate=newVisitorGate((borrower,item,start,end)->{approvalReads.incrementAndGet();returnapprovals.stream().filter(r->r.matches(borrower,item,start,end)).findFirst();},Clock.fixed(now,ZoneOffset.UTC),enabled);service=newDirectoryReservationService(directory,coordinator,gate);}World(booleanenabled,Instantnow){this(enabled,now,newRecordingNotificationSender());}Worldapprove(Stringborrower,Stringitem,LocalDatestart,intdays){approvals.add(newApprovalRecord(borrower,item,start,start.plusDays(days),"S1",ApprovalStatus.APPROVED,FROM,UNTIL));returnthis;}}privatestaticvoidassertNothingChanged(Worldw){assertTrue(w.coordinator.reservations().isEmpty());}@Testvoidac1_aVisitorWithAMatchingApprovalReservesCam1ForTwoDays(){varrecorder=newRecordingNotificationSender();varw=newWorld(true,NOW,recorder).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));assertEquals(1,w.coordinator.reservations().size());assertEquals(1,recorder.received().size());}@Testvoidac2_cam2ForThreeDaysNeedsItsOwnRecordAndIsAllowed(){varw=newWorld(true,NOW).approve("V1","CAM2",START,3);assertEquals("Confirmed: R1 for V1 on CAM2 [2026-10-20, 2026-10-23).",w.service.reserve("V1","CAM2",START,3));}@Testvoidac3_fourDaysIsRejectedEvenWithAMatchingRecord(){varrecorder=newRecordingNotificationSender();varw=newWorld(true,NOW,recorder).approve("V1","CAM1",START,4);assertEquals(LIMIT,w.service.reserve("V1","CAM1",START,4));assertNothingChanged(w);assertTrue(recorder.received().isEmpty());}@Testvoidac4_zeroOrNegativeDaysIsRejectedWithoutReadingTheRegister(){varrecorder=newRecordingNotificationSender();varw=newWorld(true,NOW,recorder).approve("V1","CAM1",START,2);assertEquals(LIMIT,w.service.reserve("V1","CAM1",START,0));assertEquals(LIMIT,w.service.reserve("V1","CAM1",START,-1));assertNothingChanged(w);assertEquals(0,w.approvalReads.get());assertTrue(recorder.received().isEmpty());}@Testvoidac5_anItemOutsideTheAllowlistIsRejectedEvenWithAMatchingRecord(){varw=newWorld(true,NOW).approve("V1","MIC1",START,2);assertEquals(VisitorGate.ITEM_NOT_ALLOWED,w.service.reserve("V1","MIC1",START,2));assertNothingChanged(w);}@Testvoidac6_aMissingOrDeniedApprovalIsAnExpectedRejection(){varmissing=newWorld(true,NOW);assertEquals(VisitorGate.NOT_APPROVED,missing.service.reserve("V1","CAM1",START,2));assertNothingChanged(missing);vardenied=newWorld(true,NOW);denied.approvals.add(newApprovalRecord("V1","CAM1",START,START.plusDays(2),"S1",ApprovalStatus.DENIED,FROM,UNTIL));assertEquals(VisitorGate.NOT_APPROVED,denied.service.reserve("V1","CAM1",START,2));assertNothingChanged(denied);}@Testvoidac7_anApprovalForAnotherBorrowerItemOrPeriodDoesNotAuthorise(){varw=newWorld(true,NOW).approve("V2","CAM1",START,2).approve("V1","CAM2",START,2).approve("V1","CAM1",START,3);assertEquals(VisitorGate.NOT_APPROVED,w.service.reserve("V1","CAM1",START,2));assertNothingChanged(w);}@Testvoidac8_theRequestInstantEqualToValidFromIsValid(){varw=newWorld(true,FROM).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));}@Testvoidac9_theRequestInstantAtValidUntilOrBeforeValidFromIsRejected(){varatExpiry=newWorld(true,UNTIL).approve("V1","CAM1",START,2);assertEquals(VisitorGate.NOT_APPROVED,atExpiry.service.reserve("V1","CAM1",START,2));varearly=newWorld(true,FROM.minusSeconds(1)).approve("V1","CAM1",START,2);assertEquals(VisitorGate.NOT_APPROVED,early.service.reserve("V1","CAM1",START,2));assertNothingChanged(atExpiry);assertNothingChanged(early);}@Testvoidac10_anUnreadableApprovalRegisterIsAnOperationalFailureNotARejection(){varrecorder=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(recorder);ApprovalSourceExceptionfailure=newApprovalSourceException("register unreadable");vargate=newVisitorGate((b,i,s,e)->{throwfailure;},Clock.fixed(NOW,ZoneOffset.UTC),true);varservice=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),coordinator,gate);assertSame(failure,assertThrows(ApprovalSourceException.class,()->service.reserve("V1","CAM1",START,2)));assertTrue(coordinator.reservations().isEmpty());assertTrue(recorder.received().isEmpty());}@Testvoidprecedence_theRegisterIsReadBeforeTheCountAndDurationRulesApply(){varrecorder=newRecordingNotificationSender();varcoordinator=ReservationApplication.create(recorder);ApprovalSourceExceptionfailure=newApprovalSourceException("register unreadable");vargate=newVisitorGate((b,i,s,e)->{throwfailure;},Clock.fixed(NOW,ZoneOffset.UTC),true);varservice=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),coordinator,gate);// Four days breaks the duration rule, but the gate reads the register first, so the failure is what the caller sees.assertSame(failure,assertThrows(ApprovalSourceException.class,()->service.reserve("V1","CAM1",START,4)));// Zero days is left to the coordinator: the register is not read and the limit rejection is returned.assertEquals(LIMIT,service.reserve("V1","CAM1",START,0));assertTrue(coordinator.reservations().isEmpty());assertTrue(recorder.received().isEmpty());}@Testvoidac11_threeRetainedReservationsBlockAFourthEvenAfterLoansAreReturned(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);varw=newWorld(true,NOW);varloans=newLoanService(w.coordinator,checkoutDay);for(inti=0;i<3;i++){assertTrue(w.coordinator.reserve("V1","CAM"+(1+i%2),START.plusDays(10L*i),1,BorrowerCategory.VISITOR).startsWith("Confirmed"));}loans.checkOut("R1");loans.returnLoan("R1");w.approve("V1","CAM1",START.plusDays(40),1);assertEquals(LIMIT,w.service.reserve("V1","CAM1",START.plusDays(40),1));assertEquals(3,w.coordinator.reservations().size());}@Testvoidac12_aDisabledFlagRejectsExplicitlyWithoutReadingTheRegister(){varw=newWorld(false,NOW).approve("V1","CAM1",START,2);assertEquals(VisitorGate.DISABLED,w.service.reserve("V1","CAM1",START,2));assertEquals(0,w.approvalReads.get());assertNothingChanged(w);vardefaultService=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),ReservationApplication.create(newRecordingNotificationSender()));assertEquals(VisitorGate.DISABLED,defaultService.reserve("V1","CAM1",START,2));}@Testvoidac13_studentAndStaffBehaviourIsUnchangedUnderEitherSettingAndNeverReadsTheRegister(){for(booleanenabled:newboolean[]{false,true}){varw=newWorld(enabled,NOW);assertEquals("Confirmed: R1 for S1 on CAM1 [2026-10-20, 2026-10-27).",w.service.reserve("S1","CAM1",START,7));assertEquals("Confirmed: R2 for T1 on MIC1 [2026-10-20, 2026-11-03).",w.service.reserve("T1","MIC1",START,14));assertEquals("Rejected: borrower limit or duration.",w.service.reserve("S1","CAM2",START,8));assertEquals(0,w.approvalReads.get());}}@Testvoidac14_anOverlapWithAnExistingReservationIsRejectedByTheExistingRule(){varw=newWorld(true,NOW).approve("V1","CAM1",START,2);assertTrue(w.coordinator.reserve("S1","CAM1",START,2).startsWith("Confirmed: R1"));assertEquals("Rejected: item unavailable.",w.service.reserve("V1","CAM1",START,2));assertEquals(1,w.coordinator.reservations().size());}@Testvoidac15_anExpectedNotificationFailureLeavesTheVisitorReservationInPlace(){varw=newWorld(true,NOW,reservation->{thrownewNotificationException("outbox down");}).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1+" Notification failed; reservation retained.",w.service.reserve("V1","CAM1",START,2));assertEquals(1,w.coordinator.reservations().size());}@Testvoidac16_aVisitorReservationStaysUsableAfterTheApprovalExpiresAndTheFlagIsOff(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);varw=newWorld(true,NOW).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));varafterwards=newDirectoryReservationService(id->Optional.of(newBorrower(id,BorrowerCategory.VISITOR)),w.coordinator,VisitorGate.disabled());assertEquals(VisitorGate.DISABLED,afterwards.reserve("V1","CAM2",START,1));varloans=newLoanService(w.coordinator,checkoutDay);assertTrue(loans.checkOut("R1").active());assertFalse(loans.returnLoan("R1").active());}@Testvoidac17_anInactiveOrUnknownBorrowerIsRejectedAsBefore(){varw=newWorld(true,NOW).approve("U9","CAM1",START,2);assertEquals("Rejected: borrower not found or inactive.",w.service.reserve("U9","CAM1",START,2));assertEquals(0,w.approvalReads.get());assertNothingChanged(w);}@Testvoidac18_aVisitorLoanInTheOverdueReportKeepsTheExistingKindRuleSd11_1(){ClockcheckoutDay=Clock.fixed(Instant.parse("2026-10-20T09:00:00Z"),ZoneOffset.UTC);ClockreportDay=Clock.fixed(Instant.parse("2026-10-22T09:00:00Z"),ZoneOffset.UTC);varw=newWorld(true,NOW).approve("V1","CAM1",START,2);assertEquals(SUCCESS_V1_CAM1,w.service.reserve("V1","CAM1",START,2));varloans=newLoanService(w.coordinator,checkoutDay);loans.checkOut("R1");varrows=newLoanSnapshotRows(loans::loans,w.coordinator::reservations,borrower->"V");// The legacy report knows only S and T. Any other kind pays 1.00 a day (suspected defect SD-11-1,// pinned in Chapter 11 and not fixed here), so a visitor one day late is fined 1.00.assertEquals("OVERDUE REPORT 2026-10-22\nCAM1 V1 1 day(s) late, fine 1.00\n1 overdue, total fines 1.00\n",newOverdueReportService(rows,reportDay).report());}}
packageedu.se506.lending.integration;importcom.sun.net.httpserver.HttpExchange;importcom.sun.net.httpserver.HttpServer;importjava.io.IOException;importjava.net.InetAddress;importjava.net.InetSocketAddress;importjava.net.URI;importjava.nio.charset.StandardCharsets;importjava.util.concurrent.CountDownLatch;importjava.util.concurrent.TimeUnit;/**
* A controlled local directory service for tests: the JDK's own HTTP server on the loopback
* address and a free port. Known keys answer with a kind and a status separated by a tab. The key
* "SLOW" waits before sending anything until the stub is closed, "SLOWBODY" sends its headers and the
* first byte of the body and then holds the rest until the stub is closed, "BROKEN" answers 500 and
* "GARBAGE" answers with a body that is not two fields. Everything else is 404. It is a stand-in for a real provider, not the provider.
*/publicfinalclassDirectoryStubimplementsAutoCloseable{privatefinalHttpServerserver;privatefinalCountDownLatchrelease=newCountDownLatch(1);privatevolatileintrequests;publicDirectoryStub()throwsIOException{server=HttpServer.create(newInetSocketAddress(InetAddress.getLoopbackAddress(),0),0);server.createContext("/borrowers/",this::handle);server.start();}publicURIbaseUri(){returnURI.create("http://127.0.0.1:"+server.getAddress().getPort());}publicintrequests(){returnrequests;}privatevoidhandle(HttpExchangeexchange)throwsIOException{requests++;Stringkey=exchange.getRequestURI().getPath().substring("/borrowers/".length());intstatus=200;booleanstallBody=false;Stringbody;switch(key){case"B1"->body="S\tY";case"T1"->body="T\tY";case"V1","V2"->body="V\tY";case"VOFF"->body="V\tN";case"ODD"->body="X\tY";case"GARBAGE"->body="only-one-field";case"BROKEN"->{status=500;body="server error";}case"SLOW"->{awaitRelease();body="S\tY";}case"SLOWBODY"->{stallBody=true;body="S\tY";}default->{status=404;body="none";}}byte[]bytes=body.getBytes(StandardCharsets.UTF_8);try{exchange.sendResponseHeaders(status,bytes.length);if(stallBody){exchange.getResponseBody().write(bytes,0,1);exchange.getResponseBody().flush();awaitRelease();exchange.getResponseBody().write(bytes,1,bytes.length-1);}else{exchange.getResponseBody().write(bytes);}}catch(IOExceptionclientGone){// the client gave up (a timeout test); nothing more to send}finally{exchange.close();}}privatevoidawaitRelease(){try{release.await(30,TimeUnit.SECONDS);}catch(InterruptedExceptioninterrupted){Thread.currentThread().interrupt();}}@Overridepublicvoidclose(){release.countDown();server.stop(0);}}
packageedu.se506.lending.integration;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importorg.junit.jupiter.api.Test;/**
* The behavioural contract that every {@link LegacyDirectoryClient} must honour, written once and run
* against each implementation: the in-memory fixture the earlier chapters used and the real HTTP client.
* A contract test says what callers may rely on. It does not prove that a real provider honours it.
*/abstractclassDirectoryClientContract{/** A client that answers "S" and "Y" for the key B1 and has no row for the key NONE. */abstractLegacyDirectoryClientclient()throwsException;/** A client for which every lookup fails because the provider cannot be reached or understood. */abstractLegacyDirectoryClientfailingClient()throwsException;@TestvoidaKnownKeyReturnsItsRowWithTheKeyItWasAskedFor()throwsException{LegacyDirectoryClient.Personrow=client().lookup("B1");assertEquals(newLegacyDirectoryClient.Person("B1","S","Y"),row);}@TestvoidanUnknownKeyReturnsNullAndIsNotAFailure()throwsException{assertNull(client().lookup("NONE"));}@TestvoidaProviderThatCannotAnswerFailsAndNeverReturnsAnEmptyAnswer()throwsException{LegacyDirectoryClientfailing=failingClient();assertThrows(LegacyDirectoryClient.Failure.class,()->failing.lookup("B1"));}}
packageedu.se506.lending.integration;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;/** The contract run against the in-memory fixture that earlier chapters used. */classFixtureDirectoryClientContractTestextendsDirectoryClientContract{@OverrideLegacyDirectoryClientclient(){returnkey->"B1".equals(key)?newLegacyDirectoryClient.Person("B1","S","Y"):null;}@OverrideLegacyDirectoryClientfailingClient(){returnkey->{thrownewLegacyDirectoryClient.Failure("fixture provider is down");};}}
packageedu.se506.lending.integration;importedu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importjava.io.IOException;importjava.net.ServerSocket;importjava.net.URI;importjava.time.Duration;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Tag;/** The same contract run against the real HTTP client and a controlled local service. */@Tag("integration")classHttpDirectoryClientContractTestextendsDirectoryClientContract{privateDirectoryStubstub;@BeforeEachvoidstartStub()throwsIOException{stub=newDirectoryStub();}@AfterEachvoidstopStub(){stub.close();}@OverrideLegacyDirectoryClientclient(){returnnewHttpLegacyDirectoryClient(stub.baseUri(),Duration.ofSeconds(2),Duration.ofSeconds(2));}@OverrideLegacyDirectoryClientfailingClient()throwsIOException{intclosedPort;try(ServerSocketsocket=newServerSocket(0)){closedPort=socket.getLocalPort();}returnnewHttpLegacyDirectoryClient(URI.create("http://127.0.0.1:"+closedPort),Duration.ofSeconds(2),Duration.ofSeconds(2));}}
packageedu.se506.lending.integration;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.DirectoryReservationService;importedu.se506.lending.application.ReservationCoordinator;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.DirectoryException;importedu.se506.lending.bootstrap.ReservationApplication;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importedu.se506.lending.infrastructure.directory.HttpLegacyDirectoryClient;importedu.se506.lending.infrastructure.directory.LegacyDirectoryAdapter;importedu.se506.lending.infrastructure.directory.LegacyDirectoryClient;importedu.se506.lending.infrastructure.notification.RecordingNotificationSender;importjava.io.IOException;importjava.net.ServerSocket;importjava.net.URI;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Clock;importjava.time.Duration;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importorg.junit.jupiter.api.AfterEach;importorg.junit.jupiter.api.BeforeEach;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.io.TempDir;/**
* The directory policy of Chapter 13 against a controlled local service: one attempt, a bounded wait,
* no retry and no fallback. An outage is a failure for the caller and never a "not found" answer, and a
* borrower is never given a category the provider did not state.
*/@Tag("integration")classDirectoryOutageIntegrationTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privateDirectoryStubstub;privateRecordingNotificationSendersender;privateReservationCoordinatorcoordinator;@BeforeEachvoidstart()throwsIOException{stub=newDirectoryStub();sender=newRecordingNotificationSender();coordinator=ReservationApplication.create(sender);}@AfterEachvoidstop(){stub.close();}privateDirectoryReservationServiceservice(URIbase,Durationtimeout,VisitorGategate){varclient=newHttpLegacyDirectoryClient(base,timeout,timeout);returnnewDirectoryReservationService(newLegacyDirectoryAdapter(client),coordinator,gate);}privatevoidassertNothingHappened(){assertTrue(coordinator.reservations().isEmpty());assertTrue(sender.received().isEmpty());}@TestvoidanOrdinaryStudentRequestWorksThroughTheRealHttpClient(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());assertEquals("Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).",service.reserve("B1","CAM1",START,2));assertEquals(1,sender.received().size());}@TestvoidaMissingRowIsAnOrdinaryRejection(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());assertEquals("Rejected: borrower not found or inactive.",service.reserve("NOBODY","CAM1",START,2));assertNothingHappened();}@TestvoidaServerErrorIsAnOutageNotANotFoundAndIsNotRetried(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());DirectoryExceptionfailure=assertThrows(DirectoryException.class,()->service.reserve("BROKEN","CAM1",START,2));assertInstanceOf(LegacyDirectoryClient.Failure.class,failure.getCause());assertEquals(1,stub.requests());assertNothingHappened();}@TestvoidaSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest(){varservice=service(stub.baseUri(),Duration.ofMillis(300),VisitorGate.disabled());longbegin=System.nanoTime();DirectoryExceptionfailure=assertThrows(DirectoryException.class,()->service.reserve("SLOW","CAM1",START,2));longelapsedMillis=Duration.ofNanos(System.nanoTime()-begin).toMillis();assertInstanceOf(LegacyDirectoryClient.Failure.class,failure.getCause());assertTrue(elapsedMillis<5_000,"the call must give up after its timeout, but took "+elapsedMillis+" ms");assertEquals(1,stub.requests());assertNothingHappened();}@TestvoidaStalledBodyIsAFailureWithinTheOverallWait(){varservice=service(stub.baseUri(),Duration.ofMillis(300),VisitorGate.disabled());// The headers and the first byte arrive at once and the rest is held. A client whose timeout covers// only the headers would wait for the stub to release the body and return a result, so the outcome// is asserted, with a generous outer deadline so that a defect shows as a failure and not a hang.DirectoryExceptionfailure=assertTimeoutPreemptively(Duration.ofSeconds(5),()->assertThrows(DirectoryException.class,()->service.reserve("SLOWBODY","CAM1",START,2)));assertInstanceOf(LegacyDirectoryClient.Failure.class,failure.getCause());assertEquals(1,stub.requests());assertNothingHappened();}@TestvoidaRefusedConnectionIsAnOutage()throwsIOException{intclosedPort;try(ServerSocketsocket=newServerSocket(0)){closedPort=socket.getLocalPort();}varservice=service(URI.create("http://127.0.0.1:"+closedPort),Duration.ofSeconds(2),VisitorGate.disabled());assertThrows(DirectoryException.class,()->service.reserve("B1","CAM1",START,2));assertNothingHappened();}@TestvoidaMalformedAnswerIsAnOutageNotAGuess(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());assertThrows(DirectoryException.class,()->service.reserve("GARBAGE","CAM1",START,2));assertNothingHappened();}@TestvoidanUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent(){varservice=service(stub.baseUri(),Duration.ofSeconds(2),VisitorGate.disabled());DirectoryExceptionfailure=assertThrows(DirectoryException.class,()->service.reserve("ODD","CAM1",START,2));assertEquals("Unsupported borrower category",failure.getMessage());assertNothingHappened();}@TestvoidanInactiveVisitorIsRejectedAndTheApprovalRegisterIsNotRead(@TempDirPathdir){PathmissingRegister=dir.resolve("never-read.tsv");vargate=newVisitorGate(newFileApprovalSource(missingRegister),Clock.systemUTC(),true);varservice=service(stub.baseUri(),Duration.ofSeconds(2),gate);assertEquals("Rejected: borrower not found or inactive.",service.reserve("VOFF","CAM1",START,2));assertNothingHappened();}@TestvoidaVisitorIsServedThroughTheRealDirectoryAndTheRealApprovalFile(@TempDirPathdir)throwsIOException{Pathregister=dir.resolve("approvals.tsv");Files.writeString(register,HEADER+"\nV1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t"+"2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z\n",StandardCharsets.UTF_8);Clockclock=Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"),ZoneOffset.UTC);vargate=newVisitorGate(newFileApprovalSource(register),clock,true);varservice=service(stub.baseUri(),Duration.ofSeconds(2),gate);assertEquals("Confirmed: R1 for V1 on CAM1 [2026-10-20, 2026-10-22).",service.reserve("V1","CAM1",START,2));assertEquals("Rejected: no valid sponsor approval.",service.reserve("V2","CAM1",START,2));assertEquals(1,coordinator.reservations().size());}}
packageedu.se506.lending.integration;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.domain.ApprovalRecord;importedu.se506.lending.domain.ApprovalStatus;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.NoSuchFileException;importjava.nio.file.Path;importjava.time.Instant;importjava.time.LocalDate;importjava.util.Optional;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.io.TempDir;/**
* Integration tests for the approval-register adapter. Every test writes a real file and reads it
* back through the real adapter: nothing replaces the file system or the parser, because those are
* the boundary under test.
*/@Tag("integration")classFileApprovalSourceIntegrationTest{privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);privatestaticfinalLocalDateEND=LocalDate.of(2026,10,22);privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatestaticfinalStringGOOD="V1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";@TempDirPathdir;privatePathregister(String...lines)throwsIOException{Pathfile=dir.resolve("approvals.tsv");Files.writeString(file,String.join("\n",lines)+"\n",StandardCharsets.UTF_8);returnfile;}privatestaticApprovalSourceExceptionfailureOf(Pathfile){returnassertThrows(ApprovalSourceException.class,()->newFileApprovalSource(file).find("V1","CAM1",START,END));}@TestvoidaMatchingRowIsReturnedWithEveryFieldParsed()throwsIOException{Optional<ApprovalRecord>found=newFileApprovalSource(register(HEADER,GOOD)).find("V1","CAM1",START,END);ApprovalRecordrecord=found.orElseThrow();assertEquals("S1",record.sponsorId());assertEquals(ApprovalStatus.APPROVED,record.status());assertEquals(Instant.parse("2026-10-18T00:00:00Z"),record.validFrom());assertEquals(Instant.parse("2026-10-20T00:00:00Z"),record.validUntil());}@TestvoidaRequestWithNoMatchingRowIsEmptyNotAFailure()throwsIOException{varsource=newFileApprovalSource(register(HEADER,GOOD));assertEquals(Optional.empty(),source.find("V2","CAM1",START,END));assertEquals(Optional.empty(),source.find("V1","CAM2",START,END));assertEquals(Optional.empty(),source.find("V1","CAM1",START,END.plusDays(1)));}@TestvoidaDeniedRowIsReturnedAsDeniedForTheCallerToDecide()throwsIOException{Stringdenied=GOOD.replace("APPROVED","DENIED");assertEquals(ApprovalStatus.DENIED,newFileApprovalSource(register(HEADER,denied)).find("V1","CAM1",START,END).orElseThrow().status());}@TestvoidtheFileIsReadAfreshOnEveryCall()throwsIOException{Pathfile=register(HEADER);varsource=newFileApprovalSource(file);assertEquals(Optional.empty(),source.find("V1","CAM1",START,END));Files.writeString(file,HEADER+"\n"+GOOD+"\n",StandardCharsets.UTF_8);assertTrue(source.find("V1","CAM1",START,END).isPresent());}@TestvoidaMissingFileIsAnOperationalFailureWithItsCause(){ApprovalSourceExceptionfailure=failureOf(dir.resolve("absent.tsv"));assertInstanceOf(NoSuchFileException.class,failure.getCause());}@TestvoidaWrongOrMissingHeaderIsAFailure()throwsIOException{failureOf(register(GOOD));failureOf(register(HEADER.replace("sponsorId","sponsor"),GOOD));Pathempty=dir.resolve("empty.tsv");Files.writeString(empty,"",StandardCharsets.UTF_8);failureOf(empty);}@TestvoidaRowWithTheWrongNumberOfColumnsIsAFailure()throwsIOException{failureOf(register(HEADER,GOOD+"\textra"));failureOf(register(HEADER,"V1\tCAM1\t2026-10-20"));}@TestvoidanUnknownStatusIsAFailureNotADenial()throwsIOException{failureOf(register(HEADER,GOOD.replace("APPROVED","approved")));failureOf(register(HEADER,GOOD.replace("APPROVED","PENDING")));}@TestvoidanInvalidDateOrInstantIsAFailure()throwsIOException{failureOf(register(HEADER,GOOD.replace("2026-10-20\t2026-10-22","20-10-2026\t2026-10-22")));failureOf(register(HEADER,GOOD.replace("2026-10-18T00:00:00Z","2026-10-18")));}@TestvoidaBlankSponsorOrAnInvalidIntervalIsAFailure()throwsIOException{failureOf(register(HEADER,GOOD.replace("\tS1\t","\t \t")));failureOf(register(HEADER,GOOD.replace("2026-10-20\t2026-10-22","2026-10-22\t2026-10-20")));failureOf(register(HEADER,GOOD.replace("2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z","2026-10-20T00:00:00Z\t2026-10-18T00:00:00Z")));}@TestvoidaDuplicateRequestKeyIsAFailureEvenIfTheRowsDisagree()throwsIOException{failureOf(register(HEADER,GOOD,GOOD.replace("APPROVED","DENIED")));}@TestvoidoneBadRowRejectsTheWholeRegisterEvenWhenTheRequestMatchesAGoodRow()throwsIOException{failureOf(register(HEADER,GOOD,"V9\tCAM2\tnot-a-date\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z"));}@TestvoidbytesThatAreNotUtf8AreAFailure()throwsIOException{Pathfile=dir.resolve("binary.tsv");byte[]header=(HEADER+"\n").getBytes(StandardCharsets.UTF_8);byte[]bad={(byte)0xC3,(byte)0x28,'\n'};byte[]all=newbyte[header.length+bad.length];System.arraycopy(header,0,all,0,header.length);System.arraycopy(bad,0,all,header.length,bad.length);Files.write(file,all);failureOf(file);}}
packageedu.se506.lending.e2e;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.integration.DirectoryStub;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.util.ArrayList;importjava.util.List;importjava.util.concurrent.TimeUnit;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.io.TempDir;/**
* End-to-end journeys at the real system boundary of this command-line application: a separate Java
* process, started as a user would start it, talking over HTTP to a controlled local directory service
* and reading a real approval file. The classpath comes from the system property e2e.classpath
* (default target/classes), so the same journeys can be run against a packaged jar.
*/@Tag("e2e")classChapter13JourneyE2ETest{privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatestaticfinalStringV1_CAM2="V1\tCAM2\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";@TempDirPathdir;privaterecordRun(intexitCode,List<String>lines){}privateRunrun(DirectoryStubstub,Pathregister,booleanvisitors)throwsIOException,InterruptedException{Stringjava=Path.of(System.getProperty("java.home"),"bin",System.getProperty("os.name").toLowerCase().contains("win")?"java.exe":"java").toString();Stringclasspath=System.getProperty("e2e.classpath","target/classes");ProcessBuilderbuilder=newProcessBuilder(java,"-Dlending.directory.url="+stub.baseUri(),"-Dlending.approvals.file="+register,"-Dlending.visitors="+visitors,"-Dlending.now=2026-10-19T12:00:00Z","-Dlending.directory.timeout.millis=2000","-cp",classpath,"edu.se506.lending.bootstrap.Chapter13Demo");builder.redirectErrorStream(true);Processprocess=builder.start();List<String>lines=newArrayList<>();try(varreader=newjava.io.BufferedReader(newjava.io.InputStreamReader(process.getInputStream(),StandardCharsets.UTF_8))){Stringline;while((line=reader.readLine())!=null)lines.add(line);}assertTrue(process.waitFor(60,TimeUnit.SECONDS),"the demo did not finish");returnnewRun(process.exitValue(),lines);}privatePathregister(String...rows)throwsIOException{Pathfile=dir.resolve("approvals.tsv");Files.writeString(file,String.join("\n",rows)+"\n",StandardCharsets.UTF_8);returnfile;}@TestvoidwithTheFlagOffVisitorsAreRefusedAndEveryoneElseIsServed()throwsException{try(varstub=newDirectoryStub()){Runrun=run(stub,register(HEADER,V1_CAM2),false);assertEquals(0,run.exitCode(),String.join("\n",run.lines()));assertEquals(List.of("Visitors enabled: false","Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).","Visitor V1: Rejected: visitor reservations are not enabled.","Visitor V2: Rejected: visitor reservations are not enabled.","Unknown U9: Rejected: borrower not found or inactive.","Outage BROKEN: Unavailable: Borrower directory unavailable","Reservations: 1","Notices: 1"),run.lines());}}@TestvoidwithTheFlagOnAnApprovedVisitorIsServedAndAnUnapprovedOneIsRefused()throwsException{try(varstub=newDirectoryStub()){Runrun=run(stub,register(HEADER,V1_CAM2),true);assertEquals(0,run.exitCode(),String.join("\n",run.lines()));assertEquals(List.of("Visitors enabled: true","Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).","Visitor V1: Confirmed: R2 for V1 on CAM2 [2026-10-20, 2026-10-22).","Visitor V2: Rejected: no valid sponsor approval.","Unknown U9: Rejected: borrower not found or inactive.","Outage BROKEN: Unavailable: Borrower directory unavailable","Reservations: 2","Notices: 2"),run.lines());}}@TestvoidaMalformedRegisterFailsOnlyTheVisitorRequestsAndIsReportedAsAnOutage()throwsException{try(varstub=newDirectoryStub()){Runrun=run(stub,register(HEADER,V1_CAM2,"garbage row"),true);assertEquals(0,run.exitCode(),String.join("\n",run.lines()));assertEquals("Student B1: Confirmed: R1 for B1 on CAM1 [2026-10-20, 2026-10-22).",run.lines().get(1));assertEquals("Visitor V1: Approval register unavailable: Row 3 has 1 columns, not 8.",run.lines().get(2));assertEquals("Visitor V2: Approval register unavailable: Row 3 has 1 columns, not 8.",run.lines().get(3));assertEquals("Reservations: 1",run.lines().get(6));}}}
4.7 The three changed regression tests
Each change adds the visitor policy to a map that lists every category, and one import where the class had none for it. Apply the hunks; every other line is as in the earlier companions. Lines starting with a plus sign are added and lines starting with a minus sign are removed.
--- chapter11/src/test/java/edu/se506/lending/structure/RepositoryBoundaryTest.java
+++ chapter13/src/test/java/edu/se506/lending/structure/RepositoryBoundaryTest.java
@@ -76,12 +76,14 @@
}
@Test void coordinatorRequiresARepository() {
var policies = Map.<BorrowerCategory, BorrowingPolicy>of(BorrowerCategory.STUDENT, new ReservationRules(),
- BorrowerCategory.STAFF, new StaffReservationRules());
+ BorrowerCategory.STAFF, new StaffReservationRules(),
+ BorrowerCategory.VISITOR, new VisitorReservationRules());
assertThrows(NullPointerException.class,
() -> new ReservationCoordinator(policies, new ConfirmationFormatter(), reservation -> { }, null));
}
private ReservationCoordinator coordinator(ReservationRepository repository, NotificationSender sender) {
return new ReservationCoordinator(Map.of(BorrowerCategory.STUDENT, new ReservationRules(),
- BorrowerCategory.STAFF, new StaffReservationRules()), new ConfirmationFormatter(), sender, repository);
+ BorrowerCategory.STAFF, new StaffReservationRules(),
+ BorrowerCategory.VISITOR, new VisitorReservationRules()), new ConfirmationFormatter(), sender, repository);
}
}
Other test files that build a coordinator needed no edit, and the 159 inherited invocations passed with these four edits and nothing else.
5. Build, scripts and documentation
5.1 after/pom.xml
Change the project version from 0.11.0 to 0.13.1 and nothing else. The POM already sets failIfNoTests to true for Surefire, which is why a stage that selects no tests fails (Section 1.3). The hunk is an edit to the Chapter 11 POM:
#!/usr/bin/env bash
# A modest delivery pipeline run on one machine. Each stage must pass before the next one starts,# and the first failing stage stops the run with a non-zero status. CHAPTER_MVN names the Maven# command (default: mvn); CHAPTER_M2 optionally names a local Maven repository.set -euo pipefail
cd"$(dirname "${BASH_SOURCE[0]}")/.."MVN="${CHAPTER_MVN:-mvn}"ARGS=(--batch-mode)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fistage(){localname="$1"shiftecho"=== STAGE: ${name}"localbegan=$SECONDS"$@"echo"=== PASSED: ${name} ($((SECONDS - began)) s)"}stage "1 compile""$MVN""${ARGS[@]}" -q compile
stage "2 unit tests and architecture checks""$MVN""${ARGS[@]}"test -DexcludedGroups=integration,e2e
stage "3 integration tests (real file, real HTTP client, local stub service)""$MVN""${ARGS[@]}"test -Dgroups=integration
stage "4 end-to-end journeys (separate process)""$MVN""${ARGS[@]}"test -Dgroups=e2e
stage "5 package""$MVN""${ARGS[@]}" package -DskipTests
echo"PIPELINE PASSED"
5.3 after/scripts/simulate-release.sh
#!/usr/bin/env bash
# This script does not enforce the pipeline's gates: it packages with -DskipTests and then runs only the# end-to-end smoke journeys, so it assumes that scripts/pipeline.sh has already passed for the same candidate.# Rollback assumes the same version-derived jar file name in both releases, so the exercise's two releases are# two labels for one build and not a tested cross-version rollback.## SIMULATION of continuous delivery to a staging target. Nothing here is a server, a network# deployment or a production release: "staging" is a folder, "deploying" is copying a jar into it,# and the smoke test is the end-to-end journeys run against the jar that was copied.## bash scripts/simulate-release.sh build, deliver as the current release, smoke-test it# RELEASE_LABEL=v2 bash scripts/simulate-release.sh deliver again; the old release becomes "previous"# bash scripts/simulate-release.sh rollback make "previous" the current release again, smoke-test itset -euo pipefail
cd"$(dirname "${BASH_SOURCE[0]}")/.."MVN="${CHAPTER_MVN:-mvn}"ARGS=(--batch-mode)if[ -n "${CHAPTER_M2:-}"];thenARGS+=("-Dmaven.repo.local=${CHAPTER_M2}")fiSTAGING="build/staging"VERSION="$(sed -n 's:^ <version>\(.*\)</version>$:\1:p' pom.xml | head -1)"JAR="equipment-lending-${VERSION}.jar"smoke(){echo"--- smoke test of $(cat "${STAGING}/current/RELEASE") (end-to-end journeys against ${STAGING}/current/${JAR})""$MVN""${ARGS[@]}"test -Dgroups=e2e "-De2e.classpath=${STAGING}/current/${JAR}"}case"${1:-deliver}" in
deliver)"$MVN""${ARGS[@]}" -q package -DskipTests
mkdir -p "${STAGING}"if[ -d "${STAGING}/current"];then rm -rf "${STAGING}/previous" mv "${STAGING}/current""${STAGING}/previous"fi mkdir -p "${STAGING}/current" cp "target/${JAR}""${STAGING}/current/${JAR}"echo"${RELEASE_LABEL:-${VERSION}}" > "${STAGING}/current/RELEASE"echo"--- delivered $(cat "${STAGING}/current/RELEASE") to ${STAGING}/current (simulation)" smoke
;; rollback)if[ ! -d "${STAGING}/previous"];thenecho"No previous release to roll back to." >&2exit1fi rm -rf "${STAGING}/current" mv "${STAGING}/previous""${STAGING}/current"echo"--- rolled back to $(cat "${STAGING}/current/RELEASE") (simulation)" smoke
;; *)echo"usage: simulate-release.sh [deliver|rollback]" >&2exit2;;esac
5.4 after/.github/workflows/pipeline.yml
This workflow expresses the stages as jobs, each waiting for the one before it (needs). It has not been run: no hosted CI run is claimed. The action versions are those in the file; check the current ones before relying on it.
name:Delivery pipelineon:push:pull_request:workflow_dispatch:permissions:contents:readjobs:unit:name:1Compile, unit tests and architecture checksruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Compilerun:mvn --batch-mode -q compile- name:Unit testsrun:mvn --batch-mode test -DexcludedGroups=integration,e2eintegration:name:2Integration testsneeds:unitruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Integration testsrun:mvn --batch-mode test -Dgroups=integratione2e:name:3End-to-end journeysneeds:integrationruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Compilerun:mvn --batch-mode -q compile- name:End-to-end journeysrun:mvn --batch-mode test -Dgroups=e2epackage:name:4Package the release candidateneeds:e2eruns-on:ubuntu-latesttimeout-minutes:10steps:- uses:actions/checkout@v7- uses:actions/setup-java@v6with:distribution:temurinjava-version:'17'cache:maven- name:Packagerun:mvn --batch-mode package -DskipTests- name:Keep the jaruses:actions/upload-artifact@v7with:name:release-candidatepath:target/equipment-lending-*.jarif-no-files-found:error
5.5 after/README.md
# Chapter 13 checkpoint
Use a full Java 17 JDK and Maven 3.9.11. The JDK must include jdk.jdeps for the compiled dependency tests. Existing dependency and plugin versions are unchanged; only the project version changes (to 0.13.1).
From this directory, run the whole delivery pipeline (compile; unit tests and architecture checks; integration tests; end-to-end journeys; package):
```bash
bash scripts/pipeline.sh
```or the single command `mvn --batch-mode verify`, which runs every test in one pass. The build executes 232 JUnit invocations: 159 inherited from Chapter 11 and 73 new. Of the 232, 203 run in the unit stage, 26 are tagged `integration` and 3 are tagged `e2e`.
What Chapter 13 adds: a visiting-researcher category (VISITOR), a VisitorReservationRules policy, a VisitorGate that holds one feature flag (default off), an item allowlist and the approval check, an ApprovalSource port with a real file adapter (FileApprovalSource) that reads a tab-separated sponsor register, and an HTTP client for the borrower directory (HttpLegacyDirectoryClient) with a bounded wait over the whole lookup (the body included), one attempt, no retry and no fallback answer. A missing approval is an expected rejection; an unreadable register or an unreachable directory is a failure and never looks like a rejection or a missing borrower.
The directory service in the tests is a local stub built on the JDK's embedded HTTP server. The end-to-end journeys start Chapter13Demo as a separate process against that stub and a real approval file; the demo reads its settings from system properties (see the class comment). There is no real directory service or approval service, so what the stub does is an assumption.
`scripts/simulate-release.sh` is a SIMULATION of delivery: staging is a folder, deploying is copying a jar, and the smoke test is the end-to-end journeys against the copied jar. `.github/workflows/pipeline.yml` expresses the same stages for a hosted runner; it has not been run. `docs/ADR-013-visiting-researchers.md` records the decision, the flag removal plan and the rollback. The directory `ai-activity` holds the two tests of the AI activity; they are not part of the Maven build.
SD-11-1 (the report's rate for any other kind of borrower, which a late visitor now reaches) and SD-12 (the count rule counts every retained reservation) are not changed here.
The package `legacy` stands for the old records system's overdue report. It is isolated, like `patterns`, `behaviour` and `examples`: production code under `edu.se506.lending` must not refer to it, and DependencyRulesTest checks that. Only bootstrap launches it or feeds it rows. LoanRow is the old system's export format and is deliberately unchanged.
The optional bridge (OverdueRowSource, OverdueReportService and LoanSnapshotRows in bootstrap) feeds the report from the live loan and reservation snapshots. It reads one date and one snapshot per report, and a failure to read is never reported as zero overdue loans.
The directory `stages` holds the report at each step of the refactoring. It is not part of the Maven build. To run a stage, copy its OverdueReport.java over src/main/java/legacy/OverdueReport.java and run:
```bash
mvn --batch-mode -Dtest='OverdueReport*Test'test```Stages 1 to 4 each pass the same 28 invocations. Stage 0 is the code as found and cannot be tested without a change, because it reads the system clock. stageC is a separate functional change (unknown kinds are rejected) with its own replacement tests; to run it, copy all three of its files to their src locations.
The directory `probe` holds a program that runs the original, unseamed report beside the seamed one and compares their output. It is not part of the Maven build. From this directory, with stage 1 or the final report in place:
```bash
mkdir -p probe/out
javac --release 17 -d probe/out src/main/java/legacy/*.java probe/OriginalUnseamed.java probe/OriginalReportProbe.java
java -cp probe/out legacy.OriginalReportProbe
```The characterisation tests record what the report does today. A passing characterisation test means unchanged, not correct. Four behaviours are recorded as suspected or surprising in docs/SUSPECTED-DEFECTS-legacy-overdue-report.md and are not fixed here. ADR-011 records the decision to pin, isolate and refactor the report.
To print the package metrics, run `mvn --batch-mode compile` and then `java tools/PackageMetrics.java target/classes`.
ADR-010 from Chapter 10 is still in docs. No hosted CI run and no peer review is claimed.
5.6 after/docs/ADR-013-visiting-researchers.md
# ADR-013: Visiting-researcher reservations behind a flag, with two real boundaries
Date: 13 October 2026
Status: Implemented teaching example; peer review pending.
## Context
Change request CR-12 (Chapter 12) adds a visiting-researcher category. A visitor may reserve CAM1 or CAM2 for one to three days when a sponsor approval matches the exact borrower, item and period and is valid at request time. Student and staff behaviour must not change.
Two outside collaborators now have real boundaries. The borrower directory, which the synthetic fixture of Chapter 8 stood in for, can be slow, down or wrong. The sponsor-approval register is a file that can be missing or malformed. Until now neither could fail in a way that tests could see.
## Decision
- Add `VISITOR`, a `VisitorReservationRules` policy (count and duration only) and a `VisitorGate` that holds the feature flag, the item allowlist and the approval check. The existing `BorrowingPolicy` contract is not widened.
- Define an `ApprovalSource` port. `FileApprovalSource` reads a UTF-8 tab-separated register of eight columns and rejects the whole file if any row is invalid or any request key repeats. A missing record is an expected rejection. An unreadable or untrustworthy register is an `ApprovalSourceException`, which no code on the reservation path catches.
- Reach the directory over HTTP with `HttpLegacyDirectoryClient`, behind the existing `LegacyDirectoryClient` contract. **Policy: one attempt, a bounded wait, no retry and no fallback answer.** The wait bounds the whole lookup, including the time to receive the body. A timeout, a refused connection, any other status and a malformed row are failures, which the adapter turns into `DirectoryException`. An outage is never reported as “not found”, and no borrower is ever given a category that the provider did not state.
- Introduce one flag, `visitingResearchersEnabled`, default **false**. When it is off, visitor requests are rejected explicitly and the approval register is not read. Student and staff requests never depend on it.
- Test the file adapter with real temporary files and the HTTP client against a local stub service, and run the contract for directory clients against both the in-memory fixture and the HTTP client. Run focused end-to-end journeys against a separate process.
## Alternatives considered
- Pass sponsor approval to `BorrowingPolicy` through a flag or a static: rejected, because the policy would then depend on hidden global state and could not be tested for identity or time.
- Fall back to a cached or default directory answer after a timeout: rejected, because a stale answer would grant a category the provider did not currently state.
- Retry after a timeout: not adopted in this change. A retry would repeat a slow call for the person waiting, and it would change the one-attempt notification and directory contracts of earlier chapters.
- A database or a real HTTP service for approvals: not needed for CR-12 and outside the course scope.
## Consequences
The directory and the register are now two places that can fail at the edge of the system, and each failure has its own test. Adding `VISITOR` forced a policy for it in every coordinator construction, so four fixture constructions in three test files that enumerate every category were updated. No assertion was weakened.
The count rule still counts every retained reservation, which is SD-12, unresolved. The legacy overdue report still treats any kind other than `S` or `T` at the rate of SD-11-1, so a visitor who is late is charged one unit a day with no grace and no cap. Neither is changed here.
## Flag removal plan
The flag exists to let the controlled demonstration prove both paths before visitors are released. Remove it when all of these hold: the released variant of CR-12 is accepted, both settings have passed in the pipeline for the release candidate, and a decision to keep visitors enabled is recorded. Then delete `VisitorGate.DISABLED`, the `enabled` parameter and the disabled-path tests together, in one commit. Review the flag at the end of Chapter 14's handover; a flag with no removal decision is a permanent branch.
## Rollback
To stop new visitor reservations, turn the flag off. That does not delete existing reservations or undo notices already sent, and an existing reservation can still be checked out and returned. To go back to the previous release, redeliver it (the simulated delivery keeps one previous release) and smoke-test it.
## Validation and review
Run `bash scripts/pipeline.sh` for all stages, then `bash scripts/simulate-release.sh` for the simulated delivery. No hosted CI run, public deployment or independent peer review is claimed.
## Revisit triggers
A decision on SD-11-1 or SD-12; a requirement to revoke an approval after a reservation; a real approval service or directory with authentication; concurrent writers to the register; a need for retry or caching after measured outages.
6. The AI activity files
Both files live in after/ai-activity/ and are outside the build. To run them, copy them into src/test/java/edu/se506/lending/aifixture/ and run mvn test -Dtest='ProposedRegisterIntegrationTest,RepairedRegisterIntegrationTest' -Dgroups=integration.
packageedu.se506.lending.aifixture;import staticorg.junit.jupiter.api.Assertions.*;importedu.se506.lending.application.VisitorGate;importedu.se506.lending.application.port.ApprovalSourceException;importedu.se506.lending.infrastructure.approvals.FileApprovalSource;importjava.io.IOException;importjava.nio.charset.StandardCharsets;importjava.nio.file.Files;importjava.nio.file.Path;importjava.time.Clock;importjava.time.Instant;importjava.time.LocalDate;importjava.time.ZoneOffset;importjava.util.Optional;importorg.junit.jupiter.api.Tag;importorg.junit.jupiter.api.Test;importorg.junit.jupiter.api.io.TempDir;/** The repaired test: a real register file, read by the real adapter, through the real gate. */@Tag("integration")classRepairedRegisterIntegrationTest{privatestaticfinalStringHEADER="borrowerId\titemId\tstart\tendExclusive\tsponsorId\tstatus\tvalidFrom\tvalidUntil";privatestaticfinalStringROW="V1\tCAM1\t2026-10-20\t2026-10-22\tS1\tAPPROVED\t2026-10-18T00:00:00Z\t2026-10-20T00:00:00Z";privatestaticfinalLocalDateSTART=LocalDate.of(2026,10,20);@TestvoidanApprovedVisitorIsAcceptedFromARealRegisterFile(@TempDirPathdir)throwsIOException{Pathfile=dir.resolve("approvals.tsv");Files.writeString(file,HEADER+"\n"+ROW+"\n",StandardCharsets.UTF_8);Clockclock=Clock.fixed(Instant.parse("2026-10-19T12:00:00Z"),ZoneOffset.UTC);VisitorGategate=newVisitorGate(newFileApprovalSource(file),clock,true);assertEquals(Optional.empty(),gate.rejection("V1","CAM1",START,2));assertEquals(Optional.of(VisitorGate.NOT_APPROVED),gate.rejection("V2","CAM1",START,2));Files.writeString(file,HEADER+"\n"+ROW+"\nbroken row\n",StandardCharsets.UTF_8);assertThrows(ApprovalSourceException.class,()->gate.rejection("V1","CAM1",START,2));}}
7. Worked explanation and evidence
7.1 Why the gate sits in front of the coordinator
BorrowingPolicy.mayRequest(count, days) answers a count and duration question and nothing else. Approval needs the borrower, the item, the period and the time. Putting any of those into the policy through a flag or a static would give it hidden state and make it untestable for identity and time. So the policy stays small (VisitorReservationRules), and the request-specific decision lives in VisitorGate, which sits in the application layer and is called by DirectoryReservationService before the coordinator. Student and staff requests never reach the gate. The order inside the gate is cheap to expensive: flag, then item, then duration, then one clock read, then one register read, then an exact-request re-check and the validity window.
The exact-request re-check looks redundant, since the source is asked for the exact request. It is there because the source is a boundary: a source that returns the wrong record (a bug, or a register that was changed underneath) must not approve. aSourceThatReturnsADifferentRequestDoesNotApprove and the fault experiment in Section 12.2 test it.
7.2 Exceptions and expected results
An expected result is a return value; an operational failure is an exception that nothing on the request path catches. FileApprovalSource.find returns an empty Optional when no record matches and throws ApprovalSourceException when the register cannot be trusted. The gate does not catch it, and neither does the service. The demonstration catches it at the edge and prints Approval register unavailable: .... That is the whole difference between “no sponsor approved this” and “we could not find out”, and AC10 checks that a malformed register creates no reservation and sends no notice. The same applies to the directory: DirectoryException is the failure and a missing row is a rejection.
FileApprovalSourceIntegrationTest writes real files into a @TempDir and reads them through the real adapter. Thirteen tests cover: a matching row with every field parsed; no matching row (empty, not a failure); a denied row (returned for the caller to decide); a file changed between two calls (read afresh every time); a missing file (a failure, with the NoSuchFileException as its cause); a wrong, missing or empty header; a row with the wrong number of columns; an unknown status (a failure, not a denial); a bad date or instant; a blank sponsor or an interval whose end is not after its start; a duplicate request key even when the rows disagree; one bad row rejecting the whole register even when the request matches a good row; and bytes that are not UTF-8.
The whole-file rule is a design choice, and it has a cost: one bad row stops every visitor request until the register is repaired. The alternative, skipping bad rows, would let a typo silently remove a sponsor’s approval, or worse, hide a duplicate that makes two rows disagree. The fault experiment includes it (Section 12.2), and the ADR names the revisit triggers.
7.5 What the stub is, and what it is not
DirectoryStub is a JDK HttpServer on the loopback address with an ephemeral port. It answers keys B1, T1, V1, V2, VOFF, ODD and GARBAGE with prepared bodies, BROKEN with status 500, SLOW by not answering until the test releases it, and SLOWBODY by sending the headers and the first byte of the body and then holding the rest until the test releases it. Closing the stub releases any waiting request, so a test never leaves a thread hanging. Its answers are the author’s assumption of what a provider could say. Nothing in this book checks them against a real directory, which is why the chapter names a provider contract test as a limit and not as something done. The interface contract (DirectoryClientContract) is run against the in-memory example and the HTTP client, so a caller can substitute one for the other; that is a statement about our two implementations, not about the provider.
7.6 The failing pipeline, read
A deliberate fault was introduced into a scratch copy: the header comparison in FileApprovalSource.readAll was changed from lines.isEmpty() || !HEADER.equals(lines.get(0)) to lines.isEmpty(). The pipeline ended with exit status 1 after stage 3, which is where a person should look first:
=== STAGE: 1 compile
=== PASSED: 1 compile (5 s)
=== STAGE: 2 unit tests and architecture checks
[INFO] Tests run: 203, Failures: 0, Errors: 0, Skipped: 0
=== PASSED: 2 unit tests and architecture checks (11 s)
=== STAGE: 3 integration tests (real file, real HTTP client, local stub service)
[ERROR] edu.se506.lending.integration.FileApprovalSourceIntegrationTest.aWrongOrMissingHeaderIsAFailure -- Time elapsed: 0.013 s <<< FAILURE!
org.opentest4j.AssertionFailedError: Expected edu.se506.lending.application.port.ApprovalSourceException to be thrown, but nothing was thrown.
[ERROR] Failures:
[ERROR] FileApprovalSourceIntegrationTest.aWrongOrMissingHeaderIsAFailure:84->failureOf:44 Expected edu.se506.lending.application.port.ApprovalSourceException to be thrown, but nothing was thrown.
[ERROR] Tests run: 26, Failures: 1, Errors: 0, Skipped: 0
Stages 4 and 5 never started. Reading it: the first failing stage is 3; the first failing test, by name, promises that a wrong or missing header is a failure; the assertion says that it was not. Stage 2 could not have caught it, because no unit test reads a real file: the stage-2 tests use a controlled approval source, and that is correct for their purpose. The other 25 integration tests passed, so the fault is local to the header, and the test’s first assertion that fails is at line 84 (the first of its three cases: a file with no header at all, the register’s first row used as a header and skipped). This is the reason the integration stage exists.
7.7 The simulated delivery and its limits
simulate-release.sh kept one previous release and smoke-tested each step against the copied jar. The script packages with the tests skipped and runs only the end-to-end journeys, so it assumes that the pipeline has already passed for the same candidate; it does not enforce the other gates. Rollback assumes the same version-derived jar file name in both releases. Both releases were built from the same source, so the rollback demonstrated the mechanics (which folder is current, and that the smoke test runs against it) and not a change of behaviour. A real rollback has more to settle: data written by the new release, notices already sent and reservations already made (AC16 pins that a visitor reservation made before the flag went off stays usable).
7.8 A timeout that does not cover the body
A client that calls the blocking send with a request timeout relies on a timeout that the JDK documents as the time within which the response must be received. The documentation says nothing about a body [8]. A test that uses a stub which waits before sending anything exercises the one case the timeout certainly covers.
A different case: a server that sends the headers and the first byte at once and holds the rest. With the client’s timeout at 300 ms and the server holding the body for 1,600 ms, a client that relies on the request timeout alone returned a valid result after about 1.7 seconds. The policy said “a bounded wait” and that client did not keep it, because the timeout bounded the wait for the headers and not for the whole answer.
The client in this chapter therefore sends the request asynchronously and waits for the future for at most the configured time. On timeout it cancels the future, which asks the client to drop the exchange and is not guaranteed to be prompt or to take effect [13][14], and fails the lookup with a message that names the wait. The caller does not depend on the cancellation, because it has already stopped waiting. An interrupted wait cancels, restores the interrupt flag and fails. A failed future becomes a Failure whose message names the cause’s class. The header timeout stays on the request.
Run
Result
New test aStalledBodyIsAFailureWithinTheOverallWait against a client that relies on the request timeout alone
Fails: execution timed out after 5000 ms (the outer deadline), because the client waits for the stub to release the body and returns it
The same test against the client in this chapter
Passes. A probe with the same 300 ms timeout and 1,600 ms stall fails the lookup after about 330 ms: Directory did not answer within 300 ms.
Fault experiment, Section 12.2
Removing the overall deadline is caught by the new test alone. Removing the header timeout alone is not caught: the overall deadline still bounds the call, so it is an equivalent change. Removing both is caught by both slow-provider tests
Not changed: one logical attempt, no retry, no fallback category, the distinction between an unavailable directory and an absent borrower, and no reservation or notice after a failure.
7.9 The order of the visitor decision
The order of the checks is observable when more than one thing is wrong. The gate checks the flag, the item, and (for fewer than one day) steps aside; otherwise it reads the clock and the register; then the coordinator applies the count and duration rules and the availability rule. A four-day visitor request with an unreadable register therefore fails with the register’s error and is not rejected for its duration. The test precedence_theRegisterIsReadBeforeTheCountAndDurationRulesApply pins both ends: four days with an unreadable register throws the register’s exception, and zero days does not read the register and returns the limit rejection. It is not one of the eighteen acceptance examples and is not named ac<N>_.
If a different precedence is wanted, for example duration before register, it is a change to the contract, the implementation and the tests, decided by the requirement’s owner. The picture follows the code.
7.10 What this evidence does not show
The pipeline and the tests show the selected behaviours on the supplied inputs in the stated environment. They do not show behaviour against a real directory or a real approval workflow, concurrency (two writers of the register, or two simultaneous requests), authentication, behaviour under load, a hosted run, Java 17 behaviour, or that the suspected defects SD-11-1 and SD-12 are acceptable. AC18 shows that SD-11-1 now has a consequence for a late visitor, and the change leaves it as it was.
8. Suggested practical solution
8.1 the book’s project, steps B1–B4
B1. Run the pipeline. A good answer reproduces the counts of Section 1.2 (203, 26, 3), notes the time of each stage, and says what a failure at each stage means: 1, the code does not build; 2, a rule or an architectural boundary of our own code is wrong; 3, an adapter does not meet its boundary; 4, the pieces are not joined or a setting is not read; 5, the artefact cannot be built.
B2. Trace three examples. For example:
Example
Test
Deciding production code
Observable result
Stage
AC9 (the expiry boundary)
ac9_...
ApprovalRecord.validAt, called from VisitorGate.rejection
Rejected: no valid sponsor approval. at validUntil; no reservation
2
AC10 (operational failure)
ac10_... and the third journey
FileApprovalSource.readAll throws; nothing catches it on the request path
An ApprovalSourceException; no reservation or notice. The demonstration prints Approval register unavailable: ...
2 and 4
AC12 (the flag)
ac12_... and the first journey
VisitorGate.rejection, the first check
Rejected: visitor reservations are not enabled.; the register is not read
2 and 4
B3. Make a test bite. A good answer predicts before running. Example: change FileApprovalSource so that an unknown status becomes DENIED. Prediction: stage 2 passes (no unit test reads the real file), and stage 3 fails at anUnknownStatusIsAFailureNotADenial. Section 12.2 lists thirty faults and the tests that caught them; use it to check a prediction after making it, not before.
B4. The evidence table. A good answer has one row per risk of Section 13.1, with the test’s name and what it cannot show, and at least one risk that nothing here covers, for example: the real directory’s behaviour; two concurrent writers of the register; a sponsor who is revoked after a reservation; and the effect of an unusual time zone on the request instant.
8.1a the book’s project, optional steps B5–B7
B5. Deliver and roll back. Run the commands of Section 1.4. A good answer records current and previous after each step (after the second delivery, v2 is current and v1 previous; after the rollback, v1 is current and nothing is previous), and says that the rollback in this simulation changes a folder and not any data.
B6. AI activity. See Section 9.
B7. Break a boundary on purpose. In a scratch copy, change the timeout of aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest from 300 milliseconds to twenty seconds and run mvn test -Dtest=DirectoryOutageIntegrationTest -Dgroups=integration. Observed: the test fails after 20.03 seconds with the call must give up after its timeout, but took 20015 ms, and the class takes 20.58 seconds instead of 1.01. The client did exactly what it was told, and the test’s own bound (five seconds) caught that the wait is too long to be useful. A good answer explains two things: a test of a timeout should use a short, injected timeout, and a pipeline that waits for the real timeout of every failure case is itself a problem, because slow feedback gets skipped.
8.2 your team’s project, steps T1–T5
These are not model answers, because the code is your team’s. A good answer shows:
T1: the change implemented from the smallest vertical slice outwards; one flag that defaults to off; acceptance examples that each have a test; expected rejections and operational failures that are different results.
T2: a boundary test that does not replace the boundary: a real temporary file, a local stub service or a throw-away database; at least one failure case (missing, malformed or slow); no mock standing in for the reader under test.
T3: the existing suite run first; every changed example listed with the reason; tests for the flag on and off.
T4: at least three stages; a captured passing run; a captured failing run read in the form of Section 7.6 (first failing stage, first failing test, why the earlier stages could not have caught it); a stage that fails when it selects nothing.
T5: a removal plan for the flag with a named condition; a rollback step that says what it does not undo; a demonstration; a retrospective with one next action; a limits statement; and the eight items of Section 13.8, each marked complete or a gap.
A weak answer replaces the boundary with a mock and calls it an integration test, tests only the happy path of the boundary, defaults to a guess when a service fails, or claims a hosted run that did not happen.
9. AI activity solution
The example describes a test that “builds a visitor approval, makes the register return it, and checks that the gate accepts the visitor and that the register was consulted”. The code does that, and nothing more.
Question
Answer
Which part of the system does it touch?
VisitorGate and ApprovalRecord. Nothing else.
Is there a file in it?
No. mock(ApprovalSource.class) replaces the register; FileApprovalSource, the parser and the file system are not in the test.
What does it say after the parser is deleted?
The same: it passes.
What does @Tag("integration") promise that the body does not deliver?
A test of a real boundary. The tag puts it in stage 3, where it adds time and a false sense of cover.
What does verify(register).find(...) establish?
That the gate asked the mock for the exact request. That is a fact about the gate, not about the register.
Both tests were run against two faults, each applied to its own scratch copy of the final code.
Run
Proposed test
Repaired test
No fault
Passes
Passes
Fault A: the validFrom and validUntil columns are read the wrong way round
Passes
Fails: anApprovedVisitorIsAcceptedFromARealRegisterFile throws ApprovalSourceException: Row 2 is not a valid approval record.
Fault B: a broken row is skipped instead of rejecting the whole file
Passes
Fails: Expected ApprovalSourceException to be thrown, but nothing was thrown at the broken-row assertion
The example’s description is accurate about what the test does, and the name and tag are misleading about what it verifies. The repaired test writes a real register into a temporary directory, reads it through the real FileApprovalSource, passes it to the real gate, and adds the failure case: a broken row appended to the file makes the gate throw the register’s exception and not reject politely.
What the original is good for: it is a fair unit test of the gate’s decision logic with a controlled source, and it belongs in the unit stage under an honest name (VisitorGateTest has several like it). Accept it as a unit test; reject it as an integration test.
The repaired test is deliberately small. It does not replace FileApprovalSourceIntegrationTest, which carries the detail, and it covers only the faults that reach the gate. A header fault, for example, would pass it. Run against the two faults above, it says what it covers and no more.
The secondary activity (reading a pipeline failure) is Section 7.6. If a live assistant is used, keep its actual output, apply any test it writes to a scratch copy, and run it against a fault. Do not use a generated test to approve generated code.
10. Intentionally flawed alternatives
These fragments are diagnostic alternatives, not files to compile. Each corresponds to a fault in the experiment of Section 12.2 or to a mistake in Section 13.10 of the chapter.
10.1 An outage that becomes “not found”
// WRONG: a failure of the provider is reported as an absent borrower.try{returnfetch(key);}catch(IOException|InterruptedExceptionfailure){returnnull;}
The caller then tells a real borrower that they do not exist. The fault experiment includes “connection failure treated as not found”, and aRefusedConnectionIsAnOutage catches it. The correct code fails the request with a Failure that the adapter turns into DirectoryException.
10.2 A guess for an unknown code
// WRONG: an unknown kind is given the nearest known category.default->BorrowerCategory.STUDENT;
A visitor, or a kind added tomorrow, becomes a student without anyone deciding it. anUnknownCategoryCodeIsNeverDefaultedToStaffOrStudent fails it.
10.3 A wait that is not bounded
// WRONG: no timeout; the request waits for ever.HttpRequestrequest=HttpRequest.newBuilder(uri).GET().build();
The officer’s screen freezes. aSlowProviderTimesOutAfterTheBoundedWaitAndFailsTheRequest fails it.
10.4 A mock standing in for the file
// WRONG for a test named FileApprovalSource...: the reader is replaced by the thing it should be tested against.ApprovalSourceregister=mock(ApprovalSource.class);
Section 9 runs it.
10.5 A flag that is read once and never removed
// WRONG: a flag with no owner and no removal condition.publicstaticbooleanvisitingResearchersEnabled=true;
A global, mutable and on by default. It cannot be tested for both settings in one process without cleaning up, and it has no removal plan. The chapter’s flag is a constructor argument that defaults to off, and the ADR names the condition for removal.
10.6 A stage that can select nothing
mvn test -Dgroups=integraton # misspelt: no test has this tag
Without failIfNoTests, a stage that runs zero tests passes. With it, the stage fails with No tests were executed!. The pipeline would otherwise report green for a stage that checked nothing.
11. Review answers and transfer solution
11.1 Concept answers
1. Unit test and a file adapter. A unit test controls everything the unit collaborates with, so it cannot show that the real parser reads the real file correctly: the file system, the encoding, the line endings and the parser are exactly what it has replaced. It shows that the logic that consumes a parsed record is right when given one.
2. The directory contract. It says that a known key returns its row, an unknown key returns nothing and is not a failure, and a provider that cannot answer fails and never returns an empty answer. Two implementations (the in-memory example and the HTTP client against the stub) pass it, so they are interchangeable for a caller. It can never show how the real provider behaves: the stub’s answers are the author’s assumption, and a provider contract test run against the real service would be the evidence.
3. Why not “not found”. An empty answer tells a caller the borrower is absent. After a timeout we do not know that. Two other defaults the policy refuses: treating an unknown kind code as student or staff, and answering from a cached or earlier result when the provider is down.
4. Delivery and deployment. Continuous delivery keeps the software releasable at any time; continuous deployment releases every passing change automatically. simulate-release.sh imitates delivery (a release candidate that has passed the stages and is smoke-tested in a staging folder). It imitates no deployment to production.
11.2 Applied diagnosis answers
5. The file reader replaced by a mock. The risk that is no longer covered is the parse of the real register: the header, the columns, the dates, the encoding and the duplicate keys. Keep the repeatability by using a temporary directory: write the file inside the test, read it with the real adapter, and delete it afterwards. Repeatable and real are not opposites.
6. A green pipeline and a failing demonstration. In the pipeline, an end-to-end journey with the flag on, which would have started the program with the flag that the demonstration uses. In the delivery process, a smoke test of the packaged jar with the settings of the target and a printed first line stating the effective setting (the program prints Visitors enabled: ...). Both are checks of configuration, and neither is a check of logic.
11.3 Transfer solution
For the model-serving team:
Risk
Check
The approval rule (only approved versions are served to external callers)
Unit tests with a controlled approval source: approved, not approved, expired, a different version, the boundary of the validity window
The approval list file has a stray column or a duplicate
Integration test with a real temporary file and each failure case; a whole-file rule or a skip rule, chosen and written down
The registry times out or returns nonsense
Integration test against a local stub with a bounded wait; the policy says the request fails and the version is not served, and that an unknown version is never defaulted to the latest approved one
The flag is on in the demonstration and off in the build
One end-to-end journey that starts the serving process with each setting
Old behaviour changed
The existing suite
The flag enables the rule for external callers; rollback turns it off, but does not recall a response already served or revoke an approval a person already acted on. The evidence would not show the model’s accuracy, fairness or drift: nothing here evaluates the model, only the gate in front of it.
11.4 Quiz 2 revision pointers
None. Quiz 2 covers the earlier weeks; Section 13.11 of the chapter creates no new assessment.
12. Instructor notes
12.1 Suggested demonstration and review
Run pipeline.sh on a clean copy and read each stage’s counts aloud. Then make the header-check fault of Section 7.6 and run it again; ask students to predict which stage fails before they see it. Run the flag-off and flag-on journeys and ask what the first printed line is for. Run the simulated delivery twice and the rollback, and ask what the rollback did not change. Show the proposed AI test passing a parser fault before showing the repaired one failing.
Review your team’s project against the eight items of Section 13.8 of the chapter: ask for the first failing stage of the failing run, the name of the test, and a statement of what the pipeline did not check. A weak answer to the last question is “nothing”.
12.2 Do the tests detect faults?
Thirty faults were run one at a time in a copy of the project, through mvn test. Twenty-nine were caught. One survived, and it is an equivalent change (below). The harness reports the first two failing tests, in alphabetical order, so the third column is a sample of what noticed each fault and not a full list.
Area
Fault
A test that caught it
Gate
Duration check removed (the register is read for zero days)
One reading note: some faults were first noticed by an end-to-end journey (the service and directory-mapping faults, for example). That does not mean no narrower test would notice; the harness lists only the first two names. It does mean those faults would be caught by stage 4 if every earlier stage were somehow silent, which is a reason to keep a few journeys.
Before the stalled-body test existed, no fault in the experiment concerned the body of the response, so the experiment could not have found the problem of Section 7.8. A fault experiment finds the faults that someone thought to apply, which is the same limit as the tests it measures. These counts come from one fault set on one small change, chosen by the author, and they say nothing about how this suite compares with earlier chapters. They do not show that the suite catches every fault, that the real directory behaves like the stub, or that the rule is the right rule.
References
The online references of the student chapter (sources 1–9, 13 and 14) were opened on 13 October 2026; see the chapter for the source context.