diff --git a/claimManagement/src/main/java/org/openimis/imisclaims/MainActivity.java b/claimManagement/src/main/java/org/openimis/imisclaims/MainActivity.java index 0ee391e4..77822f44 100644 --- a/claimManagement/src/main/java/org/openimis/imisclaims/MainActivity.java +++ b/claimManagement/src/main/java/org/openimis/imisclaims/MainActivity.java @@ -619,7 +619,8 @@ public void DownLoadDiagnosesServicesItems(@Nullable final String officerCode) { Thread thread = new Thread() { public void run() { try { - DiagnosesServicesMedications diagnosesServicesMedications = new FetchDiagnosesServicesItems().execute(); + FetchDiagnosesServicesItems fetchDiagnosesServicesItems = new FetchDiagnosesServicesItems(); + DiagnosesServicesMedications diagnosesServicesMedications = fetchDiagnosesServicesItems.execute(); saveLastUpdateDate(diagnosesServicesMedications.getLastUpdated()); sqlHandler.ClearAll("tblReferences"); sqlHandler.ClearAll("tblHealthFacilities"); @@ -688,9 +689,7 @@ public void run() { runOnUiThread(() -> { progressDialog.dismiss(); - if (officerCode != null) { - DownLoadServicesItemsPriceList(officerCode); - } + handlePostMasterDataSync(officerCode, fetchDiagnosesServicesItems.getSkippedMedicationPages()); }); } catch (Exception e) { e.printStackTrace(); @@ -713,7 +712,27 @@ public void run() { } } - private void DownLoadServicesItemsPriceList(@NonNull final String claimAdministratorCode) { + void handlePostMasterDataSync( + @Nullable String officerCode, + @NonNull List skippedMedicationPages + ) { + if (!skippedMedicationPages.isEmpty()) { + final String errorLogMessage = "Master data synced with partial medications.\nSkipped pages: " + + skippedMedicationPages; + showDialog( + errorLogMessage, + (dialog, which) -> { + if (officerCode != null) { + DownLoadServicesItemsPriceList(officerCode); + } + } + ); + } else if (officerCode != null) { + DownLoadServicesItemsPriceList(officerCode); + } + } + + protected void DownLoadServicesItemsPriceList(@NonNull final String claimAdministratorCode) { if (global.isNetworkAvailable()) { String progress_message = getResources().getString(R.string.Services) + ", " + getResources().getString(R.string.Items) + "..."; progressDialog = ProgressDialog.show(this, getResources().getString(R.string.mapping), progress_message); diff --git a/claimManagement/src/main/java/org/openimis/imisclaims/network/util/PaginatedResponseUtils.java b/claimManagement/src/main/java/org/openimis/imisclaims/network/util/PaginatedResponseUtils.java index 828d1992..f4939884 100644 --- a/claimManagement/src/main/java/org/openimis/imisclaims/network/util/PaginatedResponseUtils.java +++ b/claimManagement/src/main/java/org/openimis/imisclaims/network/util/PaginatedResponseUtils.java @@ -4,14 +4,18 @@ import androidx.annotation.Nullable; import androidx.annotation.WorkerThread; +import org.openimis.imisclaims.network.exception.HttpException; import org.openimis.imisclaims.network.request.BaseFHIRGetPaginatedRequest; import org.openimis.imisclaims.network.response.PaginatedResponse; +import org.openimis.imisclaims.tools.Log; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.List; public class PaginatedResponseUtils { + private static final String LOG_TAG = "FHIR_SYNC_TOLERANCE"; private PaginatedResponseUtils() { throw new IllegalAccessError("This constructor is private"); @@ -50,10 +54,154 @@ public static List downloadAll( return list; } + @NonNull + @WorkerThread + public static DownloadResult downloadAllSkipFailedPages( + @NonNull String endpoint, + @NonNull RequestExecutor executor, + @Nullable Mapper.Transformer transformer, + int maxPages, + int maxConsecutiveFailures + ) throws Exception { + int page = 0; + int consecutiveFailures = 0; + boolean hasMore = true; + boolean hasSkippedPages = false; + List list = new ArrayList<>(); + List skippedPages = new ArrayList<>(); + List recoveredPages = new ArrayList<>(); + Mapper mapper = transformer != null ? new Mapper<>(transformer) : null; + + while (hasMore && page < maxPages) { + final int displayPage = page + 1; + try { + PaginatedResponse response = executor.download(page); + if (mapper != null) { + list.addAll(mapper.map(response.getValue())); + } else { + list.addAll((Collection) response.getValue()); + } + hasMore = response.hasMore(); + consecutiveFailures = 0; + if (hasSkippedPages) { + recoveredPages.add(displayPage); + } + } catch (Exception exception) { + skippedPages.add(displayPage); + hasSkippedPages = true; + consecutiveFailures++; + logSkippedPage(endpoint, displayPage, exception); + if (consecutiveFailures >= maxConsecutiveFailures) { + Log.w( + LOG_TAG, + String.format( + "endpoint=%s action=stop reason=maxConsecutiveFailures reached skippedPages=%s", + endpoint, + skippedPages + ) + ); + break; + } + } + page++; + } + + if (page >= maxPages && hasMore) { + Log.w( + LOG_TAG, + String.format( + "endpoint=%s action=stop reason=maxPages reached maxPages=%s skippedPages=%s", + endpoint, + maxPages, + skippedPages + ) + ); + } + + Log.i( + LOG_TAG, + String.format( + "endpoint=%s summary skippedPages=%s recoveredPages=%s totalItems=%s", + endpoint, + skippedPages, + recoveredPages, + list.size() + ) + ); + return new DownloadResult<>(list, skippedPages, recoveredPages); + } + + private static void logSkippedPage(@NonNull String endpoint, int page, @NonNull Exception exception) { + String errorType = exception.getClass().getSimpleName(); + if (exception instanceof HttpException) { + HttpException httpException = (HttpException) exception; + Log.w( + LOG_TAG, + String.format( + "endpoint=%s page=%s action=skip errorType=%s code=%s message=%s", + endpoint, + page, + errorType, + httpException.getCode(), + exception.getMessage() + ) + ); + } else { + Log.w( + LOG_TAG, + String.format( + "endpoint=%s page=%s action=skip errorType=%s message=%s", + endpoint, + page, + errorType, + exception.getMessage() + ) + ); + } + } + public interface RequestExecutor { @NonNull @WorkerThread PaginatedResponse download(int page) throws Exception; } + + public static class DownloadResult { + @NonNull + private final List items; + @NonNull + private final List skippedPages; + @NonNull + private final List recoveredPages; + + public DownloadResult( + @NonNull List items, + @NonNull List skippedPages, + @NonNull List recoveredPages + ) { + this.items = items; + this.skippedPages = skippedPages; + this.recoveredPages = recoveredPages; + } + + @NonNull + public List getItems() { + return items; + } + + @NonNull + public List getSkippedPages() { + return Collections.unmodifiableList(skippedPages); + } + + @NonNull + public List getRecoveredPages() { + return Collections.unmodifiableList(recoveredPages); + } + + public boolean hasSkippedPages() { + return !skippedPages.isEmpty(); + } + } } diff --git a/claimManagement/src/main/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItems.java b/claimManagement/src/main/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItems.java index 941e6dc0..1cf53cb0 100644 --- a/claimManagement/src/main/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItems.java +++ b/claimManagement/src/main/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItems.java @@ -19,8 +19,11 @@ import org.openimis.imisclaims.util.DateUtils; import java.util.Date; +import java.util.List; public class FetchDiagnosesServicesItems { + private static final int MAX_PAGES = 1000; + private static final int MAX_CONSECUTIVE_FAILURES = 3; @NonNull private final GetActivityDefinitionsRequest getActivityDefinitionsRequest; @@ -28,6 +31,8 @@ public class FetchDiagnosesServicesItems { private final GetDiagnosesRequest getDiagnosesRequest; @NonNull private final GetMedicationsRequest getMedicationsRequest; + @NonNull + private List skippedMedicationPages = java.util.Collections.emptyList(); public FetchDiagnosesServicesItems() { this( @@ -50,6 +55,16 @@ public FetchDiagnosesServicesItems( @NonNull @WorkerThread public DiagnosesServicesMedications execute() throws Exception { + PaginatedResponseUtils.DownloadResult medicationDownloadResult = + PaginatedResponseUtils.downloadAllSkipFailedPages( + "Medication", + getMedicationsRequest::get, + this::toMedication, + MAX_PAGES, + MAX_CONSECUTIVE_FAILURES + ); + skippedMedicationPages = medicationDownloadResult.getSkippedPages(); + // previous code was passing sometimes a `last_updated_date` but it was either empty or // `new Date(0)`. I'm still returning the last updated date in case it's one day used // again.¯\_(ツ)_/¯ @@ -60,13 +75,15 @@ public DiagnosesServicesMedications execute() throws Exception { getActivityDefinitionsRequest::get, this::toService ), - /* medications = */ PaginatedResponseUtils.downloadAll( - getMedicationsRequest::get, - this::toMedication - ) + /* medications = */ medicationDownloadResult.getItems() ); } + @NonNull + public List getSkippedMedicationPages() { + return skippedMedicationPages; + } + @NonNull private Diagnosis toDiagnosis(@NonNull DiagnosisDto dto) { return new Diagnosis( diff --git a/claimManagement/src/test/java/org/openimis/imisclaims/MainActivityPostSyncTest.java b/claimManagement/src/test/java/org/openimis/imisclaims/MainActivityPostSyncTest.java new file mode 100644 index 00000000..6679a19a --- /dev/null +++ b/claimManagement/src/test/java/org/openimis/imisclaims/MainActivityPostSyncTest.java @@ -0,0 +1,84 @@ +package org.openimis.imisclaims; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.app.AlertDialog; +import android.content.DialogInterface; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; +public class MainActivityPostSyncTest { + + private TestMainActivity activity; + + @Before + public void setup() { + activity = new TestMainActivity(); + } + + @Test + public void downloadAllData_showsPartialMedicationDialog_whenSkippedMedicationPagesNotEmpty() { + activity.handlePostMasterDataSync("OFFICER", Arrays.asList(2, 4)); + + assertTrue(activity.dialogShown); + assertNotNull(activity.lastDialogMessage); + assertTrue(activity.lastDialogMessage.contains("partial medications")); + assertTrue(activity.lastDialogMessage.contains("[2, 4]")); + } + + @Test + public void downloadAllData_doesNotAutoCallDownloadPriceList_beforeDialogConfirmation_whenSkippedPagesExist() { + activity.handlePostMasterDataSync("OFFICER", Collections.singletonList(2)); + + assertTrue(activity.dialogShown); + assertFalse(activity.downloadCalled); + } + + @Test + public void downloadAllData_callsDownloadPriceListDirectly_whenNoSkippedPages_andOfficerCodePresent() { + activity.handlePostMasterDataSync("OFFICER", Collections.emptyList()); + + assertTrue(activity.downloadCalled); + assertEquals("OFFICER", activity.downloadCalledWith); + assertFalse(activity.dialogShown); + } + + @Test + public void downloadAllData_afterDialogConfirmation_callsDownloadPriceList_whenOfficerCodePresent() { + activity.handlePostMasterDataSync("OFFICER", Collections.singletonList(3)); + + assertFalse(activity.downloadCalled); + activity.lastOkCallback.onClick(null, 0); + + assertTrue(activity.downloadCalled); + assertEquals("OFFICER", activity.downloadCalledWith); + } + + public static class TestMainActivity extends MainActivity { + boolean dialogShown = false; + boolean downloadCalled = false; + String downloadCalledWith; + String lastDialogMessage; + DialogInterface.OnClickListener lastOkCallback; + + @Override + protected AlertDialog showDialog(String msg, DialogInterface.OnClickListener okCallback) { + this.dialogShown = true; + this.lastDialogMessage = msg; + this.lastOkCallback = okCallback; + return null; + } + + @Override + protected void DownLoadServicesItemsPriceList(String claimAdministratorCode) { + this.downloadCalled = true; + this.downloadCalledWith = claimAdministratorCode; + } + } +} diff --git a/claimManagement/src/test/java/org/openimis/imisclaims/network/util/PaginatedResponseUtilsTest.java b/claimManagement/src/test/java/org/openimis/imisclaims/network/util/PaginatedResponseUtilsTest.java new file mode 100644 index 00000000..719afd2d --- /dev/null +++ b/claimManagement/src/test/java/org/openimis/imisclaims/network/util/PaginatedResponseUtilsTest.java @@ -0,0 +1,142 @@ +package org.openimis.imisclaims.network.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; +import org.openimis.imisclaims.network.exception.HttpException; +import org.openimis.imisclaims.network.response.PaginatedResponse; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class PaginatedResponseUtilsTest { + + @Test + public void downloadAllSkipFailedPages_returnsAllItems_whenNoFailure() throws Exception { + PaginatedResponseUtils.DownloadResult result = PaginatedResponseUtils.downloadAllSkipFailedPages( + "Medication", + page -> { + if (page == 0) { + return new PaginatedResponse<>(Collections.singletonList("a"), true); + } + return new PaginatedResponse<>(Collections.singletonList("b"), false); + }, + null, + 100, + 3 + ); + + assertEquals(Arrays.asList("a", "b"), result.getItems()); + assertTrue(result.getSkippedPages().isEmpty()); + assertTrue(result.getRecoveredPages().isEmpty()); + assertFalse(result.hasSkippedPages()); + } + + @Test + public void downloadAllSkipFailedPages_skipsSingleFailedPage_andContinues() throws Exception { + PaginatedResponseUtils.DownloadResult result = PaginatedResponseUtils.downloadAllSkipFailedPages( + "Medication", + page -> { + if (page == 0) { + return new PaginatedResponse<>(Collections.singletonList("p1"), true); + } + if (page == 1) { + throw new HttpException(500, "server", "error", null); + } + if (page == 2) { + return new PaginatedResponse<>(Collections.singletonList("p3"), false); + } + return new PaginatedResponse<>(Collections.emptyList(), false); + }, + null, + 100, + 3 + ); + + assertEquals(Arrays.asList("p1", "p3"), result.getItems()); + assertEquals(Collections.singletonList(2), result.getSkippedPages()); + assertEquals(Collections.singletonList(3), result.getRecoveredPages()); + assertTrue(result.hasSkippedPages()); + } + + @Test + public void downloadAllSkipFailedPages_stopsAfterMaxConsecutiveFailures() throws Exception { + Map failures = new HashMap<>(); + failures.put(0, new RuntimeException("f1")); + failures.put(1, new RuntimeException("f2")); + failures.put(2, new RuntimeException("f3")); + + PaginatedResponseUtils.DownloadResult result = PaginatedResponseUtils.downloadAllSkipFailedPages( + "Medication", + page -> { + Exception failure = failures.get(page); + if (failure != null) { + throw failure; + } + return new PaginatedResponse<>(Collections.singletonList("ok"), false); + }, + null, + 100, + 3 + ); + + assertTrue(result.getItems().isEmpty()); + assertEquals(Arrays.asList(1, 2, 3), result.getSkippedPages()); + assertTrue(result.getRecoveredPages().isEmpty()); + } + + @Test + public void downloadAllSkipFailedPages_stopsAtMaxPages_whenHasMoreStillTrue() throws Exception { + PaginatedResponseUtils.DownloadResult result = PaginatedResponseUtils.downloadAllSkipFailedPages( + "Medication", + page -> new PaginatedResponse<>(Collections.singletonList("i" + page), true), + null, + 2, + 3 + ); + + assertEquals(Arrays.asList("i0", "i1"), result.getItems()); + assertTrue(result.getSkippedPages().isEmpty()); + } + + @Test + public void downloadAllSkipFailedPages_recordsRecoveredPages_afterFailure() throws Exception { + PaginatedResponseUtils.DownloadResult result = PaginatedResponseUtils.downloadAllSkipFailedPages( + "Medication", + page -> { + if (page == 0) { + throw new RuntimeException("fail"); + } + if (page == 1) { + return new PaginatedResponse<>(Collections.singletonList("r1"), true); + } + return new PaginatedResponse<>(Collections.singletonList("r2"), false); + }, + null, + 100, + 3 + ); + + assertEquals(Collections.singletonList(1), result.getSkippedPages()); + assertEquals(Arrays.asList(2, 3), result.getRecoveredPages()); + assertEquals(Arrays.asList("r1", "r2"), result.getItems()); + } + + @Test + public void downloadResult_listsAreUnmodifiable_forSkippedAndRecovered() { + PaginatedResponseUtils.DownloadResult result = + new PaginatedResponseUtils.DownloadResult<>( + Collections.singletonList("x"), + Collections.singletonList(1), + Collections.singletonList(2) + ); + + assertThrows(UnsupportedOperationException.class, () -> result.getSkippedPages().add(3)); + assertThrows(UnsupportedOperationException.class, () -> result.getRecoveredPages().add(4)); + } +} diff --git a/claimManagement/src/test/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItemsTest.java b/claimManagement/src/test/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItemsTest.java new file mode 100644 index 00000000..2272028d --- /dev/null +++ b/claimManagement/src/test/java/org/openimis/imisclaims/usecase/FetchDiagnosesServicesItemsTest.java @@ -0,0 +1,145 @@ +package org.openimis.imisclaims.usecase; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.mockito.junit.MockitoJUnitRunner; +import org.openimis.imisclaims.domain.entity.DiagnosesServicesMedications; +import org.openimis.imisclaims.network.dto.ActivityDefinitionDto; +import org.openimis.imisclaims.network.dto.DiagnosisDto; +import org.openimis.imisclaims.network.dto.IdentifierDto; +import org.openimis.imisclaims.network.dto.MedicationDto; +import org.openimis.imisclaims.network.request.GetActivityDefinitionsRequest; +import org.openimis.imisclaims.network.request.GetDiagnosesRequest; +import org.openimis.imisclaims.network.request.GetMedicationsRequest; +import org.openimis.imisclaims.network.response.PaginatedResponse; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.List; + +@RunWith(MockitoJUnitRunner.class) +public class FetchDiagnosesServicesItemsTest { + + @Mock + private GetActivityDefinitionsRequest getActivityDefinitionsRequest; + @Mock + private GetDiagnosesRequest getDiagnosesRequest; + @Mock + private GetMedicationsRequest getMedicationsRequest; + + private FetchDiagnosesServicesItems useCase; + + @Before + public void setUp() { + MockitoAnnotations.openMocks(this); + useCase = new FetchDiagnosesServicesItems( + getActivityDefinitionsRequest, + getDiagnosesRequest, + getMedicationsRequest + ); + } + + @Test + public void execute_returnsDiagnosesServicesAndPartialMedications_whenMedicationPageFails() throws Exception { + when(getDiagnosesRequest.get()).thenReturn(Collections.singletonList(new DiagnosisDto("D1", "Diagnosis 1"))); + when(getActivityDefinitionsRequest.get(anyInt())).thenReturn(new PaginatedResponse<>( + Collections.singletonList(new ActivityDefinitionDto( + "s1", + Collections.singletonList(new IdentifierDto("Code", "SVC1")), + "Service1", + "Service 1", + 10.0, + "XAF", + "active", + new Date() + )), + false + )); + + when(getMedicationsRequest.get(0)).thenReturn(new PaginatedResponse<>(Collections.singletonList( + new MedicationDto( + "m1", + Collections.singletonList(new IdentifierDto("Code", "MED1")), + "Medication 1", + 20.0, + "XAF", + "active", + 1.0 + ) + ), true)); + when(getMedicationsRequest.get(1)).thenThrow(new RuntimeException("server error")); + when(getMedicationsRequest.get(2)).thenReturn(new PaginatedResponse<>(Collections.singletonList( + new MedicationDto( + "m2", + Collections.singletonList(new IdentifierDto("Code", "MED2")), + "Medication 2", + 30.0, + "XAF", + "active", + 1.0 + ) + ), false)); + + DiagnosesServicesMedications result = useCase.execute(); + + assertNotNull(result); + assertEquals(1, result.getDiagnoses().size()); + assertEquals(1, result.getServices().size()); + assertEquals(2, result.getMedications().size()); + assertEquals(Collections.singletonList(2), useCase.getSkippedMedicationPages()); + } + + @Test + public void execute_exposesSkippedMedicationPages_afterPartialMedicationSync() throws Exception { + when(getDiagnosesRequest.get()).thenReturn(Collections.singletonList(new DiagnosisDto("D1", "Diagnosis 1"))); + when(getActivityDefinitionsRequest.get(anyInt())).thenReturn(new PaginatedResponse<>(Collections.emptyList(), false)); + when(getMedicationsRequest.get(0)).thenThrow(new RuntimeException("page 1 failed")); + when(getMedicationsRequest.get(1)).thenReturn(new PaginatedResponse<>(Collections.emptyList(), false)); + + useCase.execute(); + + assertEquals(Collections.singletonList(1), useCase.getSkippedMedicationPages()); + } + + @Test + public void execute_throws_whenDiagnosesRequestFails() throws Exception { + when(getDiagnosesRequest.get()).thenThrow(new RuntimeException("diagnoses down")); + when(getMedicationsRequest.get(anyInt())).thenReturn(new PaginatedResponse<>(Collections.emptyList(), false)); + + assertThrows(RuntimeException.class, () -> useCase.execute()); + } + + @Test + public void execute_throws_whenServicesRequestFails() throws Exception { + when(getDiagnosesRequest.get()).thenReturn(Collections.singletonList(new DiagnosisDto("D1", "Diagnosis 1"))); + when(getActivityDefinitionsRequest.get(0)).thenThrow(new RuntimeException("services down")); + when(getMedicationsRequest.get(anyInt())).thenReturn(new PaginatedResponse<>(Collections.emptyList(), false)); + + assertThrows(RuntimeException.class, () -> useCase.execute()); + } + + @Test + public void getSkippedMedicationPages_emptyBeforeExecute_thenUpdatedAfterExecute() throws Exception { + assertTrue(useCase.getSkippedMedicationPages().isEmpty()); + + when(getDiagnosesRequest.get()).thenReturn(Collections.singletonList(new DiagnosisDto("D1", "Diagnosis 1"))); + when(getActivityDefinitionsRequest.get(anyInt())).thenReturn(new PaginatedResponse<>(Collections.emptyList(), false)); + when(getMedicationsRequest.get(0)).thenThrow(new RuntimeException("page 1 failed")); + when(getMedicationsRequest.get(1)).thenReturn(new PaginatedResponse<>(Collections.emptyList(), false)); + + useCase.execute(); + + assertEquals(Arrays.asList(1), useCase.getSkippedMedicationPages()); + } +}