feat(cpone): migrate lab registration endpoints

This commit is contained in:
2026-08-18 14:14:49 +07:00
parent 676d930561
commit a799022bb5
11 changed files with 2590 additions and 7 deletions

View File

@@ -16,12 +16,23 @@ type LayananRepository interface {
MappingHarga(ctx context.Context, params repository.MappingHargaParams) ([]repository.TarifLayanan, error)
}
type Handler struct {
repo LayananRepository
type LabRegistrationRepository interface {
RegisterLab(ctx context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error)
AddLabServices(ctx context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error)
}
func NewHandler(repo LayananRepository) Handler {
return Handler{repo: repo}
type Handler struct {
layananRepo LayananRepository
labRepo LabRegistrationRepository
}
type CPONERepository interface {
LayananRepository
LabRegistrationRepository
}
func NewHandler(repo CPONERepository) Handler {
return Handler{layananRepo: repo, labRepo: repo}
}
func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
@@ -55,7 +66,7 @@ func (h Handler) MappingHarga(w http.ResponseWriter, r *http.Request) {
return
}
rows, err := h.repo.MappingHarga(r.Context(), repository.MappingHargaParams{
rows, err := h.layananRepo.MappingHarga(r.Context(), repository.MappingHargaParams{
PeriodeTarifID: periodeTarifID,
LayananIDs: layananIDs,
Search: strings.TrimSpace(r.URL.Query().Get("search")),

View File

@@ -0,0 +1,331 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"regexp"
"strconv"
"strings"
"time"
"unicode/utf8"
"primaya-api/cpone-middleware/internal/repository"
)
var integerValue = regexp.MustCompile(`^[+-]?\d+$`)
type LabRegistrationResponse struct {
Success bool `json:"success"`
Status string `json:"status"`
Message string `json:"message"`
Data *repository.LabRegistrationData `json:"data"`
}
type validatedLabRegistration struct {
Params repository.LabRegistrationParams
}
func (h Handler) RegisterLab(w http.ResponseWriter, r *http.Request) {
payload, err := decodeJSONObject(r)
if err != nil {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", err.Error(), nil)
return
}
validated, validationMessage := validateLabRegistration(payload)
if validationMessage != "" {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil)
return
}
outcome, err := h.labRepo.RegisterLab(r.Context(), validated.Params)
if err != nil {
var validationErr repository.ValidationError
if errors.As(err, &validationErr) {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr.Message, nil)
return
}
writeLabResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
return
}
writeLabResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
}
func decodeJSONObject(r *http.Request) (map[string]any, error) {
decoder := json.NewDecoder(r.Body)
decoder.UseNumber()
payload := make(map[string]any)
if err := decoder.Decode(&payload); err != nil {
// Laravel treats an empty, malformed, or non-object JSON body as empty
// request input; validation then reports the first required field.
return payload, nil
}
var trailing any
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
return make(map[string]any), nil
}
return payload, nil
}
func validateLabRegistration(payload map[string]any) (validatedLabRegistration, string) {
medrecID, message := requiredString(payload, "medrec_id", 50, "Medrec ID wajib diisi")
if message != "" {
return validatedLabRegistration{}, message
}
jakarta, err := time.LoadLocation("Asia/Jakarta")
if err != nil {
return validatedLabRegistration{}, err.Error()
}
registrationDate := time.Now().In(jakarta)
if value, present := nullableValue(payload, "tanggal"); present {
dateText, ok := value.(string)
if !ok || !validDateFormat(dateText, jakarta) {
return validatedLabRegistration{}, "Tanggal registrasi harus format Y-m-d"
}
registrationDate, _ = time.ParseInLocation("2006-01-02", dateText, jakarta)
}
departmentID, message := requiredString(payload, "departemen_id", 50, "Departemen wajib diisi untuk membuat registrasi")
if message != "" {
return validatedLabRegistration{}, message
}
doctorID, message := requiredString(payload, "dokter_id", 50, "Dokter penanggung jawab wajib diisi")
if message != "" {
return validatedLabRegistration{}, message
}
senderDoctorID, senderPresent, message := optionalString(payload, "dokter_pengirim_id", 50)
if message != "" {
return validatedLabRegistration{}, message
}
referrer, referrerPresent, message := optionalString(payload, "perujuk", 255)
if message != "" {
return validatedLabRegistration{}, message
}
if !senderPresent && !referrerPresent {
return validatedLabRegistration{}, "Dokter pengirim atau perujuk wajib diisi"
}
patientType, patientTypePresent, message := optionalString(payload, "jenis_pasien_id", 0)
if message != "" {
return validatedLabRegistration{}, message
}
if patientTypePresent && patientType != "0" {
return validatedLabRegistration{}, "Registrasi laboratorium API saat ini hanya mendukung pasien umum"
}
tariffPeriodID, message := requiredInteger(payload, "periode_tarif_id", "Periode tarif wajib diisi")
if message != "" {
return validatedLabRegistration{}, message
}
if tariffPeriodID < 1 {
return validatedLabRegistration{}, "The periode tarif id must be at least 1."
}
classID, classPresent, message := optionalString(payload, "kelas_id", 50)
if message != "" {
return validatedLabRegistration{}, message
}
if !classPresent || strings.TrimSpace(classID) == "" {
classID = "Default"
} else {
classID = strings.TrimSpace(classID)
}
var dailyShiftID *int64
if value, present := nullableValue(payload, "shift_harian_id"); present {
parsed, ok := parseInteger(value)
if !ok {
return validatedLabRegistration{}, "The shift harian id must be an integer."
}
if parsed < 1 {
return validatedLabRegistration{}, "The shift harian id must be at least 1."
}
dailyShiftID = &parsed
}
workingDiagnosis, message := requiredString(payload, "diagnosa_kerja", 1000, "Diagnosa kerja wajib diisi")
if message != "" {
return validatedLabRegistration{}, message
}
if utf8.RuneCountInString(workingDiagnosis) < 3 {
return validatedLabRegistration{}, "Diagnosa kerja minimal 3 karakter"
}
if consistsOfOneRepeatedRune(workingDiagnosis) {
return validatedLabRegistration{}, "Diagnosa kerja tidak valid"
}
notes, _, message := optionalString(payload, "catatan", 1000)
if message != "" {
return validatedLabRegistration{}, message
}
channel, channelPresent, message := optionalString(payload, "channel", 50)
if message != "" {
return validatedLabRegistration{}, message
}
if !channelPresent {
channel = "CPONE"
}
channel = strings.ToUpper(strings.TrimSpace(channel))
return validatedLabRegistration{Params: repository.LabRegistrationParams{
MedrecID: medrecID,
RegistrationDate: registrationDate,
DepartmentID: departmentID,
DoctorID: doctorID,
SenderDoctorID: senderDoctorID,
Referrer: referrer,
TariffPeriodID: tariffPeriodID,
ClassID: classID,
DailyShiftID: dailyShiftID,
WorkingDiagnosis: workingDiagnosis,
Notes: notes,
Channel: channel,
}}, ""
}
func requiredString(payload map[string]any, key string, maxLength int, requiredMessage string) (string, string) {
value, exists := payload[key]
if !exists || isLaravelEmpty(value) {
return "", requiredMessage
}
text, ok := value.(string)
if !ok {
return "", fmt.Sprintf("The %s must be a string.", validationAttribute(key))
}
if maxLength > 0 && utf8.RuneCountInString(text) > maxLength {
return "", fmt.Sprintf("The %s must not be greater than %d characters.", validationAttribute(key), maxLength)
}
return text, ""
}
func optionalString(payload map[string]any, key string, maxLength int) (string, bool, string) {
value, present := nullableValue(payload, key)
if !present {
return "", false, ""
}
text, ok := value.(string)
if !ok {
return "", false, fmt.Sprintf("The %s must be a string.", validationAttribute(key))
}
if maxLength > 0 && utf8.RuneCountInString(text) > maxLength {
return "", false, fmt.Sprintf("The %s must not be greater than %d characters.", validationAttribute(key), maxLength)
}
return text, true, ""
}
func requiredInteger(payload map[string]any, key string, requiredMessage string) (int64, string) {
value, exists := payload[key]
if !exists || isLaravelEmpty(value) {
return 0, requiredMessage
}
parsed, ok := parseInteger(value)
if !ok {
return 0, fmt.Sprintf("The %s must be an integer.", validationAttribute(key))
}
return parsed, ""
}
func nullableValue(payload map[string]any, key string) (any, bool) {
value, exists := payload[key]
if !exists || value == nil || value == "" {
return nil, false
}
return value, true
}
func parseInteger(value any) (int64, bool) {
switch typed := value.(type) {
case json.Number:
if integerValue.MatchString(typed.String()) {
parsed, err := strconv.ParseInt(typed.String(), 10, 64)
return parsed, err == nil
}
parsedFloat, err := strconv.ParseFloat(typed.String(), 64)
if err != nil || math.Trunc(parsedFloat) != parsedFloat || parsedFloat > math.MaxInt64 || parsedFloat < math.MinInt64 {
return 0, false
}
return int64(parsedFloat), true
case string:
typed = strings.TrimSpace(typed)
if !integerValue.MatchString(typed) || hasInvalidIntegerLeadingZero(typed) {
return 0, false
}
parsed, err := strconv.ParseInt(typed, 10, 64)
return parsed, err == nil
case bool:
if typed {
return 1, true
}
return 0, false
default:
return 0, false
}
}
func hasInvalidIntegerLeadingZero(value string) bool {
digits := strings.TrimPrefix(strings.TrimPrefix(value, "+"), "-")
return len(digits) > 1 && strings.HasPrefix(digits, "0")
}
func isLaravelEmpty(value any) bool {
if value == nil || value == "" {
return true
}
switch typed := value.(type) {
case []any:
return len(typed) == 0
case map[string]any:
return len(typed) == 0
default:
return false
}
}
func validDateFormat(value string, location *time.Location) bool {
parsed, err := time.ParseInLocation("2006-01-02", value, location)
return err == nil && parsed.Format("2006-01-02") == value
}
func consistsOfOneRepeatedRune(value string) bool {
var first rune
count := 0
for _, current := range value {
if count == 0 {
first = current
} else if current != first {
return false
}
count++
}
return count > 0
}
func validationAttribute(key string) string {
return strings.ReplaceAll(key, "_", " ")
}
func writeLabResponse(
w http.ResponseWriter,
statusCode int,
success bool,
status string,
message string,
data *repository.LabRegistrationData,
) {
WriteJSON(w, statusCode, LabRegistrationResponse{
Success: success,
Status: status,
Message: message,
Data: data,
})
}

View File

@@ -0,0 +1,228 @@
package http
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
type fakeLabRegistrationRepository struct {
outcome repository.LabRegistrationOutcome
err error
params repository.LabRegistrationParams
addParams repository.AddLabServicesParams
regID string
}
func (f *fakeLabRegistrationRepository) RegisterLab(_ context.Context, params repository.LabRegistrationParams) (repository.LabRegistrationOutcome, error) {
f.params = params
return f.outcome, f.err
}
func (f *fakeLabRegistrationRepository) AddLabServices(_ context.Context, regID string, params repository.AddLabServicesParams) (repository.LabRegistrationOutcome, error) {
f.regID = regID
f.addParams = params
return f.outcome, f.err
}
func TestRegisterLabValidPayloadAndResponseParity(t *testing.T) {
doctorID := "DR01"
registrationType := int64(3)
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: http.StatusCreated,
Success: true,
Status: "registered",
Message: "Registrasi laboratorium berhasil disimpan",
Data: &repository.LabRegistrationData{
Registration: repository.LabRegistration{
RegID: "CPLAB2608180001",
MEDRECID: "00001234",
Tanggal: "2026-08-18 10:11:12",
DepartemenID: "LAB",
DokterID: &doctorID,
JenisPasienID: 0,
JenisRegID: &registrationType,
PeriodeTarifID: 10,
},
Transaction: &repository.LabTransaction{
TrxDepartemenID: 99,
TrxID: "CPLAB2608180001",
GrandTotal: 0,
},
Layanan: []repository.LabService{},
},
}}
handler := Handler{labRepo: repo}
body := `{
"medrec_id":"00001234",
"tanggal":"2026-08-18",
"departemen_id":"LAB",
"dokter_id":"DR01",
"perujuk":" Klinik A ",
"jenis_pasien_id":"0",
"periode_tarif_id":"10",
"kelas_id":"",
"diagnosa_kerja":"Pemeriksaan laboratorium",
"catatan":" catatan ",
"channel":" cpone-api "
}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(body))
recorder := httptest.NewRecorder()
handler.RegisterLab(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d, want %d; body=%s", recorder.Code, http.StatusCreated, recorder.Body.String())
}
const expected = `{"success":true,"status":"registered","message":"Registrasi laboratorium berhasil disimpan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":0},"layanan":[]}}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}
if repo.params.MedrecID != "00001234" || repo.params.DepartmentID != "LAB" || repo.params.DoctorID != "DR01" {
t.Fatalf("unexpected repository params: %+v", repo.params)
}
if repo.params.TariffPeriodID != 10 || repo.params.ClassID != "Default" || repo.params.Channel != "CPONE-API" {
t.Fatalf("normalization mismatch: %+v", repo.params)
}
if repo.params.Referrer != " Klinik A " || repo.params.Notes != " catatan " {
t.Fatalf("handler must preserve values for repository trimming: %+v", repo.params)
}
}
func TestRegisterLabValidationParity(t *testing.T) {
baseline := map[string]any{
"medrec_id": "00001234",
"departemen_id": "LAB",
"dokter_id": "DR01",
"dokter_pengirim_id": "DR02",
"periode_tarif_id": 10,
"diagnosa_kerja": "Pemeriksaan laboratorium",
}
tests := []struct {
name string
mutate func(map[string]any)
message string
}{
{
name: "missing medrec",
mutate: func(payload map[string]any) { delete(payload, "medrec_id") },
message: "Medrec ID wajib diisi",
},
{
name: "invalid date",
mutate: func(payload map[string]any) { payload["tanggal"] = "18-08-2026" },
message: "Tanggal registrasi harus format Y-m-d",
},
{
name: "missing sender and referrer",
mutate: func(payload map[string]any) {
delete(payload, "dokter_pengirim_id")
delete(payload, "perujuk")
},
message: "Dokter pengirim atau perujuk wajib diisi",
},
{
name: "non-general patient",
mutate: func(payload map[string]any) { payload["jenis_pasien_id"] = "1" },
message: "Registrasi laboratorium API saat ini hanya mendukung pasien umum",
},
{
name: "invalid tariff period type",
mutate: func(payload map[string]any) { payload["periode_tarif_id"] = "1.5" },
message: "The periode tarif id must be an integer.",
},
{
name: "short diagnosis",
mutate: func(payload map[string]any) { payload["diagnosa_kerja"] = "AB" },
message: "Diagnosa kerja minimal 3 karakter",
},
{
name: "repeated diagnosis",
mutate: func(payload map[string]any) { payload["diagnosa_kerja"] = "---" },
message: "Diagnosa kerja tidak valid",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
payload := make(map[string]any, len(baseline))
for key, value := range baseline {
payload[key] = value
}
test.mutate(payload)
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
handler := Handler{labRepo: &fakeLabRegistrationRepository{}}
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(string(body)))
recorder := httptest.NewRecorder()
handler.RegisterLab(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
expected := `{"success":false,"status":"validation_error","message":` + strconvQuote(test.message) + `,"data":null}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}
})
}
}
func TestRegisterLabRepositoryErrorsMatchLaravelEnvelope(t *testing.T) {
tests := []struct {
name string
err error
statusCode int
status string
message string
}{
{
name: "domain validation",
err: repository.ValidationError{Message: "Departemen tujuan bukan departemen laboratorium aktif"},
statusCode: http.StatusUnprocessableEntity,
status: "validation_error",
message: "Departemen tujuan bukan departemen laboratorium aktif",
},
{
name: "database error",
err: errors.New("database unavailable"),
statusCode: http.StatusInternalServerError,
status: "error",
message: "database unavailable",
},
}
const validBody = `{"medrec_id":"00001234","departemen_id":"LAB","dokter_id":"DR01","dokter_pengirim_id":"DR02","periode_tarif_id":10,"diagnosa_kerja":"Pemeriksaan laboratorium"}`
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
handler := Handler{labRepo: &fakeLabRegistrationRepository{err: test.err}}
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration", strings.NewReader(validBody))
recorder := httptest.NewRecorder()
handler.RegisterLab(recorder, request)
if recorder.Code != test.statusCode {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
expected := `{"success":false,"status":` + strconvQuote(test.status) + `,"message":` + strconvQuote(test.message) + `,"data":null}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}
})
}
}
func strconvQuote(value string) string {
encoded, _ := json.Marshal(value)
return string(encoded)
}

