Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -253,8 +253,9 @@ public Response saveFormSchema(
storageService.writeJsonToStorage(filePath, request.schema());
return Response.ok().build();
} catch (Exception e) {
Log.info(("Failed to save form for screener " + screenerId));
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
Log.error("Failed to save form for screener " + screenerId, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Could not save the form. Please try again.")).build();
}
}

Expand Down Expand Up @@ -283,9 +284,12 @@ public Response publishScreener(
Screener screener = screenerOpt.get();
screenerRepository.publishScreener(screener);
return Response.ok().build();
} catch (BadRequestException e) {
return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", e.getMessage())).build();
} catch (Exception e) {
Log.error("Error: Error updating screener to published. Screener: " + screenerId);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
Log.error("Error updating screener to published. Screener: " + screenerId, e);
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Could not publish the screener. Please try again.")).build();
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,19 @@ public static String persistDocumentWithId(String collectionPath, String documen
}


/* Writes a document under a known ID, replacing any document already there. */
public static void replaceDocument(String collectionPath, String documentId, Map<String, Object> data) throws Exception {
try {
db.collection(collectionPath).document(documentId).set(data).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new Exception("Thread interrupted while saving to Firestore", e);
} catch (ExecutionException e) {
throw new Exception("Failed to write document to Firestore", e);
}
}


/* Reads a document inside a transaction, so the transaction fails if it changes before commit. */
public interface TransactionReader {
Optional<Map<String, Object>> get(String collection, String id) throws Exception;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ public void writeStringToStorage(String filePath, String content, String content
}

@Override
public void writeBytesToStorage(String filePath, byte[] content, String contentType){
public void writeBytesToStorage(String filePath, byte[] content, String contentType) throws Exception {
try {
BlobId blobId = BlobId.of(bucketName, filePath);
BlobInfo blobInfo = BlobInfo.newBuilder(blobId)
Expand All @@ -57,11 +57,12 @@ public void writeBytesToStorage(String filePath, byte[] content, String contentT
Log.info("Uploaded to GCS: " + filePath);
} catch (Exception e){
Log.error("Error writing bytes to GCS: " + e.getMessage());
throw new Exception("Could not write file to cloud storage", e);
}
}

@Override
public void writeJsonToStorage(String filePath, JsonNode json){
public void writeJsonToStorage(String filePath, JsonNode json) throws Exception {
try {
ObjectMapper mapper = new ObjectMapper();
byte[] content = mapper.writeValueAsBytes(json);
Expand All @@ -73,6 +74,7 @@ public void writeJsonToStorage(String filePath, JsonNode json){
Log.info("Uploaded to GCS: " + filePath);
} catch (Exception e){
Log.error("Error writing JSON to GCS: " + e.getMessage());
throw new Exception("Could not save JSON to cloud storage", e);
}
}

Expand Down Expand Up @@ -189,7 +191,7 @@ public void updatePublishedFormSchemaArtifact(String screenerId, String publishe
Log.info("Working form schema copied to published artifact path for screener: " + screenerId);
} catch (Exception e) {
Log.error("Error updating published form schema in cloud storage:", e);
throw new Exception("Error updating published form schema in cloud storage");
throw new Exception("Error updating published form schema in cloud storage", e);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ public interface StorageService {

void writeStringToStorage(String filePath, String content, String contentType) throws Exception;

void writeBytesToStorage(String filePath, byte[] content, String contentType);
void writeBytesToStorage(String filePath, byte[] content, String contentType) throws Exception;

void writeJsonToStorage(String filePath, JsonNode json);
void writeJsonToStorage(String filePath, JsonNode json) throws Exception;

Optional<InputStream> getFileInputStreamFromStorage(String filePath);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,12 @@ public void refreshPublishedScreenerbenefits(String publishedScreenerId, List<Be
for (Benefit benefit : benefits) {
ObjectMapper mapper = new ObjectMapper().setSerializationInclusion(JsonInclude.Include.NON_NULL);
Map<String, Object> data = mapper.convertValue(benefit, Map.class);
FirestoreUtils.persistDocument(
// Schema components refer to the working benefit IDs. Keep those IDs
// in the published subcollection so evaluation results match the snapshot.
// Overwrite: a document the delete above missed must not fail the publish.
FirestoreUtils.replaceDocument(
calculateCustomBenefitCollection(publishedScreenerId),
benefit.getId(),
data
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,23 +188,31 @@ public void deleteCustomBenefit(String screenerId, String benefitId) throws Exce
/* Publishing logic */
@Override
public void publishScreener(Screener screener) throws Exception {
if (screener.getPublishedScreenerId() == null || screener.getPublishedScreenerId().isEmpty()) {
// A working screener can exist before its form has ever been saved. Check
// storage before creating a public record or changing publication metadata.
String workingFormPath = storageService.getScreenerWorkingFormSchemaPath(screener.getId());
if (storageService.readFormSchema(workingFormPath).isEmpty()) {
throw new jakarta.ws.rs.BadRequestException("Save the form in Form Editor before publishing.");
}
List<Benefit> benefits = this.getBenefitsInScreener(screener);
boolean firstPublish = screener.getPublishedScreenerId() == null || screener.getPublishedScreenerId().isEmpty();
if (firstPublish) {
String publishedScreenerId = publishedScreenerRepository.createPublishedScreener(screener);
screener.setPublishedScreenerId(publishedScreenerId);
} else {
publishedScreenerRepository.updatePublishedScreener(screener.getPublishedScreenerId(), screener);
}

screener.setLastPublishDate(Instant.now().toString());
this.updateWorkingScreener(screener);

storageService.updatePublishedFormSchemaArtifact(
screener.getId(), screener.getPublishedScreenerId()
);
publishedScreenerRepository.refreshPublishedScreenerbenefits(
screener.getPublishedScreenerId(),
this.getBenefitsInScreener(screener)
benefits
);

storageService.updatePublishedFormSchemaArtifact(
screener.getId(), screener.getPublishedScreenerId()
);
screener.setLastPublishDate(Instant.now().toString());
if (!firstPublish) {
publishedScreenerRepository.updatePublishedScreener(screener.getPublishedScreenerId(), screener);
}
this.updateWorkingScreener(screener);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,14 @@ public List<String> importForUser(String userId) throws Exception {
for (SeedScreenerData seedScreener : seedData.screeners()) {
List<Benefit> importedBenefits = new ArrayList<>();
List<BenefitDetail> importedBenefitDetails = new ArrayList<>();
Map<String, String> benefitIds = new LinkedHashMap<>();
for (Benefit seedBenefit : seedScreener.benefits()) {
Benefit importedBenefit = cloneBenefit(
seedBenefit,
userId,
importedCustomCheckIds);
importedBenefits.add(importedBenefit);
benefitIds.put(seedBenefit.getId(), importedBenefit.getId());
importedBenefitDetails.add(
new BenefitDetail(importedBenefit.getId(),
importedBenefit.getName(),
Expand All @@ -100,7 +102,7 @@ public List<String> importForUser(String userId) throws Exception {
.getScreenerWorkingFormSchemaPath(newScreenerId);
storageService.writeJsonToStorage(
formPath,
seedScreener.formSchema());
NextStepsFormSchema.remapBenefits(seedScreener.formSchema(), benefitIds));
}

importedScreenerIds.add(newScreenerId);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package org.codeforphilly.bdt.builder.service;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import java.util.Map;

/** Keeps component copies attached to their benefits when an import allocates new IDs. */
final class NextStepsFormSchema {
private NextStepsFormSchema() {}

static JsonNode remapBenefits(JsonNode schema, Map<String, String> benefitIds) {
if (schema == null) return null;
JsonNode copy = schema.deepCopy();
remapComponent(copy, benefitIds);
return copy;
}

private static void remapComponent(JsonNode component, Map<String, String> benefitIds) {
if (!component.isObject()) return;
if ("next_steps".equals(component.path("type").asText())
&& component.path("benefitContent").isObject()) {
ObjectNode content = JsonNodeFactory.instance.objectNode();
component.path("benefitContent").fields().forEachRemaining(entry -> {
String importedId = benefitIds.get(entry.getKey());
// Copies for removed benefits have no destination and can be discarded.
if (importedId != null) content.set(importedId, entry.getValue());
});
((ObjectNode) component).set("benefitContent", content);
}
JsonNode children = component.path("components");
if (children.isArray()) children.forEach(child -> remapComponent(child, benefitIds));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -152,9 +152,11 @@ public Screener importScreener(String owner, ScreenerTransfer transfer) throws E
}
List<Benefit> benefits = new ArrayList<>();
List<BenefitDetail> details = new ArrayList<>();
Map<String, String> benefitIds = new LinkedHashMap<>();
for (Benefit original : transfer.benefits()) {
Benefit benefit = copy(original, Benefit.class);
benefit.setId(UUID.randomUUID().toString());
benefitIds.put(original.getId(), benefit.getId());
benefit.setOwnerId(owner);
for (CheckConfig config : configs(benefit)) {
String source = sourceId(config);
Expand Down Expand Up @@ -184,7 +186,8 @@ public Screener importScreener(String owner, ScreenerTransfer transfer) throws E
if (transfer.formSchema() != null && !transfer.formSchema().isNull()) {
String path = storage.getScreenerWorkingFormSchemaPath(id);
cleanup.push(() -> storage.deleteFile(path));
storage.writeStringToStorage(path, mapper.writeValueAsString(transfer.formSchema()), "application/json");
storage.writeStringToStorage(path, mapper.writeValueAsString(
NextStepsFormSchema.remapBenefits(transfer.formSchema(), benefitIds)), "application/json");
}
screener.setBenefits(details);
cleanup.push(() -> screeners.deleteWorkingScreener(id));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.NotFoundException;
import org.codeforphilly.bdt.builder.model.dto.screener.EditScreenerRequest;
import org.codeforphilly.bdt.builder.model.dto.screener.PublishScreenerRequest;
import org.codeforphilly.bdt.builder.model.dto.screener.SaveSchemaRequest;
import org.codeforphilly.bdt.builder.persistence.DuplicateScreenerNameException;
import org.codeforphilly.bdt.builder.persistence.ScreenerRepository;
import org.eclipse.microprofile.jwt.JsonWebToken;
Expand Down Expand Up @@ -50,6 +52,32 @@ void setUp() {
when(screenerRepository.getWorkingScreener("screener-1")).thenReturn(Optional.of(screener));
}

@Test
void publishReturnsAnActionableErrorForAnUnsavedForm() throws Exception {
when(screenerRepository.getWorkingScreenerMetaDataOnly("screener-1")).thenReturn(Optional.of(screener));
doThrow(new jakarta.ws.rs.BadRequestException("Save the form in Form Editor before publishing."))
.when(screenerRepository).publishScreener(screener);
var request = new PublishScreenerRequest();
request.screenerId = "screener-1";
try (var response = resource.publishScreener(identity, request)) {
assertEquals(400, response.getStatus());
assertEquals(Map.of("error", "Save the form in Form Editor before publishing."), response.getEntity());
}
}

@Test
void failedFormSaveDoesNotReturnSuccess() throws Exception {
resource.validator = mock(jakarta.validation.Validator.class);
var request = new SaveSchemaRequest(new ObjectMapper().readTree("{\"components\":[]}"));
when(resource.validator.validate(request)).thenReturn(java.util.Set.of());
when(storage.getScreenerWorkingFormSchemaPath("screener-1")).thenReturn("form/working/screener-1.json");
doThrow(new Exception("Storage write failed")).when(storage).writeJsonToStorage("form/working/screener-1.json", request.schema());
try (var response = resource.saveFormSchema(identity, "screener-1", request)) {
assertEquals(500, response.getStatus());
assertTrue(((Map<?, ?>) response.getEntity()).get("error").toString().contains("Could not save the form"));
}
}

@Test
void changedNameIsCheckedForUniqueness() throws Exception {
doThrow(new DuplicateScreenerNameException()).when(screenerRepository).renameWorkingScreener(any());
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package org.codeforphilly.bdt.builder.persistence;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.cloud.storage.Blob;
import com.google.cloud.storage.BlobId;
import com.google.cloud.storage.BlobInfo;
import com.google.cloud.storage.Storage;
import com.google.cloud.storage.StorageException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.InputStream;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class GoogleStorageServiceTest {
private final Storage storage = mock(Storage.class);
private final GoogleStorageService service = new GoogleStorageService();

@BeforeEach
void setup() {
service.storage = storage;
service.bucketName = "test-bucket";
}

@Test
void formSavePropagatesCloudStorageFailures() throws Exception {
var failure = new StorageException(503, "Storage unavailable");
when(storage.createFrom(any(BlobInfo.class), any(InputStream.class))).thenThrow(failure);
var schema = new ObjectMapper().readTree("{\"components\":[]}");
var error = assertThrows(Exception.class, () -> service.writeJsonToStorage("form/working/test.json", schema));
assertSame(failure, error.getCause());
}

@Test
void publishedFormCopyPropagatesDestinationWriteFailures() throws Exception {
var source = mock(Blob.class);
when(storage.get(BlobId.of("test-bucket", "form/working/working.json"))).thenReturn(source);
when(source.exists()).thenReturn(true);
when(source.getContent()).thenReturn("{\"components\":[]}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
var failure = new StorageException(503, "Write unavailable");
when(storage.createFrom(any(BlobInfo.class), any(InputStream.class))).thenThrow(failure);
var error = assertThrows(Exception.class, () -> service.updatePublishedFormSchemaArtifact("working", "public"));
assertSame(failure, error.getCause().getCause());
}
}
Loading
Loading