Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
380a880
Add full User entity to the JSON payload
hurricanepilot Jul 2, 2026
5b573c6
Expose court lock timeout as an env var
hurricanepilot Jul 2, 2026
37c2f87
Merge branch 'master' into FACT-2674-being-edited-by
hurricanepilot Jul 2, 2026
9a83c09
Change empty results to return a 204 + null rather than 200 + null
hurricanepilot Jul 3, 2026
527fab1
Add additional Page types
hurricanepilot Jul 3, 2026
d9933e1
Merge branch 'master' into FACT-2674-being-edited-by
hurricanepilot Jul 6, 2026
5f81973
Refactor court lock functionality to general lock management
hurricanepilot Jul 6, 2026
9144e81
revert build.gradle change that downgrades the blob store api.
hurricanepilot Jul 6, 2026
ac28b6a
Rename `AuditSubjectType` to `SubjectType` and add SPoE Page enum
hurricanepilot Jul 7, 2026
d900b50
Refactor lock handling and clean up code
hurricanepilot Jul 7, 2026
b8f0832
Coverage
hurricanepilot Jul 8, 2026
6dabcb9
Merge master into FACT-2674-being-edited-by
github-actions[bot] Jul 8, 2026
8dc186e
Merge master into FACT-2674-being-edited-by
github-actions[bot] Jul 8, 2026
922d7a1
Rework lock service/repo to avoid concurrentcy issues + PR RC updates
hurricanepilot Jul 8, 2026
e79fa51
Merge branch 'FACT-2674-being-edited-by' of github.com:hmcts/fact-dat…
hurricanepilot Jul 8, 2026
916c518
whitespace
hurricanepilot Jul 9, 2026
cdf1cb5
Merge master into FACT-2674-being-edited-by
github-actions[bot] Jul 9, 2026
f0e621d
Merge master into FACT-2674-being-edited-by
github-actions[bot] Jul 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
# FaCT Data API