View File

@@ -0,0 +1,215 @@
package http
import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
"unicode/utf8"
"primaya-api/cpone-middleware/internal/repository"
)
func (h Handler) AddLabServices(w http.ResponseWriter, r *http.Request) {
payload, _ := decodeJSONObject(r)
params, validationMessage := validateAddLabServices(payload)
if validationMessage != "" {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationMessage, nil)
return
}
outcome, err := h.labRepo.AddLabServices(r.Context(), r.PathValue("regId"), params)
if err != nil {
if validationErr, ok := asValidationError(err); ok {
writeLabResponse(w, http.StatusUnprocessableEntity, false, "validation_error", validationErr, nil)
return
}
writeLabResponse(w, http.StatusInternalServerError, false, "error", err.Error(), nil)
return
}
writeLabResponse(w, outcome.HTTPStatus, outcome.Success, outcome.Status, outcome.Message, outcome.Data)
}
func validateAddLabServices(payload map[string]any) (repository.AddLabServicesParams, string) {
value, exists := payload["layanan"]
if !exists || isLaravelEmpty(value) {
return repository.AddLabServicesParams{}, "Minimal satu layanan wajib diisi"
}
items, ok := value.([]any)
if !ok {
return repository.AddLabServicesParams{}, "The layanan must be an array."
}
if len(items) == 0 {
return repository.AddLabServicesParams{}, "Minimal satu layanan wajib diisi"
}
services := make([]repository.RequestedLabService, len(items))
seenIDs := make(map[string]struct{}, len(items))
for index, rawItem := range items {
item, _ := rawItem.(map[string]any)
serviceID, message := requiredNestedString(item, "layanan_id", index, 50, "Layanan ID wajib diisi")
if message != "" {
return repository.AddLabServicesParams{}, message
}
if _, duplicate := seenIDs[serviceID]; duplicate {
return repository.AddLabServicesParams{}, "Layanan yang sama tidak boleh dikirim lebih dari satu kali"
}
seenIDs[serviceID] = struct{}{}
services[index].LayananID = serviceID
}
for index, rawItem := range items {
item, _ := rawItem.(map[string]any)
services[index].Jumlah = 1
if value, present := nullableValue(item, "jumlah"); present {
quantity, valid := parseInteger(value)
if !valid {
return repository.AddLabServicesParams{}, nestedMessage(index, "jumlah", "must be an integer")
}
if quantity < 1 {
return repository.AddLabServicesParams{}, nestedMessage(index, "jumlah", "must be at least 1")
}
if quantity > 100 {
return repository.AddLabServicesParams{}, nestedMessage(index, "jumlah", "must not be greater than 100")
}
services[index].Jumlah = int(quantity)
}
}
for index, rawItem := range items {
item, _ := rawItem.(map[string]any)
if value, present := nullableValue(item, "cito"); present {
cito, valid := parseLaravelBoolean(value)
if !valid {
return repository.AddLabServicesParams{}, nestedMessage(index, "cito", "field must be true or false")
}
services[index].Cito = cito
}
}
for index, rawItem := range items {
item, _ := rawItem.(map[string]any)
if value, present := nullableValue(item, "markup_cito"); present {
markup, valid := parseNumeric(value)
if !valid {
return repository.AddLabServicesParams{}, nestedMessage(index, "markup_cito", "must be a number")
}
if markup < 0 {
return repository.AddLabServicesParams{}, nestedMessage(index, "markup_cito", "must be at least 0")
}
if markup > 100 {
return repository.AddLabServicesParams{}, nestedMessage(index, "markup_cito", "must not be greater than 100")
}
services[index].MarkupCito = markup
}
if !services[index].Cito {
services[index].MarkupCito = 0
}
}
for index, rawItem := range items {
item, _ := rawItem.(map[string]any)
notes, _, message := optionalNestedString(item, "catatan", index, 500)
if message != "" {
return repository.AddLabServicesParams{}, message
}
services[index].Catatan = notes
}
channel, channelPresent, message := optionalString(payload, "channel", 50)
if message != "" {
return repository.AddLabServicesParams{}, message
}
if !channelPresent {
channel = "CPONE"
}
channel = strings.ToUpper(strings.TrimSpace(channel))
return repository.AddLabServicesParams{Layanan: services, Channel: channel}, ""
}
func requiredNestedString(item map[string]any, key string, index int, maxLength int, requiredMessage string) (string, string) {
value, exists := item[key]
if !exists || isLaravelEmpty(value) {
return "", requiredMessage
}
text, ok := value.(string)
if !ok {
return "", nestedMessage(index, key, "must be a string")
}
if utf8.RuneCountInString(text) > maxLength {
return "", nestedMessage(index, key, fmt.Sprintf("must not be greater than %d characters", maxLength))
}
return text, ""
}
func optionalNestedString(item map[string]any, key string, index int, maxLength int) (string, bool, string) {
value, present := nullableValue(item, key)
if !present {
return "", false, ""
}
text, ok := value.(string)
if !ok {
return "", false, nestedMessage(index, key, "must be a string")
}
if utf8.RuneCountInString(text) > maxLength {
return "", false, nestedMessage(index, key, fmt.Sprintf("must not be greater than %d characters", maxLength))
}
return text, true, ""
}
func nestedMessage(index int, key string, ruleMessage string) string {
attribute := fmt.Sprintf("layanan.%d.%s", index, strings.ReplaceAll(key, "_", " "))
if strings.HasPrefix(ruleMessage, "field ") {
return fmt.Sprintf("The %s %s.", attribute, ruleMessage)
}
return fmt.Sprintf("The %s %s.", attribute, ruleMessage)
}
func parseLaravelBoolean(value any) (bool, bool) {
switch typed := value.(type) {
case bool:
return typed, true
case json.Number:
if typed.String() == "1" {
return true, true
}
if typed.String() == "0" {
return false, true
}
case string:
if typed == "1" {
return true, true
}
if typed == "0" {
return false, true
}
}
return false, false
}
func parseNumeric(value any) (float64, bool) {
var text string
switch typed := value.(type) {
case json.Number:
text = typed.String()
case string:
text = strings.TrimSpace(typed)
default:
return 0, false
}
parsed, err := strconv.ParseFloat(text, 64)
return parsed, err == nil && !math.IsInf(parsed, 0) && !math.IsNaN(parsed)
}
func asValidationError(err error) (string, bool) {
var validationErr repository.ValidationError
if errors.As(err, &validationErr) {
return validationErr.Message, true
}
return "", false
}

