From 884e8314e9861502e6c06bf2cb56e8b050168a23 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 15:43:53 +0700 Subject: [PATCH] feat: restructure province COVID-19 endpoints with nested JSON format - Add new ProvinceCaseResponse DTOs with nested daily/cumulative/statistics structure - Transform Indonesian field names to English equivalents (hari_ke->day, kasus_baru->daily, etc.) - Update province endpoints to use new transformation logic - Include province-specific fields (PersonUnderObservation, PersonUnderSupervision) - Add comprehensive unit tests for transformation functions - Maintain backward compatibility by preserving province information in response - Follow same pattern as national endpoints for consistency --- internal/handler/covid_handler.go | 16 +- internal/models/province_case_response.go | 113 +++++ .../models/province_case_response_test.go | 478 ++++++++++++++++++ 3 files changed, 603 insertions(+), 4 deletions(-) create mode 100644 internal/models/province_case_response.go create mode 100644 internal/models/province_case_response_test.go diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index d09e167..e077d09 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -90,7 +90,9 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - writeSuccessResponse(w, cases) + // Transform to new response structure + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) return } @@ -99,7 +101,9 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - writeSuccessResponse(w, cases) + // Transform to new response structure + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) return } @@ -112,7 +116,9 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - writeSuccessResponse(w, cases) + // Transform to new response structure + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) return } @@ -122,7 +128,9 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) return } - writeSuccessResponse(w, cases) + // Transform to new response structure + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) } func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go new file mode 100644 index 0000000..dfc6994 --- /dev/null +++ b/internal/models/province_case_response.go @@ -0,0 +1,113 @@ +package models + +import "time" + +// ProvinceCaseResponse represents the structured response for province COVID-19 case data +type ProvinceCaseResponse struct { + Day int64 `json:"day"` + Date time.Time `json:"date"` + Daily ProvinceDailyCases `json:"daily"` + Cumulative ProvinceCumulativeCases `json:"cumulative"` + Statistics ProvinceCaseStatistics `json:"statistics"` + Province *Province `json:"province,omitempty"` +} + +// ProvinceDailyCases represents new cases for a single day in a province +type ProvinceDailyCases struct { + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + PersonUnderObservation int64 `json:"person_under_observation"` + FinishedPersonUnderObservation int64 `json:"finished_person_under_observation"` + PersonUnderSupervision int64 `json:"person_under_supervision"` + FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision"` +} + +// ProvinceCumulativeCases represents total cases accumulated over time in a province +type ProvinceCumulativeCases struct { + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + PersonUnderObservation int64 `json:"person_under_observation"` + ActivePersonUnderObservation int64 `json:"active_person_under_observation"` + FinishedPersonUnderObservation int64 `json:"finished_person_under_observation"` + PersonUnderSupervision int64 `json:"person_under_supervision"` + ActivePersonUnderSupervision int64 `json:"active_person_under_supervision"` + FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision"` +} + +// ProvinceCaseStatistics contains calculated statistics and metrics for province data +type ProvinceCaseStatistics struct { + Percentages CasePercentages `json:"percentages"` + ReproductionRate *ReproductionRate `json:"reproduction_rate,omitempty"` +} + +// TransformToResponse converts a ProvinceCase model to the response format +func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse { + // Calculate active cases + dailyActive := pc.Positive - pc.Recovered - pc.Deceased + cumulativeActive := pc.CumulativePositive - pc.CumulativeRecovered - pc.CumulativeDeceased + + // Calculate active under observation and supervision + activePersonUnderObservation := pc.CumulativePersonUnderObservation - pc.CumulativeFinishedPersonUnderObservation + activePersonUnderSupervision := pc.CumulativePersonUnderSupervision - pc.CumulativeFinishedPersonUnderSupervision + + // Build response + response := ProvinceCaseResponse{ + Day: pc.Day, + Date: date, + Daily: ProvinceDailyCases{ + Positive: pc.Positive, + Recovered: pc.Recovered, + Deceased: pc.Deceased, + Active: dailyActive, + PersonUnderObservation: pc.PersonUnderObservation, + FinishedPersonUnderObservation: pc.FinishedPersonUnderObservation, + PersonUnderSupervision: pc.PersonUnderSupervision, + FinishedPersonUnderSupervision: pc.FinishedPersonUnderSupervision, + }, + Cumulative: ProvinceCumulativeCases{ + Positive: pc.CumulativePositive, + Recovered: pc.CumulativeRecovered, + Deceased: pc.CumulativeDeceased, + Active: cumulativeActive, + PersonUnderObservation: pc.CumulativePersonUnderObservation, + ActivePersonUnderObservation: activePersonUnderObservation, + FinishedPersonUnderObservation: pc.CumulativeFinishedPersonUnderObservation, + PersonUnderSupervision: pc.CumulativePersonUnderSupervision, + ActivePersonUnderSupervision: activePersonUnderSupervision, + FinishedPersonUnderSupervision: pc.CumulativeFinishedPersonUnderSupervision, + }, + Statistics: ProvinceCaseStatistics{ + Percentages: calculatePercentages(pc.CumulativePositive, pc.CumulativeRecovered, pc.CumulativeDeceased, cumulativeActive), + }, + Province: pc.Province, + } + + // Add reproduction rate if available + if pc.Rt != nil && pc.RtUpper != nil && pc.RtLower != nil { + response.Statistics.ReproductionRate = &ReproductionRate{ + Value: *pc.Rt, + UpperBound: *pc.RtUpper, + LowerBound: *pc.RtLower, + } + } + + return response +} + +// TransformProvinceCaseWithDateToResponse converts a ProvinceCaseWithDate model to the response format +func (pcd *ProvinceCaseWithDate) TransformToResponse() ProvinceCaseResponse { + return pcd.ProvinceCase.TransformToResponse(pcd.Date) +} + +// TransformProvinceCaseSliceToResponse converts a slice of ProvinceCaseWithDate models to response format +func TransformProvinceCaseSliceToResponse(cases []ProvinceCaseWithDate) []ProvinceCaseResponse { + responses := make([]ProvinceCaseResponse, len(cases)) + for i, c := range cases { + responses[i] = c.TransformToResponse() + } + return responses +} \ No newline at end of file diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go new file mode 100644 index 0000000..d4abd2d --- /dev/null +++ b/internal/models/province_case_response_test.go @@ -0,0 +1,478 @@ +package models + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestProvinceCase_TransformToResponse(t *testing.T) { + testDate := time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC) + rt := 1.5 + rtUpper := 1.8 + rtLower := 1.2 + + tests := []struct { + name string + provinceCase ProvinceCase + date time.Time + expectedResult ProvinceCaseResponse + }{ + { + name: "complete province case data", + provinceCase: ProvinceCase{ + ID: 1, + Day: 100, + ProvinceID: "ID-JK", + Positive: 150, + Recovered: 120, + Deceased: 10, + PersonUnderObservation: 25, + FinishedPersonUnderObservation: 20, + PersonUnderSupervision: 30, + FinishedPersonUnderSupervision: 25, + CumulativePositive: 5000, + CumulativeRecovered: 4500, + CumulativeDeceased: 300, + CumulativePersonUnderObservation: 800, + CumulativeFinishedPersonUnderObservation: 750, + CumulativePersonUnderSupervision: 600, + CumulativeFinishedPersonUnderSupervision: 580, + Rt: &rt, + RtUpper: &rtUpper, + RtLower: &rtLower, + Province: &Province{ + ID: "ID-JK", + Name: "DKI Jakarta", + }, + }, + date: testDate, + expectedResult: ProvinceCaseResponse{ + Day: 100, + Date: testDate, + Daily: ProvinceDailyCases{ + Positive: 150, + Recovered: 120, + Deceased: 10, + Active: 20, // 150 - 120 - 10 + PersonUnderObservation: 25, + FinishedPersonUnderObservation: 20, + PersonUnderSupervision: 30, + FinishedPersonUnderSupervision: 25, + }, + Cumulative: ProvinceCumulativeCases{ + Positive: 5000, + Recovered: 4500, + Deceased: 300, + Active: 200, // 5000 - 4500 - 300 + PersonUnderObservation: 800, + ActivePersonUnderObservation: 50, // 800 - 750 + FinishedPersonUnderObservation: 750, + PersonUnderSupervision: 600, + ActivePersonUnderSupervision: 20, // 600 - 580 + FinishedPersonUnderSupervision: 580, + }, + Statistics: ProvinceCaseStatistics{ + Percentages: CasePercentages{ + Active: 4.0, // (200 / 5000) * 100 + Recovered: 90.0, // (4500 / 5000) * 100 + Deceased: 6.0, // (300 / 5000) * 100 + }, + ReproductionRate: &ReproductionRate{ + Value: 1.5, + UpperBound: 1.8, + LowerBound: 1.2, + }, + }, + Province: &Province{ + ID: "ID-JK", + Name: "DKI Jakarta", + }, + }, + }, + { + name: "province case without reproduction rate", + provinceCase: ProvinceCase{ + ID: 2, + Day: 50, + ProvinceID: "ID-JB", + Positive: 100, + Recovered: 80, + Deceased: 5, + PersonUnderObservation: 15, + FinishedPersonUnderObservation: 10, + PersonUnderSupervision: 20, + FinishedPersonUnderSupervision: 15, + CumulativePositive: 2000, + CumulativeRecovered: 1800, + CumulativeDeceased: 100, + CumulativePersonUnderObservation: 400, + CumulativeFinishedPersonUnderObservation: 350, + CumulativePersonUnderSupervision: 300, + CumulativeFinishedPersonUnderSupervision: 290, + Rt: nil, + RtUpper: nil, + RtLower: nil, + Province: &Province{ + ID: "ID-JB", + Name: "Jawa Barat", + }, + }, + date: testDate, + expectedResult: ProvinceCaseResponse{ + Day: 50, + Date: testDate, + Daily: ProvinceDailyCases{ + Positive: 100, + Recovered: 80, + Deceased: 5, + Active: 15, // 100 - 80 - 5 + PersonUnderObservation: 15, + FinishedPersonUnderObservation: 10, + PersonUnderSupervision: 20, + FinishedPersonUnderSupervision: 15, + }, + Cumulative: ProvinceCumulativeCases{ + Positive: 2000, + Recovered: 1800, + Deceased: 100, + Active: 100, // 2000 - 1800 - 100 + PersonUnderObservation: 400, + ActivePersonUnderObservation: 50, // 400 - 350 + FinishedPersonUnderObservation: 350, + PersonUnderSupervision: 300, + ActivePersonUnderSupervision: 10, // 300 - 290 + FinishedPersonUnderSupervision: 290, + }, + Statistics: ProvinceCaseStatistics{ + Percentages: CasePercentages{ + Active: 5.0, // (100 / 2000) * 100 + Recovered: 90.0, // (1800 / 2000) * 100 + Deceased: 5.0, // (100 / 2000) * 100 + }, + ReproductionRate: nil, + }, + Province: &Province{ + ID: "ID-JB", + Name: "Jawa Barat", + }, + }, + }, + { + name: "province case with zero cumulative positive", + provinceCase: ProvinceCase{ + ID: 3, + Day: 1, + ProvinceID: "ID-AC", + Positive: 0, + Recovered: 0, + Deceased: 0, + PersonUnderObservation: 0, + FinishedPersonUnderObservation: 0, + PersonUnderSupervision: 0, + FinishedPersonUnderSupervision: 0, + CumulativePositive: 0, + CumulativeRecovered: 0, + CumulativeDeceased: 0, + CumulativePersonUnderObservation: 0, + CumulativeFinishedPersonUnderObservation: 0, + CumulativePersonUnderSupervision: 0, + CumulativeFinishedPersonUnderSupervision: 0, + Rt: nil, + RtUpper: nil, + RtLower: nil, + Province: &Province{ + ID: "ID-AC", + Name: "Aceh", + }, + }, + date: testDate, + expectedResult: ProvinceCaseResponse{ + Day: 1, + Date: testDate, + Daily: ProvinceDailyCases{ + Positive: 0, + Recovered: 0, + Deceased: 0, + Active: 0, + PersonUnderObservation: 0, + FinishedPersonUnderObservation: 0, + PersonUnderSupervision: 0, + FinishedPersonUnderSupervision: 0, + }, + Cumulative: ProvinceCumulativeCases{ + Positive: 0, + Recovered: 0, + Deceased: 0, + Active: 0, + PersonUnderObservation: 0, + ActivePersonUnderObservation: 0, + FinishedPersonUnderObservation: 0, + PersonUnderSupervision: 0, + ActivePersonUnderSupervision: 0, + FinishedPersonUnderSupervision: 0, + }, + Statistics: ProvinceCaseStatistics{ + Percentages: CasePercentages{ + Active: 0.0, + Recovered: 0.0, + Deceased: 0.0, + }, + ReproductionRate: nil, + }, + Province: &Province{ + ID: "ID-AC", + Name: "Aceh", + }, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := tt.provinceCase.TransformToResponse(tt.date) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { + testDate := time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC) + rt := 1.2 + rtUpper := 1.5 + rtLower := 0.9 + + provinceCaseWithDate := ProvinceCaseWithDate{ + ProvinceCase: ProvinceCase{ + ID: 1, + Day: 200, + ProvinceID: "ID-JT", + Positive: 50, + Recovered: 40, + Deceased: 2, + PersonUnderObservation: 10, + FinishedPersonUnderObservation: 8, + PersonUnderSupervision: 12, + FinishedPersonUnderSupervision: 10, + CumulativePositive: 3000, + CumulativeRecovered: 2700, + CumulativeDeceased: 200, + CumulativePersonUnderObservation: 500, + CumulativeFinishedPersonUnderObservation: 450, + CumulativePersonUnderSupervision: 350, + CumulativeFinishedPersonUnderSupervision: 320, + Rt: &rt, + RtUpper: &rtUpper, + RtLower: &rtLower, + Province: &Province{ + ID: "ID-JT", + Name: "Jawa Tengah", + }, + }, + Date: testDate, + } + + result := provinceCaseWithDate.TransformToResponse() + + expected := ProvinceCaseResponse{ + Day: 200, + Date: testDate, + Daily: ProvinceDailyCases{ + Positive: 50, + Recovered: 40, + Deceased: 2, + Active: 8, // 50 - 40 - 2 + PersonUnderObservation: 10, + FinishedPersonUnderObservation: 8, + PersonUnderSupervision: 12, + FinishedPersonUnderSupervision: 10, + }, + Cumulative: ProvinceCumulativeCases{ + Positive: 3000, + Recovered: 2700, + Deceased: 200, + Active: 100, // 3000 - 2700 - 200 + PersonUnderObservation: 500, + ActivePersonUnderObservation: 50, // 500 - 450 + FinishedPersonUnderObservation: 450, + PersonUnderSupervision: 350, + ActivePersonUnderSupervision: 30, // 350 - 320 + FinishedPersonUnderSupervision: 320, + }, + Statistics: ProvinceCaseStatistics{ + Percentages: CasePercentages{ + Active: 3.3333333333333335, // (100 / 3000) * 100 + Recovered: 90.0, // (2700 / 3000) * 100 + Deceased: 6.666666666666667, // (200 / 3000) * 100 + }, + ReproductionRate: &ReproductionRate{ + Value: 1.2, + UpperBound: 1.5, + LowerBound: 0.9, + }, + }, + Province: &Province{ + ID: "ID-JT", + Name: "Jawa Tengah", + }, + } + + assert.Equal(t, expected, result) +} + +func TestTransformProvinceCaseSliceToResponse(t *testing.T) { + testDate1 := time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC) + testDate2 := time.Date(2023, 10, 16, 0, 0, 0, 0, time.UTC) + rt := 1.3 + rtUpper := 1.6 + rtLower := 1.0 + + cases := []ProvinceCaseWithDate{ + { + ProvinceCase: ProvinceCase{ + ID: 1, + Day: 1, + ProvinceID: "ID-JK", + Positive: 100, + Recovered: 80, + Deceased: 5, + PersonUnderObservation: 20, + FinishedPersonUnderObservation: 15, + PersonUnderSupervision: 25, + FinishedPersonUnderSupervision: 20, + CumulativePositive: 1000, + CumulativeRecovered: 800, + CumulativeDeceased: 50, + CumulativePersonUnderObservation: 200, + CumulativeFinishedPersonUnderObservation: 180, + CumulativePersonUnderSupervision: 250, + CumulativeFinishedPersonUnderSupervision: 230, + Rt: &rt, + RtUpper: &rtUpper, + RtLower: &rtLower, + Province: &Province{ + ID: "ID-JK", + Name: "DKI Jakarta", + }, + }, + Date: testDate1, + }, + { + ProvinceCase: ProvinceCase{ + ID: 2, + Day: 2, + ProvinceID: "ID-JK", + Positive: 50, + Recovered: 45, + Deceased: 2, + PersonUnderObservation: 10, + FinishedPersonUnderObservation: 8, + PersonUnderSupervision: 12, + FinishedPersonUnderSupervision: 10, + CumulativePositive: 1050, + CumulativeRecovered: 845, + CumulativeDeceased: 52, + CumulativePersonUnderObservation: 210, + CumulativeFinishedPersonUnderObservation: 188, + CumulativePersonUnderSupervision: 262, + CumulativeFinishedPersonUnderSupervision: 240, + Rt: &rt, + RtUpper: &rtUpper, + RtLower: &rtLower, + Province: &Province{ + ID: "ID-JK", + Name: "DKI Jakarta", + }, + }, + Date: testDate2, + }, + } + + result := TransformProvinceCaseSliceToResponse(cases) + + assert.Len(t, result, 2) + + // Test first case + assert.Equal(t, int64(1), result[0].Day) + assert.Equal(t, testDate1, result[0].Date) + assert.Equal(t, int64(100), result[0].Daily.Positive) + assert.Equal(t, int64(15), result[0].Daily.Active) // 100 - 80 - 5 + assert.Equal(t, int64(1000), result[0].Cumulative.Positive) + assert.Equal(t, int64(150), result[0].Cumulative.Active) // 1000 - 800 - 50 + + // Test second case + assert.Equal(t, int64(2), result[1].Day) + assert.Equal(t, testDate2, result[1].Date) + assert.Equal(t, int64(50), result[1].Daily.Positive) + assert.Equal(t, int64(3), result[1].Daily.Active) // 50 - 45 - 2 + assert.Equal(t, int64(1050), result[1].Cumulative.Positive) + assert.Equal(t, int64(153), result[1].Cumulative.Active) // 1050 - 845 - 52 +} + +func TestTransformProvinceCaseSliceToResponse_EmptySlice(t *testing.T) { + var cases []ProvinceCaseWithDate + result := TransformProvinceCaseSliceToResponse(cases) + assert.Empty(t, result) +} + +func TestProvinceCaseResponse_JSONStructure(t *testing.T) { + // This test verifies that the JSON structure matches the expected format + testDate := time.Date(2023, 10, 15, 0, 0, 0, 0, time.UTC) + rt := 1.5 + + provinceCase := ProvinceCase{ + ID: 1, + Day: 100, + ProvinceID: "ID-JK", + Positive: 150, + Recovered: 120, + Deceased: 10, + PersonUnderObservation: 25, + FinishedPersonUnderObservation: 20, + PersonUnderSupervision: 30, + FinishedPersonUnderSupervision: 25, + CumulativePositive: 5000, + CumulativeRecovered: 4500, + CumulativeDeceased: 300, + CumulativePersonUnderObservation: 800, + CumulativeFinishedPersonUnderObservation: 750, + CumulativePersonUnderSupervision: 600, + CumulativeFinishedPersonUnderSupervision: 580, + Rt: &rt, + RtUpper: &rt, + RtLower: &rt, + Province: &Province{ + ID: "ID-JK", + Name: "DKI Jakarta", + }, + } + + result := provinceCase.TransformToResponse(testDate) + + // Verify the nested structure exists + assert.NotNil(t, result.Daily) + assert.NotNil(t, result.Cumulative) + assert.NotNil(t, result.Statistics) + assert.NotNil(t, result.Statistics.Percentages) + assert.NotNil(t, result.Statistics.ReproductionRate) + assert.NotNil(t, result.Province) + + // Verify key field names are in English + assert.Equal(t, int64(100), result.Day) // "day" + assert.Equal(t, testDate, result.Date) // "date" + // "daily" nested structure + assert.Equal(t, int64(150), result.Daily.Positive) // "positive" + assert.Equal(t, int64(120), result.Daily.Recovered) // "recovered" + assert.Equal(t, int64(10), result.Daily.Deceased) // "deceased" + assert.Equal(t, int64(20), result.Daily.Active) // "active" + // "cumulative" nested structure + assert.Equal(t, int64(5000), result.Cumulative.Positive) // "positive" + assert.Equal(t, int64(4500), result.Cumulative.Recovered) // "recovered" + assert.Equal(t, int64(300), result.Cumulative.Deceased) // "deceased" + assert.Equal(t, int64(200), result.Cumulative.Active) // "active" + // "statistics" nested structure with "percentages" and "reproduction_rate" + assert.True(t, result.Statistics.Percentages.Active > 0) + assert.True(t, result.Statistics.Percentages.Recovered > 0) + assert.True(t, result.Statistics.Percentages.Deceased > 0) +} \ No newline at end of file