[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=uk.gov.hmcts.reform%3Afact-data-api&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=uk.gov.hmcts.reform%3Afact-data-api)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=uk.gov.hmcts.reform%3Afact-data-api&metric=coverage)](https://sonarcloud.io/summary/new_code?id=uk.gov.hmcts.reform%3Afact-data-api)
[![Lines of Code](https://sonarcloud.io/api/project_badges/measure?project=uk.gov.hmcts.reform%3Afact-data-api&metric=ncloc)](https://sonarcloud.io/summary/new_code?id=uk.gov.hmcts.reform%3Afact-data-api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
import uk.gov.hmcts.reform.fact.data.api.entities.ServiceCentreContactDetails;
import uk.gov.hmcts.reform.fact.data.api.entities.User;
import uk.gov.hmcts.reform.fact.data.api.entities.types.AddressType;
import uk.gov.hmcts.reform.fact.data.api.entities.types.SubjectType;
import uk.gov.hmcts.reform.fact.data.api.entities.types.DayOfTheWeek;
import uk.gov.hmcts.reform.fact.data.api.entities.types.HearingEnhancementEquipment;
import uk.gov.hmcts.reform.fact.data.api.entities.types.OpeningTimesDetail;
Expand Down Expand Up @@ -524,32 +525,32 @@ void courtLocalAuthoritiesAuth() throws Exception {
}

@Test
@DisplayName("Court lock endpoints enforce admin-only writes")
@DisplayName("Lock endpoints enforce admin-only writes")
void courtLockAuth() throws Exception {
UUID courtId = createCourtAsAdmin("Test Court Auth Lock");
UUID courtId = createCourtAsAdmin("Test Auth Lock");
UUID userId = createUserAsAdmin("test.user.auth.lock");
String lockPath = "/courts/" + courtId + "/v1/locks/" + Page.COURT;
String lockPath = "/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.GENERAL;

Response postAdmin = http.doPost(lockPath, mapper.writeValueAsString(userId), adminToken);
assertThat(postAdmin.statusCode()).isIn(200, 201);

assertViewerAllowed(
http.doGet("/courts/" + courtId + "/v1/locks", adminToken),
http.doGet("/courts/" + courtId + "/v1/locks", viewerToken),
"/courts/{courtId}/v1/locks [GET]"
http.doGet("/locks/" + SubjectType.COURT + "/" + courtId + "/v1/", adminToken),
http.doGet("/locks/" + SubjectType.COURT + "/" + courtId + "/v1/", viewerToken),
"/locks/{subjectType}/{subjectId}/v1 [GET]"
);
assertViewerAllowed(
http.doGet(lockPath, adminToken),
http.doGet(lockPath, viewerToken),
"/courts/{courtId}/v1/locks/{page} [GET]"
"/locks/{subjectType}/{subjectId}/v1/{page} [GET]"
);
assertViewerForbidden(
http.doPost(lockPath, mapper.writeValueAsString(userId), viewerToken),
"/courts/{courtId}/v1/locks/{page} [POST]"
"/locks/{subjectType}/{subjectId}/v1/{page} [POST]"
);
assertViewerForbidden(
http.doDelete(lockPath, viewerToken),
"/courts/{courtId}/v1/locks/{page} [DELETE]"
"/locks/{subjectType}/{subjectId}/v1/{page} [DELETE]"
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import uk.gov.hmcts.reform.fact.data.api.entities.CourtLock;
import uk.gov.hmcts.reform.fact.data.api.entities.Lock;
import uk.gov.hmcts.reform.fact.data.api.entities.types.SubjectType;
import uk.gov.hmcts.reform.fact.data.api.entities.types.Page;
import uk.gov.hmcts.reform.fact.functional.helpers.TestDataHelper;
import uk.gov.hmcts.reform.fact.functional.http.HttpClient;
Expand All @@ -24,122 +25,119 @@

@Feature("Court Lock Controller")
@DisplayName("Court Lock Controller")
public final class CourtLockControllerFunctionalTest {
public final class LockControllerFunctionalTest {

private static final HttpClient http = new HttpClient();
private static final ObjectMapper mapper = JsonMapper.builder()
.disable(DateTimeFeature.WRITE_DATES_AS_TIMESTAMPS)
.build();

@Test
@DisplayName("POST /courts/{courtId}/v1/locks/{page} creates lock successfully")
@DisplayName("POST /locks/{subjectType}/{subjectId}/v1/{page} creates lock successfully")
void shouldCreateCourtLockSuccessfully() throws Exception {
final UUID userId = TestDataHelper.createUser(http, "test.user.create");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock Create");
final UUID courtId = TestDataHelper.createCourt(http, "Test Lock Create");

final CourtLock createdLock = TestDataHelper.createCourtLock(http, courtId, Page.COURT, userId);
final Lock createdLock = TestDataHelper.createCourtLock(http, courtId, Page.GENERAL, userId);

assertThat(createdLock.getId())
.as("Created lock should have auto-generated ID")
.isNotNull();
assertThat(createdLock.getCourtId())
assertThat(createdLock.getSubjectId())
.as("Created lock court ID should match request court ID %s", courtId)
.isEqualTo(courtId);
assertThat(createdLock.getUserId())
.as("Created lock user ID should match request user ID %s", userId)
.isEqualTo(userId);
assertThat(createdLock.getPage())
.as("Created lock page should match request page %s", Page.COURT)
.isEqualTo(Page.COURT);
.as("Created lock page should match request page %s", Page.GENERAL)
.isEqualTo(Page.GENERAL);
assertThat(createdLock.getLockAcquired())
.as("Created lock should have lockAcquired timestamp set")
.isNotNull();
}

@Test
@DisplayName("GET /courts/{courtId}/v1/locks/{page} returns lock status successfully")
@DisplayName("GET /locks/{subjectType}/{subjectId}/v1/{page} returns lock status successfully")
void shouldGetCourtLockStatusSuccessfully() throws Exception {
final UUID userId = TestDataHelper.createUser(http, "test.user.getstatus");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock Get Status");
final UUID courtId = TestDataHelper.createCourt(http, "Test Lock Get Status");

TestDataHelper.createCourtLock(http, courtId, Page.COURT_ACCESSIBILITY, userId);
TestDataHelper.createCourtLock(http, courtId, Page.ACCESSIBILITY, userId);

final Response getLockStatusResponse = http.doGet(
"/courts/" + courtId + "/v1/locks/" + Page.COURT_ACCESSIBILITY
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.ACCESSIBILITY
);

assertThat(getLockStatusResponse.statusCode())
.as("Expected 200 OK when getting lock status for court %s page %s",
courtId, Page.COURT_ACCESSIBILITY)
courtId, Page.ACCESSIBILITY
)
.isEqualTo(OK.value());

assertThat(getLockStatusResponse.contentType())
.as("Expected JSON content type when getting lock status")
.contains("json");

final CourtLock fetchedLock = mapper.readValue(
final Lock fetchedLock = mapper.readValue(
getLockStatusResponse.getBody().asString(),
CourtLock.class
Lock.class
);

assertThat(fetchedLock.getCourtId())
assertThat(fetchedLock.getSubjectId())
.as("Fetched lock court ID should match %s", courtId)
.isEqualTo(courtId);
assertThat(fetchedLock.getUserId())
.as("Fetched lock user ID should match %s", userId)
.isEqualTo(userId);
assertThat(fetchedLock.getPage())
.as("Fetched lock page should match %s", Page.COURT_ACCESSIBILITY)
.isEqualTo(Page.COURT_ACCESSIBILITY);
.as("Fetched lock page should match %s", Page.ACCESSIBILITY)
.isEqualTo(Page.ACCESSIBILITY);
assertThat(fetchedLock.getLockAcquired())
.as("Fetched lock should have lockAcquired timestamp")
.isNotNull();
}

@Test
@DisplayName("DELETE /courts/{courtId}/v1/locks/{page} deletes lock successfully")
@DisplayName("DELETE /locks/{subjectType}/{subjectId}/v1/{page} deletes lock successfully")
void shouldDeleteCourtLockSuccessfully() throws Exception {
final UUID userId = TestDataHelper.createUser(http, "test.user.delete");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock Delete");
final UUID courtId = TestDataHelper.createCourt(http, "Test Lock Delete");

TestDataHelper.createCourtLock(http, courtId, Page.COURT_LOCAL_AUTHORITY, userId);
TestDataHelper.createCourtLock(http, courtId, Page.LOCAL_AUTHORITIES, userId);

final Response deleteLockResponse = http.doDelete(
"/courts/" + courtId + "/v1/locks/" + Page.COURT_LOCAL_AUTHORITY
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.LOCAL_AUTHORITIES
);

assertThat(deleteLockResponse.statusCode())
.as("Expected 204 NO CONTENT when deleting lock for court %s page %s",
courtId, Page.COURT_LOCAL_AUTHORITY)
courtId, Page.LOCAL_AUTHORITIES)
.isEqualTo(NO_CONTENT.value());

final Response getLockStatusResponse = http.doGet(
"/courts/" + courtId + "/v1/locks/" + Page.COURT_LOCAL_AUTHORITY
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.LOCAL_AUTHORITIES
);

assertThat(getLockStatusResponse.statusCode())
.as("Expected 200 OK when checking lock status after deletion")
.isEqualTo(OK.value());

assertThat(getLockStatusResponse.contentType())
.as("Expected JSON content type when checking lock status")
.contains("json");
.as("Expected 204 OK when checking lock status after deletion")
.isEqualTo(NO_CONTENT.value());

assertThat(getLockStatusResponse.getBody().asString())
.as("Expected null response body after lock deletion indicating no lock exists")
.isEqualTo("null");
.isEqualTo("");
}

@Test
@DisplayName("GET /courts/{courtId}/v1/locks returns all locks for a court")
@DisplayName("GET /locks/{subjectType}/{subjectId}/v1 returns all locks for a court")
void shouldGetAllLocksForCourtSuccessfully() throws Exception {
final UUID userId = TestDataHelper.createUser(http, "test.user.getall");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock Get All");
final UUID courtId = TestDataHelper.createCourt(http, "Test Lock Get All");

TestDataHelper.createCourtLock(http, courtId, Page.COURT, userId);
TestDataHelper.createCourtLock(http, courtId, Page.COURT_ACCESSIBILITY, userId);
TestDataHelper.createCourtLock(http, courtId, Page.GENERAL, userId);
TestDataHelper.createCourtLock(http, courtId, Page.ACCESSIBILITY, userId);

final Response getAllLocksResponse = http.doGet("/courts/" + courtId + "/v1/locks");
final Response getAllLocksResponse = http.doGet("/locks/" + SubjectType.COURT + "/" + courtId + "/v1");

assertThat(getAllLocksResponse.statusCode())
.as("Expected 200 OK when getting all locks for court %s", courtId)
Expand All @@ -149,35 +147,35 @@ void shouldGetAllLocksForCourtSuccessfully() throws Exception {
.as("Expected JSON content type when getting all locks")
.contains("json");

final List<CourtLock> locks = getAllLocksResponse.jsonPath().getList("", CourtLock.class);
final List<Lock> locks = getAllLocksResponse.jsonPath().getList("", Lock.class);

assertThat(locks)
.as("Expected 2 locks for court %s", courtId)
.hasSize(2);
.as("Expected 1 locks for court %s", courtId)
.hasSize(1);

assertThat(locks)
.as("All locks should belong to court %s", courtId)
.allSatisfy(lock -> assertThat(lock.getCourtId())
.allSatisfy(lock -> assertThat(lock.getSubjectId())
.as("Lock court ID should be %s", courtId)
.isEqualTo(courtId));

assertThat(locks)
.as("Expected locks for pages COURT and COURT_ACCESSIBILITY")
.extracting(CourtLock::getPage)
.containsExactlyInAnyOrder(Page.COURT, Page.COURT_ACCESSIBILITY);
.extracting(Lock::getPage)
.containsExactlyInAnyOrder(Page.ACCESSIBILITY);
}

@Test
@DisplayName("POST /courts/{courtId}/v1/locks/{page} returns 409 when page already locked by another user")
@DisplayName("POST /locks/{subjectType/{subjectId}/v1/{page} returns 409 when page already locked by another user")
void shouldReturnConflictWhenPageLockedByAnotherUser() throws Exception {
final UUID userAId = TestDataHelper.createUser(http, "test.user.conflict.usera");
final UUID userBId = TestDataHelper.createUser(http, "test.user.conflict.userb");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock Conflict");

TestDataHelper.createCourtLock(http, courtId, Page.COURT, userAId);
TestDataHelper.createCourtLock(http, courtId, Page.GENERAL, userAId);

final Response userBLockResponse = http.doPost(
"/courts/" + courtId + "/v1/locks/" + Page.COURT,
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.GENERAL,
mapper.writeValueAsString(userBId)
);

Expand All @@ -199,18 +197,18 @@ void shouldReturnConflictWhenPageLockedByAnotherUser() throws Exception {
}

@Test
@DisplayName("POST /courts/{courtId}/v1/locks/{page} allows same user to refresh lock")
@DisplayName("POST /locks/{subjectType}/{subjectId}/v1/{page} allows same user to refresh lock")
void shouldAllowSameUserToRefreshLock() throws Exception {
final UUID userId = TestDataHelper.createUser(http, "test.user.refresh");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock Refresh");
final UUID courtId = TestDataHelper.createCourt(http, "Test Lock Refresh");

final CourtLock initialLock = TestDataHelper.createCourtLock(http, courtId, Page.COURT, userId);
final Lock initialLock = TestDataHelper.createCourtLock(http, courtId, Page.GENERAL, userId);

final ZonedDateTime initialTimestamp = initialLock.getLockAcquired();

Thread.sleep(1000);

final CourtLock refreshedLock = TestDataHelper.createCourtLock(http, courtId, Page.COURT, userId);
final Lock refreshedLock = TestDataHelper.createCourtLock(http, courtId, Page.GENERAL, userId);

assertThat(refreshedLock.getId())
.as("Lock ID should remain the same after refresh")
Expand All @@ -220,7 +218,7 @@ void shouldAllowSameUserToRefreshLock() throws Exception {
.as("Lock acquired timestamp should be updated after refresh")
.isAfter(initialTimestamp);

assertThat(refreshedLock.getCourtId())
assertThat(refreshedLock.getSubjectId())
.as("Lock court ID should remain %s", courtId)
.isEqualTo(courtId);

Expand All @@ -229,34 +227,34 @@ void shouldAllowSameUserToRefreshLock() throws Exception {
.isEqualTo(userId);

assertThat(refreshedLock.getPage())
.as("Lock page should remain %s", Page.COURT)
.isEqualTo(Page.COURT);
.as("Lock page should remain %s", Page.GENERAL)
.isEqualTo(Page.GENERAL);
}

@Test
@DisplayName("End-to-end: Create lock, get status, refresh lock, delete, verify deletion")
void shouldCompleteEndToEndLockWorkflow() throws Exception {
final UUID userId = TestDataHelper.createUser(http, "test.user.endtoend");
final UUID courtId = TestDataHelper.createCourt(http, "Test Court Lock End To End");
final UUID courtId = TestDataHelper.createCourt(http, "Test Lock End To End");

final CourtLock createdLock = TestDataHelper
.createCourtLock(http, courtId, Page.COURT_LOCAL_AUTHORITY, userId);
final Lock createdLock = TestDataHelper
.createCourtLock(http, courtId, Page.LOCAL_AUTHORITIES, userId);

assertThat(createdLock.getCourtId())
assertThat(createdLock.getSubjectId())
.as("Created lock court ID should be %s", courtId)
.isEqualTo(courtId);

final Response getLockStatusResponse = http.doGet(
"/courts/" + courtId + "/v1/locks/" + Page.COURT_LOCAL_AUTHORITY
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.LOCAL_AUTHORITIES
);

assertThat(getLockStatusResponse.statusCode())
.as("Expected 200 OK when getting lock status")
.isEqualTo(OK.value());

final CourtLock fetchedLock = mapper.readValue(
final Lock fetchedLock = mapper.readValue(
getLockStatusResponse.getBody().asString(),
CourtLock.class
Lock.class
);

assertThat(fetchedLock.getId())
Expand All @@ -265,32 +263,32 @@ void shouldCompleteEndToEndLockWorkflow() throws Exception {

Thread.sleep(1000);

final CourtLock refreshedLock = TestDataHelper
.createCourtLock(http, courtId, Page.COURT_LOCAL_AUTHORITY, userId);
final Lock refreshedLock = TestDataHelper
.createCourtLock(http, courtId, Page.LOCAL_AUTHORITIES, userId);

assertThat(refreshedLock.getLockAcquired())
.as("Refreshed lock timestamp should be after original timestamp")
.isAfter(createdLock.getLockAcquired());

final Response deleteLockResponse = http.doDelete(
"/courts/" + courtId + "/v1/locks/" + Page.COURT_LOCAL_AUTHORITY
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.LOCAL_AUTHORITIES
);

assertThat(deleteLockResponse.statusCode())
.as("Expected 204 NO CONTENT when deleting lock")
.isEqualTo(NO_CONTENT.value());

final Response verifyDeletedResponse = http.doGet(
"/courts/" + courtId + "/v1/locks/" + Page.COURT_LOCAL_AUTHORITY
"/locks/" + SubjectType.COURT + "/" + courtId + "/v1/" + Page.LOCAL_AUTHORITIES
);

assertThat(verifyDeletedResponse.statusCode())
.as("Expected 200 OK when checking deleted lock status")
.isEqualTo(OK.value());
.as("Expected 204 OK when checking deleted lock status")
.isEqualTo(NO_CONTENT.value());

assertThat(verifyDeletedResponse.getBody().asString())
.as("Expected null response after lock deletion")
.isEqualTo("null");
.isEqualTo("");
}

@AfterAll
Expand Down
Loading