From 14cf791c5a177ad32fcf8d3c21f084cf387edfa6 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 18:53:52 +0700 Subject: [PATCH 1/5] fix: handle NULL values in province case database fields - Make nullable database fields properly nullable (*int64) in ProvinceCase model - Add null-safe transformation in ProvinceCaseResponse - Fix SQL scan errors for person_under_observation and related fields - Add page parameter support in ParsePaginationParams utility function Resolves SQL scan error: "converting NULL to int64 is unsupported" --- internal/models/province_case.go | 16 ++++++------ internal/models/province_case_response.go | 30 ++++++++++++++--------- pkg/utils/query.go | 20 +++++++++++++++ 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/internal/models/province_case.go b/internal/models/province_case.go index 7815fd4..4c59f04 100644 --- a/internal/models/province_case.go +++ b/internal/models/province_case.go @@ -9,17 +9,17 @@ type ProvinceCase struct { Positive int64 `json:"positive" db:"positive"` Recovered int64 `json:"recovered" db:"recovered"` Deceased int64 `json:"deceased" db:"deceased"` - PersonUnderObservation int64 `json:"person_under_observation" db:"person_under_observation"` - FinishedPersonUnderObservation int64 `json:"finished_person_under_observation" db:"finished_person_under_observation"` - PersonUnderSupervision int64 `json:"person_under_supervision" db:"person_under_supervision"` - FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision" db:"finished_person_under_supervision"` + PersonUnderObservation *int64 `json:"person_under_observation" db:"person_under_observation"` + FinishedPersonUnderObservation *int64 `json:"finished_person_under_observation" db:"finished_person_under_observation"` + PersonUnderSupervision *int64 `json:"person_under_supervision" db:"person_under_supervision"` + FinishedPersonUnderSupervision *int64 `json:"finished_person_under_supervision" db:"finished_person_under_supervision"` CumulativePositive int64 `json:"cumulative_positive" db:"cumulative_positive"` CumulativeRecovered int64 `json:"cumulative_recovered" db:"cumulative_recovered"` CumulativeDeceased int64 `json:"cumulative_deceased" db:"cumulative_deceased"` - CumulativePersonUnderObservation int64 `json:"cumulative_person_under_observation" db:"cumulative_person_under_observation"` - CumulativeFinishedPersonUnderObservation int64 `json:"cumulative_finished_person_under_observation" db:"cumulative_finished_person_under_observation"` - CumulativePersonUnderSupervision int64 `json:"cumulative_person_under_supervision" db:"cumulative_person_under_supervision"` - CumulativeFinishedPersonUnderSupervision int64 `json:"cumulative_finished_person_under_supervision" db:"cumulative_finished_person_under_supervision"` + CumulativePersonUnderObservation *int64 `json:"cumulative_person_under_observation" db:"cumulative_person_under_observation"` + CumulativeFinishedPersonUnderObservation *int64 `json:"cumulative_finished_person_under_observation" db:"cumulative_finished_person_under_observation"` + CumulativePersonUnderSupervision *int64 `json:"cumulative_person_under_supervision" db:"cumulative_person_under_supervision"` + CumulativeFinishedPersonUnderSupervision *int64 `json:"cumulative_finished_person_under_supervision" db:"cumulative_finished_person_under_supervision"` Rt *float64 `json:"rt" db:"rt"` RtUpper *float64 `json:"rt_upper" db:"rt_upper"` RtLower *float64 `json:"rt_lower" db:"rt_lower"` diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go index 2cfe08c..da3ad08 100644 --- a/internal/models/province_case_response.go +++ b/internal/models/province_case_response.go @@ -70,9 +70,17 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse 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 + // Helper function to safely get int64 value from pointer + safeInt64 := func(ptr *int64) int64 { + if ptr == nil { + return 0 + } + return *ptr + } + + // Calculate active under observation and supervision (with null safety) + activePersonUnderObservation := safeInt64(pc.CumulativePersonUnderObservation) - safeInt64(pc.CumulativeFinishedPersonUnderObservation) + activePersonUnderSupervision := safeInt64(pc.CumulativePersonUnderSupervision) - safeInt64(pc.CumulativeFinishedPersonUnderSupervision) // Build response response := ProvinceCaseResponse{ @@ -84,12 +92,12 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Deceased: pc.Deceased, Active: dailyActive, ODP: DailyObservationData{ - Active: pc.PersonUnderObservation - pc.FinishedPersonUnderObservation, - Finished: pc.FinishedPersonUnderObservation, + Active: safeInt64(pc.PersonUnderObservation) - safeInt64(pc.FinishedPersonUnderObservation), + Finished: safeInt64(pc.FinishedPersonUnderObservation), }, PDP: DailySupervisionData{ - Active: pc.PersonUnderSupervision - pc.FinishedPersonUnderSupervision, - Finished: pc.FinishedPersonUnderSupervision, + Active: safeInt64(pc.PersonUnderSupervision) - safeInt64(pc.FinishedPersonUnderSupervision), + Finished: safeInt64(pc.FinishedPersonUnderSupervision), }, }, Cumulative: ProvinceCumulativeCases{ @@ -99,13 +107,13 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Active: cumulativeActive, ODP: ObservationData{ Active: activePersonUnderObservation, - Finished: pc.CumulativeFinishedPersonUnderObservation, - Total: pc.CumulativePersonUnderObservation, + Finished: safeInt64(pc.CumulativeFinishedPersonUnderObservation), + Total: safeInt64(pc.CumulativePersonUnderObservation), }, PDP: SupervisionData{ Active: activePersonUnderSupervision, - Finished: pc.CumulativeFinishedPersonUnderSupervision, - Total: pc.CumulativePersonUnderSupervision, + Finished: safeInt64(pc.CumulativeFinishedPersonUnderSupervision), + Total: safeInt64(pc.CumulativePersonUnderSupervision), }, }, Statistics: ProvinceCaseStatistics{ diff --git a/pkg/utils/query.go b/pkg/utils/query.go index 734e261..a9bd461 100644 --- a/pkg/utils/query.go +++ b/pkg/utils/query.go @@ -150,3 +150,23 @@ func ValidatePaginationParams(limit, offset int) (int, int) { return limit, offset } + +// ParsePaginationParams parses pagination parameters from request +// Supports both offset-based and page-based pagination +func ParsePaginationParams(r *http.Request) (limit, offset int) { + // Parse limit (records per page) + limit = ParseIntQueryParam(r, "limit", 50) + + // Check if page parameter is provided + page := ParseIntQueryParam(r, "page", 0) + if page > 0 { + // Page-based pagination (page starts from 1) + offset = (page - 1) * limit + } else { + // Offset-based pagination (fallback) + offset = ParseIntQueryParam(r, "offset", 0) + } + + // Validate and adjust parameters + return ValidatePaginationParams(limit, offset) +} From 91028d7289a6a08453a44f87c13349ca300b717f Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:33:49 +0700 Subject: [PATCH 2/5] fix: standardize error handling in province case repository - Consistent error messages for count and query operations - Match patterns from national case repository - Improve error context in paginated methods --- .../repository/province_case_repository.go | 159 ++++++++++++++++-- 1 file changed, 144 insertions(+), 15 deletions(-) diff --git a/internal/repository/province_case_repository.go b/internal/repository/province_case_repository.go index cb7b9f0..add40cc 100644 --- a/internal/repository/province_case_repository.go +++ b/internal/repository/province_case_repository.go @@ -72,7 +72,7 @@ func (r *provinceCaseRepository) GetAllPaginatedSorted(limit, offset int, sortPa var total int err := r.db.QueryRow(countQuery).Scan(&total) if err != nil { - return nil, 0, fmt.Errorf("failed to count province cases: %w", err) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -91,7 +91,7 @@ func (r *provinceCaseRepository) GetAllPaginatedSorted(limit, offset int, sortPa cases, err := r.queryProvinceCases(query, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -123,7 +123,7 @@ func (r *provinceCaseRepository) GetByProvinceIDPaginated(provinceID string, lim var total int err := r.db.QueryRow(countQuery, provinceID).Scan(&total) if err != nil { - return nil, 0, fmt.Errorf("failed to count province cases for province %s: %w", provinceID, err) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -143,7 +143,7 @@ func (r *provinceCaseRepository) GetByProvinceIDPaginated(provinceID string, lim cases, err := r.queryProvinceCases(query, provinceID, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -175,7 +175,7 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID var total int err := r.db.QueryRow(countQuery, provinceID, startDate, endDate).Scan(&total) if err != nil { - return nil, 0, fmt.Errorf("failed to count province cases for province %s in date range: %w", provinceID, err) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -195,7 +195,7 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID cases, err := r.queryProvinceCases(query, provinceID, startDate, endDate, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -227,7 +227,7 @@ func (r *provinceCaseRepository) GetByDateRangePaginated(startDate, endDate time var total int err := r.db.QueryRow(countQuery, startDate, endDate).Scan(&total) if err != nil { - return nil, 0, fmt.Errorf("failed to count province cases in date range: %w", err) + return nil, 0, fmt.Errorf("failed to get total count: %w", err) } // Get paginated data @@ -247,7 +247,7 @@ func (r *provinceCaseRepository) GetByDateRangePaginated(startDate, endDate time cases, err := r.queryProvinceCases(query, startDate, endDate, limit, offset) if err != nil { - return nil, 0, err + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) } return cases, total, nil @@ -357,27 +357,156 @@ func (r *provinceCaseRepository) buildOrderClause(sortParams utils.SortParams) s return dbField + " " + order } -// Stub implementations for other sorted methods - delegate to existing methods for now +// Sorted method implementations func (r *provinceCaseRepository) GetByProvinceIDSorted(provinceID string, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { - return r.GetByProvinceID(provinceID) + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? + ORDER BY ` + r.buildOrderClause(sortParams) + + return r.queryProvinceCases(query, provinceID) } func (r *provinceCaseRepository) GetByProvinceIDPaginatedSorted(provinceID string, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { - return r.GetByProvinceIDPaginated(provinceID, limit, offset) + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE pc.province_id = ?` + + var total int + err := r.db.QueryRow(countQuery, provinceID).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? + ORDER BY ` + r.buildOrderClause(sortParams) + ` LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) + } + + return cases, total, nil } func (r *provinceCaseRepository) GetByProvinceIDAndDateRangeSorted(provinceID string, startDate, endDate time.Time, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { - return r.GetByProvinceIDAndDateRange(provinceID, startDate, endDate) + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ? + ORDER BY ` + r.buildOrderClause(sortParams) + + return r.queryProvinceCases(query, provinceID, startDate, endDate) } func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginatedSorted(provinceID string, startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { - return r.GetByProvinceIDAndDateRangePaginated(provinceID, startDate, endDate, limit, offset) + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ?` + + var total int + err := r.db.QueryRow(countQuery, provinceID, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ? + ORDER BY ` + r.buildOrderClause(sortParams) + ` LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) + } + + return cases, total, nil } func (r *provinceCaseRepository) GetByDateRangeSorted(startDate, endDate time.Time, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) { - return r.GetByDateRange(startDate, endDate) + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE nc.date BETWEEN ? AND ? + ORDER BY ` + r.buildOrderClause(sortParams) + + return r.queryProvinceCases(query, startDate, endDate) } func (r *provinceCaseRepository) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, int, error) { - return r.GetByDateRangePaginated(startDate, endDate, limit, offset) + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE nc.date BETWEEN ? AND ?` + + var total int + err := r.db.QueryRow(countQuery, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE nc.date BETWEEN ? AND ? + ORDER BY ` + r.buildOrderClause(sortParams) + ` LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated province cases: %w", err) + } + + return cases, total, nil } From ecd9047c25571053cbb04e1800613dcba03b533f Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:34:10 +0700 Subject: [PATCH 3/5] fix: handle nullable pointer fields in province case tests - Convert direct integer values to pointer references - Use inline pointer creation for nullable database fields - Update test assertions to dereference pointers properly - Fix compilation errors in model tests --- .../models/province_case_response_test.go | 142 +++++++++++------- internal/models/province_case_test.go | 24 +-- 2 files changed, 98 insertions(+), 68 deletions(-) diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go index 88c832a..5cd00a8 100644 --- a/internal/models/province_case_response_test.go +++ b/internal/models/province_case_response_test.go @@ -13,6 +13,36 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { rtUpper := 1.8 rtLower := 1.2 + // Helper variables for nullable int64 fields (test case 1) + personUnderObservation1 := int64(25) + finishedPersonUnderObservation1 := int64(20) + personUnderSupervision1 := int64(30) + finishedPersonUnderSupervision1 := int64(25) + cumulativePersonUnderObservation1 := int64(800) + cumulativeFinishedPersonUnderObservation1 := int64(750) + cumulativePersonUnderSupervision1 := int64(600) + cumulativeFinishedPersonUnderSupervision1 := int64(580) + + // Helper variables for nullable int64 fields (test case 2) + personUnderObservation2 := int64(15) + finishedPersonUnderObservation2 := int64(10) + personUnderSupervision2 := int64(20) + finishedPersonUnderSupervision2 := int64(15) + cumulativePersonUnderObservation2 := int64(400) + cumulativeFinishedPersonUnderObservation2 := int64(350) + cumulativePersonUnderSupervision2 := int64(300) + cumulativeFinishedPersonUnderSupervision2 := int64(290) + + // Helper variables for nullable int64 fields (test case 3 - zeros) + personUnderObservation3 := int64(0) + finishedPersonUnderObservation3 := int64(0) + personUnderSupervision3 := int64(0) + finishedPersonUnderSupervision3 := int64(0) + cumulativePersonUnderObservation3 := int64(0) + cumulativeFinishedPersonUnderObservation3 := int64(0) + cumulativePersonUnderSupervision3 := int64(0) + cumulativeFinishedPersonUnderSupervision3 := int64(0) + tests := []struct { name string provinceCase ProvinceCase @@ -28,17 +58,17 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Positive: 150, Recovered: 120, Deceased: 10, - PersonUnderObservation: 25, - FinishedPersonUnderObservation: 20, - PersonUnderSupervision: 30, - FinishedPersonUnderSupervision: 25, + PersonUnderObservation: &personUnderObservation1, + FinishedPersonUnderObservation: &finishedPersonUnderObservation1, + PersonUnderSupervision: &personUnderSupervision1, + FinishedPersonUnderSupervision: &finishedPersonUnderSupervision1, CumulativePositive: 5000, CumulativeRecovered: 4500, CumulativeDeceased: 300, - CumulativePersonUnderObservation: 800, - CumulativeFinishedPersonUnderObservation: 750, - CumulativePersonUnderSupervision: 600, - CumulativeFinishedPersonUnderSupervision: 580, + CumulativePersonUnderObservation: &cumulativePersonUnderObservation1, + CumulativeFinishedPersonUnderObservation: &cumulativeFinishedPersonUnderObservation1, + CumulativePersonUnderSupervision: &cumulativePersonUnderSupervision1, + CumulativeFinishedPersonUnderSupervision: &cumulativeFinishedPersonUnderSupervision1, Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -108,17 +138,17 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Positive: 100, Recovered: 80, Deceased: 5, - PersonUnderObservation: 15, - FinishedPersonUnderObservation: 10, - PersonUnderSupervision: 20, - FinishedPersonUnderSupervision: 15, + PersonUnderObservation: &personUnderObservation2, + FinishedPersonUnderObservation: &finishedPersonUnderObservation2, + PersonUnderSupervision: &personUnderSupervision2, + FinishedPersonUnderSupervision: &finishedPersonUnderSupervision2, CumulativePositive: 2000, CumulativeRecovered: 1800, CumulativeDeceased: 100, - CumulativePersonUnderObservation: 400, - CumulativeFinishedPersonUnderObservation: 350, - CumulativePersonUnderSupervision: 300, - CumulativeFinishedPersonUnderSupervision: 290, + CumulativePersonUnderObservation: &cumulativePersonUnderObservation2, + CumulativeFinishedPersonUnderObservation: &cumulativeFinishedPersonUnderObservation2, + CumulativePersonUnderSupervision: &cumulativePersonUnderSupervision2, + CumulativeFinishedPersonUnderSupervision: &cumulativeFinishedPersonUnderSupervision2, Rt: nil, RtUpper: nil, RtLower: nil, @@ -188,17 +218,17 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Positive: 0, Recovered: 0, Deceased: 0, - PersonUnderObservation: 0, - FinishedPersonUnderObservation: 0, - PersonUnderSupervision: 0, - FinishedPersonUnderSupervision: 0, + PersonUnderObservation: &personUnderObservation3, + FinishedPersonUnderObservation: &finishedPersonUnderObservation3, + PersonUnderSupervision: &personUnderSupervision3, + FinishedPersonUnderSupervision: &finishedPersonUnderSupervision3, CumulativePositive: 0, CumulativeRecovered: 0, CumulativeDeceased: 0, - CumulativePersonUnderObservation: 0, - CumulativeFinishedPersonUnderObservation: 0, - CumulativePersonUnderSupervision: 0, - CumulativeFinishedPersonUnderSupervision: 0, + CumulativePersonUnderObservation: &cumulativePersonUnderObservation3, + CumulativeFinishedPersonUnderObservation: &cumulativeFinishedPersonUnderObservation3, + CumulativePersonUnderSupervision: &cumulativePersonUnderSupervision3, + CumulativeFinishedPersonUnderSupervision: &cumulativeFinishedPersonUnderSupervision3, Rt: nil, RtUpper: nil, RtLower: nil, @@ -283,17 +313,17 @@ func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { Positive: 50, Recovered: 40, Deceased: 2, - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 12, - FinishedPersonUnderSupervision: 10, + PersonUnderObservation: &[]int64{10}[0], + FinishedPersonUnderObservation: &[]int64{8}[0], + PersonUnderSupervision: &[]int64{12}[0], + FinishedPersonUnderSupervision: &[]int64{10}[0], CumulativePositive: 3000, CumulativeRecovered: 2700, CumulativeDeceased: 200, - CumulativePersonUnderObservation: 500, - CumulativeFinishedPersonUnderObservation: 450, - CumulativePersonUnderSupervision: 350, - CumulativeFinishedPersonUnderSupervision: 320, + CumulativePersonUnderObservation: &[]int64{500}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{450}[0], + CumulativePersonUnderSupervision: &[]int64{350}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{320}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -377,17 +407,17 @@ func TestTransformProvinceCaseSliceToResponse(t *testing.T) { Positive: 100, Recovered: 80, Deceased: 5, - PersonUnderObservation: 20, - FinishedPersonUnderObservation: 15, - PersonUnderSupervision: 25, - FinishedPersonUnderSupervision: 20, + PersonUnderObservation: &[]int64{20}[0], + FinishedPersonUnderObservation: &[]int64{15}[0], + PersonUnderSupervision: &[]int64{25}[0], + FinishedPersonUnderSupervision: &[]int64{20}[0], CumulativePositive: 1000, CumulativeRecovered: 800, CumulativeDeceased: 50, - CumulativePersonUnderObservation: 200, - CumulativeFinishedPersonUnderObservation: 180, - CumulativePersonUnderSupervision: 250, - CumulativeFinishedPersonUnderSupervision: 230, + CumulativePersonUnderObservation: &[]int64{200}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{180}[0], + CumulativePersonUnderSupervision: &[]int64{250}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{230}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -406,17 +436,17 @@ func TestTransformProvinceCaseSliceToResponse(t *testing.T) { Positive: 50, Recovered: 45, Deceased: 2, - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 12, - FinishedPersonUnderSupervision: 10, + PersonUnderObservation: &[]int64{10}[0], + FinishedPersonUnderObservation: &[]int64{8}[0], + PersonUnderSupervision: &[]int64{12}[0], + FinishedPersonUnderSupervision: &[]int64{10}[0], CumulativePositive: 1050, CumulativeRecovered: 845, CumulativeDeceased: 52, - CumulativePersonUnderObservation: 210, - CumulativeFinishedPersonUnderObservation: 188, - CumulativePersonUnderSupervision: 262, - CumulativeFinishedPersonUnderSupervision: 240, + CumulativePersonUnderObservation: &[]int64{210}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{188}[0], + CumulativePersonUnderSupervision: &[]int64{262}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{240}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -468,17 +498,17 @@ func TestProvinceCaseResponse_JSONStructure(t *testing.T) { Positive: 150, Recovered: 120, Deceased: 10, - PersonUnderObservation: 25, - FinishedPersonUnderObservation: 20, - PersonUnderSupervision: 30, - FinishedPersonUnderSupervision: 25, + PersonUnderObservation: &[]int64{25}[0], + FinishedPersonUnderObservation: &[]int64{20}[0], + PersonUnderSupervision: &[]int64{30}[0], + FinishedPersonUnderSupervision: &[]int64{25}[0], CumulativePositive: 5000, CumulativeRecovered: 4500, CumulativeDeceased: 300, - CumulativePersonUnderObservation: 800, - CumulativeFinishedPersonUnderObservation: 750, - CumulativePersonUnderSupervision: 600, - CumulativeFinishedPersonUnderSupervision: 580, + CumulativePersonUnderObservation: &[]int64{800}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{750}[0], + CumulativePersonUnderSupervision: &[]int64{600}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{580}[0], Rt: &rt, RtUpper: &rt, RtLower: &rt, diff --git a/internal/models/province_case_test.go b/internal/models/province_case_test.go index b8071af..a5b477b 100644 --- a/internal/models/province_case_test.go +++ b/internal/models/province_case_test.go @@ -19,17 +19,17 @@ func TestProvinceCase_Structure(t *testing.T) { Positive: 50, Recovered: 40, Deceased: 2, - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 5, - FinishedPersonUnderSupervision: 3, + PersonUnderObservation: &[]int64{10}[0], + FinishedPersonUnderObservation: &[]int64{8}[0], + PersonUnderSupervision: &[]int64{5}[0], + FinishedPersonUnderSupervision: &[]int64{3}[0], CumulativePositive: 500, CumulativeRecovered: 400, CumulativeDeceased: 20, - CumulativePersonUnderObservation: 100, - CumulativeFinishedPersonUnderObservation: 80, - CumulativePersonUnderSupervision: 50, - CumulativeFinishedPersonUnderSupervision: 30, + CumulativePersonUnderObservation: &[]int64{100}[0], + CumulativeFinishedPersonUnderObservation: &[]int64{80}[0], + CumulativePersonUnderSupervision: &[]int64{50}[0], + CumulativeFinishedPersonUnderSupervision: &[]int64{30}[0], Rt: &rt, RtUpper: &rtUpper, RtLower: &rtLower, @@ -42,10 +42,10 @@ func TestProvinceCase_Structure(t *testing.T) { assert.Equal(t, int64(50), provinceCase.Positive) assert.Equal(t, int64(40), provinceCase.Recovered) assert.Equal(t, int64(2), provinceCase.Deceased) - assert.Equal(t, int64(10), provinceCase.PersonUnderObservation) - assert.Equal(t, int64(8), provinceCase.FinishedPersonUnderObservation) - assert.Equal(t, int64(5), provinceCase.PersonUnderSupervision) - assert.Equal(t, int64(3), provinceCase.FinishedPersonUnderSupervision) + assert.Equal(t, int64(10), *provinceCase.PersonUnderObservation) + assert.Equal(t, int64(8), *provinceCase.FinishedPersonUnderObservation) + assert.Equal(t, int64(5), *provinceCase.PersonUnderSupervision) + assert.Equal(t, int64(3), *provinceCase.FinishedPersonUnderSupervision) assert.Equal(t, int64(500), provinceCase.CumulativePositive) assert.Equal(t, int64(400), provinceCase.CumulativeRecovered) assert.Equal(t, int64(20), provinceCase.CumulativeDeceased) From f96597642de72545f700ad7f800ff1e981a4f7c6 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:34:27 +0700 Subject: [PATCH 4/5] feat: add national cases pagination support - Implement paginated methods in national case repository - Add pagination support to COVID service layer - Update handlers with pagination parameters and responses - Add comprehensive integration tests for paginated endpoints - Maintain backward compatibility with all=true parameter --- internal/handler/covid_handler.go | 65 ++++++++++--- internal/handler/covid_handler_test.go | 83 ++++++++++++++++- .../repository/national_case_repository.go | 93 +++++++++++++++++++ internal/service/covid_service.go | 28 ++++++ internal/service/covid_service_test.go | 10 ++ test/integration/api_test.go | 59 +++++++++++- 6 files changed, 319 insertions(+), 19 deletions(-) diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 458707a..f1de751 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -26,14 +26,19 @@ func NewCovidHandler(covidService service.CovidService, db *database.DB) *CovidH // GetNationalCases godoc // // @Summary Get national COVID-19 cases -// @Description Retrieve national COVID-19 cases data with optional date range filtering and sorting +// @Description Retrieve national COVID-19 cases data with optional date range filtering, sorting, and pagination // @Tags national // @Accept json // @Produce json +// @Param limit query integer false "Records per page (default: 50, max: 1000)" +// @Param offset query integer false "Records to skip (default: 0)" +// @Param page query integer false "Page number (1-based, alternative to offset)" +// @Param all query boolean false "Return all data without pagination" // @Param start_date query string false "Start date (YYYY-MM-DD)" // @Param end_date query string false "End date (YYYY-MM-DD)" // @Param sort query string false "Sort by field:order (e.g., date:desc, positive:asc). Default: date:asc" -// @Success 200 {object} Response{data=[]models.NationalCaseResponse} +// @Success 200 {object} Response{data=models.PaginatedResponse{data=[]models.NationalCaseResponse}} "Paginated response" +// @Success 200 {object} Response{data=[]models.NationalCaseResponse} "All data response when all=true" // @Failure 400 {object} Response // @Failure 429 {object} Response "Rate limit exceeded" // @Failure 500 {object} Response @@ -45,31 +50,66 @@ func NewCovidHandler(covidService service.CovidService, db *database.DB) *CovidH func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) { startDate := r.URL.Query().Get("start_date") endDate := r.URL.Query().Get("end_date") + all := utils.ParseBoolQueryParam(r, "all") // Parse sort parameters (default: date ascending) sortParams := utils.ParseSortParam(r, "date") - if startDate != "" && endDate != "" { - cases, err := h.covidService.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams) + // Handle pagination parameters + limit, offset := utils.ParsePaginationParams(r) + + // Return all data without pagination if "all" is true + if all { + if startDate != "" && endDate != "" { + cases, err := h.covidService.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformSliceToResponse(cases) + writeSuccessResponse(w, responseData) + return + } + + cases, err := h.covidService.GetNationalCasesSorted(sortParams) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformSliceToResponse(cases) writeSuccessResponse(w, responseData) return } - cases, err := h.covidService.GetNationalCasesSorted(sortParams) + // Return paginated data + if startDate != "" && endDate != "" { + cases, total, err := h.covidService.GetNationalCasesByDateRangePaginatedSorted(startDate, endDate, limit, offset, sortParams) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformSliceToResponse(cases) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) + return + } + + cases, total, err := h.covidService.GetNationalCasesPaginatedSorted(limit, offset, sortParams) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - - // Transform to new response structure responseData := models.TransformSliceToResponse(cases) - writeSuccessResponse(w, responseData) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) } // GetLatestNationalCase godoc @@ -145,6 +185,7 @@ func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { // @Param provinceId path string false "Province ID (e.g., '31' for Jakarta)" // @Param limit query integer false "Records per page (default: 50, max: 1000)" // @Param offset query integer false "Records to skip (default: 0)" +// @Param page query integer false "Page number (1-based, alternative to offset)" // @Param all query boolean false "Return all data without pagination" // @Param start_date query string false "Start date (YYYY-MM-DD)" // @Param end_date query string false "End date (YYYY-MM-DD)" @@ -160,8 +201,7 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) provinceID := vars["provinceId"] // Parse query parameters - limit := utils.ParseIntQueryParam(r, "limit", 50) - offset := utils.ParseIntQueryParam(r, "offset", 0) + limit, offset := utils.ParsePaginationParams(r) all := utils.ParseBoolQueryParam(r, "all") startDate := r.URL.Query().Get("start_date") endDate := r.URL.Query().Get("end_date") @@ -169,9 +209,6 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) // Parse sort parameters (default: date ascending) sortParams := utils.ParseSortParam(r, "date") - // Validate pagination params - limit, offset = utils.ValidatePaginationParams(limit, offset) - if provinceID == "" { // Handle all provinces cases if all { diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index 4dc6c40..f2161a6 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -140,6 +140,16 @@ func (m *MockCovidService) GetAllProvinceCasesByDateRangePaginatedSorted(startDa return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) } +func (m *MockCovidService) GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(startDate, endDate, limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + func TestCovidHandler_GetNationalCases(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) @@ -150,7 +160,7 @@ func TestCovidHandler_GetNationalCases(t *testing.T) { mockService.On("GetNationalCasesSorted", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - req, err := http.NewRequest("GET", "/api/v1/national", nil) + req, err := http.NewRequest("GET", "/api/v1/national?all=true", nil) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -177,7 +187,7 @@ func TestCovidHandler_GetNationalCases_WithDateRange(t *testing.T) { mockService.On("GetNationalCasesByDateRangeSorted", "2020-03-01", "2020-03-31", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31", nil) + req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31&all=true", nil) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -199,7 +209,7 @@ func TestCovidHandler_GetNationalCases_ServiceError(t *testing.T) { mockService.On("GetNationalCasesSorted", utils.SortParams{Field: "date", Order: "asc"}).Return([]models.NationalCase{}, errors.New("database error")) - req, err := http.NewRequest("GET", "/api/v1/national", nil) + req, err := http.NewRequest("GET", "/api/v1/national?all=true", nil) assert.NoError(t, err) rr := httptest.NewRecorder() @@ -216,6 +226,73 @@ func TestCovidHandler_GetNationalCases_ServiceError(t *testing.T) { mockService.AssertExpectations(t) } +func TestCovidHandler_GetNationalCases_Paginated(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.NationalCase{ + {ID: 1, Positive: 100, Recovered: 80, Deceased: 5}, + {ID: 2, Positive: 110, Recovered: 85, Deceased: 6}, + } + + mockService.On("GetNationalCasesPaginatedSorted", 10, 0, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, 2, nil) + + req, err := http.NewRequest("GET", "/api/v1/national?limit=10", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetNationalCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + assert.NotNil(t, response.Data) + + // Check that it's a paginated response + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetNationalCases_WithDateRangePaginated(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.NationalCase{ + {ID: 1, Positive: 100, Date: time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)}, + } + + mockService.On("GetNationalCasesByDateRangePaginatedSorted", "2020-03-01", "2020-03-31", 20, 10, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, 1, nil) + + req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31&limit=20&offset=10", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetNationalCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + assert.NotNil(t, response.Data) + + // Check that it's a paginated response + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") + + mockService.AssertExpectations(t) +} + func TestCovidHandler_GetLatestNationalCase(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) diff --git a/internal/repository/national_case_repository.go b/internal/repository/national_case_repository.go index 52147b7..e21f5b4 100644 --- a/internal/repository/national_case_repository.go +++ b/internal/repository/national_case_repository.go @@ -13,8 +13,10 @@ import ( type NationalCaseRepository interface { GetAll() ([]models.NationalCase, error) GetAllSorted(sortParams utils.SortParams) ([]models.NationalCase, error) + GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) GetByDateRange(startDate, endDate time.Time) ([]models.NationalCase, error) GetByDateRangeSorted(startDate, endDate time.Time, sortParams utils.SortParams) ([]models.NationalCase, error) + GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) GetLatest() (*models.NationalCase, error) GetByDay(day int64) (*models.NationalCase, error) } @@ -150,3 +152,94 @@ func (r *nationalCaseRepository) GetByDay(day int64) (*models.NationalCase, erro return &c, nil } + +func (r *nationalCaseRepository) GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + // First get the total count + countQuery := `SELECT COUNT(*) FROM national_cases` + var total int + err := r.db.QueryRow(countQuery).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // Get paginated data + query := `SELECT id, day, date, positive, recovered, deceased, + cumulative_positive, cumulative_recovered, cumulative_deceased, + rt, rt_upper, rt_lower + FROM national_cases + ORDER BY ` + sortParams.GetSQLOrderClause() + ` LIMIT ? OFFSET ?` + + rows, err := r.db.Query(query, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated national cases: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() + + var cases []models.NationalCase + for rows.Next() { + var c models.NationalCase + err := rows.Scan(&c.ID, &c.Day, &c.Date, &c.Positive, &c.Recovered, &c.Deceased, + &c.CumulativePositive, &c.CumulativeRecovered, &c.CumulativeDeceased, + &c.Rt, &c.RtUpper, &c.RtLower) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan national case: %w", err) + } + cases = append(cases, c) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("row iteration error: %w", err) + } + + return cases, total, nil +} + +func (r *nationalCaseRepository) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + // First get the total count + countQuery := `SELECT COUNT(*) FROM national_cases WHERE date BETWEEN ? AND ?` + var total int + err := r.db.QueryRow(countQuery, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to get total count: %w", err) + } + + // Get paginated data + query := `SELECT id, day, date, positive, recovered, deceased, + cumulative_positive, cumulative_recovered, cumulative_deceased, + rt, rt_upper, rt_lower + FROM national_cases + WHERE date BETWEEN ? AND ? + ORDER BY ` + sortParams.GetSQLOrderClause() + ` LIMIT ? OFFSET ?` + + rows, err := r.db.Query(query, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to query paginated national cases by date range: %w", err) + } + defer func() { + if err := rows.Close(); err != nil { + fmt.Printf("Error closing rows: %v\n", err) + } + }() + + var cases []models.NationalCase + for rows.Next() { + var c models.NationalCase + err := rows.Scan(&c.ID, &c.Day, &c.Date, &c.Positive, &c.Recovered, &c.Deceased, + &c.CumulativePositive, &c.CumulativeRecovered, &c.CumulativeDeceased, + &c.Rt, &c.RtUpper, &c.RtLower) + if err != nil { + return nil, 0, fmt.Errorf("failed to scan national case: %w", err) + } + cases = append(cases, c) + } + + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("row iteration error: %w", err) + } + + return cases, total, nil +} diff --git a/internal/service/covid_service.go b/internal/service/covid_service.go index 12db009..23fca3d 100644 --- a/internal/service/covid_service.go +++ b/internal/service/covid_service.go @@ -14,6 +14,8 @@ type CovidService interface { GetNationalCasesSorted(sortParams utils.SortParams) ([]models.NationalCase, error) GetNationalCasesByDateRange(startDate, endDate string) ([]models.NationalCase, error) GetNationalCasesByDateRangeSorted(startDate, endDate string, sortParams utils.SortParams) ([]models.NationalCase, error) + GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) + GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) GetLatestNationalCase() (*models.NationalCase, error) GetProvinces() ([]models.Province, error) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) @@ -358,3 +360,29 @@ func (s *covidService) GetProvinceCasesByDateRangePaginatedSorted(provinceID, st } return cases, total, nil } + +func (s *covidService) GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + cases, total, err := s.nationalCaseRepo.GetAllPaginatedSorted(limit, offset, sortParams) + if err != nil { + return nil, 0, fmt.Errorf("failed to get sorted national cases paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid start date format: %w", err) + } + + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid end date format: %w", err) + } + + cases, total, err := s.nationalCaseRepo.GetByDateRangePaginatedSorted(start, end, limit, offset, sortParams) + if err != nil { + return nil, 0, fmt.Errorf("failed to get sorted national cases by date range paginated: %w", err) + } + return cases, total, nil +} diff --git a/internal/service/covid_service_test.go b/internal/service/covid_service_test.go index 117352a..c1f6962 100644 --- a/internal/service/covid_service_test.go +++ b/internal/service/covid_service_test.go @@ -49,6 +49,16 @@ func (m *MockNationalCaseRepository) GetByDateRangeSorted(startDate, endDate tim return args.Get(0).([]models.NationalCase), args.Error(1) } +func (m *MockNationalCaseRepository) GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + +func (m *MockNationalCaseRepository) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(startDate, endDate, limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + type MockProvinceRepository struct { mock.Mock } diff --git a/test/integration/api_test.go b/test/integration/api_test.go index 3377975..2d28831 100644 --- a/test/integration/api_test.go +++ b/test/integration/api_test.go @@ -54,6 +54,16 @@ func (m *MockNationalCaseRepo) GetByDateRangeSorted(startDate, endDate time.Time return args.Get(0).([]models.NationalCase), args.Error(1) } +func (m *MockNationalCaseRepo) GetAllPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + +func (m *MockNationalCaseRepo) GetByDateRangePaginatedSorted(startDate, endDate time.Time, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) { + args := m.Called(startDate, endDate, limit, offset, sortParams) + return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2) +} + type MockProvinceRepo struct { mock.Mock } @@ -229,7 +239,7 @@ func TestAPI_GetNationalCases(t *testing.T) { mockNationalRepo.On("GetAllSorted", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - resp, err := http.Get(server.URL + "/api/v1/national") + resp, err := http.Get(server.URL + "/api/v1/national?all=true") assert.NoError(t, err) defer func() { if err := resp.Body.Close(); err != nil { @@ -260,7 +270,45 @@ func TestAPI_GetNationalCasesWithDateRange(t *testing.T) { mockNationalRepo.On("GetByDateRangeSorted", startDate, endDate, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil) - resp, err := http.Get(server.URL + "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31") + resp, err := http.Get(server.URL + "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31&all=true") + assert.NoError(t, err) + defer func() { + if err := resp.Body.Close(); err != nil { + t.Logf("Error closing response body: %v", err) + } + }() + + assert.Equal(t, http.StatusOK, resp.StatusCode) + + var response handler.Response + err = json.NewDecoder(resp.Body).Decode(&response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + mockNationalRepo.AssertExpectations(t) +} + +func TestAPI_GetNationalCasesPaginated(t *testing.T) { + server, mockNationalRepo, _, _ := setupTestServer() + defer server.Close() + + now := time.Now() + rt := 1.2 + expectedCases := []models.NationalCase{ + { + ID: 1, + Day: 1, + Date: now, + Positive: 100, + Recovered: 80, + Deceased: 5, + Rt: &rt, + }, + } + + mockNationalRepo.On("GetAllPaginatedSorted", 20, 0, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, 1, nil) + + resp, err := http.Get(server.URL + "/api/v1/national?limit=20") assert.NoError(t, err) defer func() { if err := resp.Body.Close(); err != nil { @@ -274,6 +322,13 @@ func TestAPI_GetNationalCasesWithDateRange(t *testing.T) { err = json.NewDecoder(resp.Body).Decode(&response) assert.NoError(t, err) assert.Equal(t, "success", response.Status) + assert.NotNil(t, response.Data) + + // Verify it's a paginated response + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") mockNationalRepo.AssertExpectations(t) } From 554ed300659a66ff1e2d0b77c1fd5be6aa9ab46a Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 21:34:42 +0700 Subject: [PATCH 5/5] style: add missing newlines at end of files - Add newline to LICENSE file - Add newline to Makefile - Add newline to generate-changelog.rb --- LICENSE | 2 +- Makefile | 3 ++- generate-changelog.rb | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index a86baf9..59ad580 100644 --- a/LICENSE +++ b/LICENSE @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/Makefile b/Makefile index 0863305..a414e30 100644 --- a/Makefile +++ b/Makefile @@ -81,4 +81,5 @@ help: @echo " dev - Run development server with hot reload" @echo " bench - Run benchmarks" @echo " security - Check for vulnerabilities" - @echo " help - Show this help message" \ No newline at end of file + @echo " help - Show this help message" + \ No newline at end of file diff --git a/generate-changelog.rb b/generate-changelog.rb index c649852..85bd29b 100755 --- a/generate-changelog.rb +++ b/generate-changelog.rb @@ -752,4 +752,4 @@ def self.run(args = ARGV) # Run the CLI if this file is executed directly if __FILE__ == $0 CLI.run -end \ No newline at end of file +end