View File

@@ -0,0 +1,168 @@
package http
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"primaya-api/cpone-middleware/internal/repository"
)
func TestAddLabServicesValidPayloadAndResponseParity(t *testing.T) {
doctorID := "DR01"
registrationType := int64(3)
added := []string{"6413"}
skipped := []string{"6414"}
repo := &fakeLabRegistrationRepository{outcome: repository.LabRegistrationOutcome{
HTTPStatus: http.StatusCreated,
Success: true,
Status: "services_added",
Message: "Layanan laboratorium berhasil ditambahkan",
Data: &repository.LabRegistrationData{
Registration: repository.LabRegistration{
RegID: "CPLAB2608180001",
MEDRECID: "00001234",
Tanggal: "2026-08-18 10:11:12",
DepartemenID: "LAB",
DokterID: &doctorID,
JenisPasienID: 0,
JenisRegID: &registrationType,
PeriodeTarifID: 10,
},
Transaction: &repository.LabTransaction{
TrxDepartemenID: 99,
TrxID: "CPLAB2608180001",
GrandTotal: 125000,
},
Layanan: []repository.LabService{},
AddedLayananIDs: &added,
SkippedLayananIDs: &skipped,
},
}}
handler := Handler{labRepo: repo}
body := `{
"layanan":[
{"layanan_id":"6413","jumlah":"2","cito":true,"markup_cito":"10.5","catatan":" urgent "},
{"layanan_id":"6414","cito":"0"}
],
"channel":" cpone-api "
}`
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration/CPLAB2608180001/services", strings.NewReader(body))
request.SetPathValue("regId", "CPLAB2608180001")
recorder := httptest.NewRecorder()
handler.AddLabServices(recorder, request)
if recorder.Code != http.StatusCreated {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
const expected = `{"success":true,"status":"services_added","message":"Layanan laboratorium berhasil ditambahkan","data":{"registration":{"RegID":"CPLAB2608180001","MEDRECID":"00001234","Tanggal":"2026-08-18 10:11:12","DepartemenID":"LAB","DokterID":"DR01","JenisPasienID":0,"JenisRegID":3,"PeriodeTarifID":10},"transaction":{"TrxDepartemenID":99,"TrxID":"CPLAB2608180001","GrandTotal":125000},"layanan":[],"added_layanan_ids":["6413"],"skipped_layanan_ids":["6414"]}}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}
if repo.regID != "CPLAB2608180001" || repo.addParams.Channel != "CPONE-API" {
t.Fatalf("unexpected repository call: regID=%q params=%+v", repo.regID, repo.addParams)
}
if len(repo.addParams.Layanan) != 2 {
t.Fatalf("services count = %d", len(repo.addParams.Layanan))
}
first := repo.addParams.Layanan[0]
if first.LayananID != "6413" || first.Jumlah != 2 || !first.Cito || first.MarkupCito != 10.5 || first.Catatan != " urgent " {
t.Fatalf("unexpected first service: %+v", first)
}
second := repo.addParams.Layanan[1]
if second.Jumlah != 1 || second.Cito || second.MarkupCito != 0 {
t.Fatalf("unexpected defaults: %+v", second)
}
}
func TestAddLabServicesValidationParity(t *testing.T) {
baseline := map[string]any{
"layanan": []any{
map[string]any{"layanan_id": "6413"},
},
}
tests := []struct {
name string
mutate func(map[string]any)
message string
}{
{
name: "missing services",
mutate: func(payload map[string]any) { delete(payload, "layanan") },
message: "Minimal satu layanan wajib diisi",
},
{
name: "not an array",
mutate: func(payload map[string]any) { payload["layanan"] = "6413" },
message: "The layanan must be an array.",
},
{
name: "missing service id",
mutate: func(payload map[string]any) {
payload["layanan"] = []any{map[string]any{}}
},
message: "Layanan ID wajib diisi",
},
{
name: "duplicate service",
mutate: func(payload map[string]any) {
payload["layanan"] = []any{
map[string]any{"layanan_id": "6413"},
map[string]any{"layanan_id": "6413"},
}
},
message: "Layanan yang sama tidak boleh dikirim lebih dari satu kali",
},
{
name: "invalid quantity",
mutate: func(payload map[string]any) {
payload["layanan"] = []any{map[string]any{"layanan_id": "6413", "jumlah": 0}}
},
message: "The layanan.0.jumlah must be at least 1.",
},
{
name: "invalid cito",
mutate: func(payload map[string]any) {
payload["layanan"] = []any{map[string]any{"layanan_id": "6413", "cito": "true"}}
},
message: "The layanan.0.cito field must be true or false.",
},
{
name: "invalid markup",
mutate: func(payload map[string]any) {
payload["layanan"] = []any{map[string]any{"layanan_id": "6413", "markup_cito": 101}}
},
message: "The layanan.0.markup cito must not be greater than 100.",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
payload := make(map[string]any, len(baseline))
for key, value := range baseline {
payload[key] = value
}
test.mutate(payload)
body, err := json.Marshal(payload)
if err != nil {
t.Fatal(err)
}
handler := Handler{labRepo: &fakeLabRegistrationRepository{}}
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/lab-registration/CPLAB2608180001/services", strings.NewReader(string(body)))
request.SetPathValue("regId", "CPLAB2608180001")
recorder := httptest.NewRecorder()
handler.AddLabServices(recorder, request)
if recorder.Code != http.StatusUnprocessableEntity {
t.Fatalf("status = %d; body=%s", recorder.Code, recorder.Body.String())
}
expected := `{"success":false,"status":"validation_error","message":` + strconvQuote(test.message) + `,"data":null}` + "\n"
if recorder.Body.String() != expected {
t.Fatalf("body mismatch\n got: %s\nwant: %s", recorder.Body.String(), expected)
}
})
}
}

View File

@@ -18,7 +18,7 @@ type MappingHargaData struct {
DetailLayanan []repository.TarifLayanan `json:"DetailLayanan"`
}
func WriteJSON(w http.ResponseWriter, statusCode int, payload Response) {
func WriteJSON(w http.ResponseWriter, statusCode int, payload any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
_ = json.NewEncoder(w).Encode(payload)