refactor: use static HIS database per instance
This commit is contained in:
@@ -1,72 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
)
|
||||
|
||||
type DatabaseSettingsStore interface {
|
||||
List() []databaseconfig.PublicSetting
|
||||
Get(code string) (databaseconfig.PublicSetting, bool)
|
||||
Upsert(ctx context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error)
|
||||
}
|
||||
|
||||
type DatabaseSettingsHandler struct{ store DatabaseSettingsStore }
|
||||
|
||||
func NewDatabaseSettingsHandler(store DatabaseSettingsStore) DatabaseSettingsHandler {
|
||||
return DatabaseSettingsHandler{store: store}
|
||||
}
|
||||
|
||||
func (h DatabaseSettingsHandler) List(w http.ResponseWriter, _ *http.Request) {
|
||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: h.store.List()})
|
||||
}
|
||||
|
||||
func (h DatabaseSettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
setting, found := h.store.Get(r.PathValue("kodeRs"))
|
||||
if !found {
|
||||
WriteJSON(w, http.StatusNotFound, Response{Success: false, Message: "Setting database tidak ditemukan", Data: nil})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: setting})
|
||||
}
|
||||
|
||||
func (h DatabaseSettingsHandler) Store(w http.ResponseWriter, r *http.Request) {
|
||||
var payload databaseconfig.Setting
|
||||
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&payload); err != nil {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: "Payload JSON tidak valid: " + err.Error(), Data: nil})
|
||||
return
|
||||
}
|
||||
if err := databaseconfig.Validate(payload); err != nil {
|
||||
WriteJSON(w, http.StatusUnprocessableEntity, Response{Success: false, Message: err.Error(), Data: nil})
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
setting, created, err := h.store.Upsert(ctx, payload)
|
||||
if err != nil {
|
||||
status := http.StatusInternalServerError
|
||||
message := "Gagal menyimpan setting database"
|
||||
if errors.Is(err, context.DeadlineExceeded) || strings.Contains(err.Error(), "koneksi database") {
|
||||
status = http.StatusBadGateway
|
||||
message = err.Error()
|
||||
}
|
||||
WriteJSON(w, status, Response{Success: false, Message: message, Data: nil})
|
||||
return
|
||||
}
|
||||
status := http.StatusOK
|
||||
message := "Setting database berhasil diperbarui"
|
||||
if created {
|
||||
status = http.StatusCreated
|
||||
message = "Setting database berhasil ditambahkan"
|
||||
}
|
||||
WriteJSON(w, status, Response{Success: true, Message: message, Data: setting})
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
)
|
||||
|
||||
type fakeDatabaseSettingsStore struct {
|
||||
setting databaseconfig.Setting
|
||||
}
|
||||
|
||||
func (f *fakeDatabaseSettingsStore) List() []databaseconfig.PublicSetting { return nil }
|
||||
func (f *fakeDatabaseSettingsStore) Get(string) (databaseconfig.PublicSetting, bool) {
|
||||
return databaseconfig.PublicSetting{}, false
|
||||
}
|
||||
func (f *fakeDatabaseSettingsStore) Upsert(_ context.Context, setting databaseconfig.Setting) (databaseconfig.PublicSetting, bool, error) {
|
||||
f.setting = setting
|
||||
return databaseconfig.PublicSetting{
|
||||
RSCode: setting.RSCode, Host: setting.Host, Port: setting.Port,
|
||||
Database: setting.Database, Username: setting.Username, HasPassword: setting.Password != "",
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func TestStoreDatabaseSettingDoesNotExposePassword(t *testing.T) {
|
||||
store := &fakeDatabaseSettingsStore{}
|
||||
handler := NewDatabaseSettingsHandler(store)
|
||||
body := `{"kode_rs":"RS_BEKASI","nama":"RS Bekasi","host":"10.0.0.2","port":"3306","database":"his","username":"cpone","password":"top-secret"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.Store(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if store.setting.Password != "top-secret" {
|
||||
t.Fatal("password was not passed to store")
|
||||
}
|
||||
if strings.Contains(recorder.Body.String(), "top-secret") || strings.Contains(recorder.Body.String(), `"password"`) {
|
||||
t.Fatalf("response exposes password: %s", recorder.Body.String())
|
||||
}
|
||||
if !strings.Contains(recorder.Body.String(), `"has_password":true`) {
|
||||
t.Fatalf("response missing has_password: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreDatabaseSettingValidation(t *testing.T) {
|
||||
handler := NewDatabaseSettingsHandler(&fakeDatabaseSettingsStore{})
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/database-settings", strings.NewReader(`{"kode_rs":"invalid code"}`))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.Store(recorder, request)
|
||||
if recorder.Code != http.StatusUnprocessableEntity {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ type Handler struct {
|
||||
periodeTarifRepo PeriodeTarifRepository
|
||||
periodeTarifDetailRepo PeriodeTarifDetailRepository
|
||||
perusahaanRepo PerusahaanRepository
|
||||
hospitalCode string
|
||||
}
|
||||
|
||||
type CPONERepository interface {
|
||||
@@ -87,7 +88,9 @@ func NewHandler(repo CPONERepository) Handler {
|
||||
}
|
||||
}
|
||||
|
||||
func NewDynamicHandler() Handler { return Handler{} }
|
||||
func NewDynamicHandler(hospitalCode string) Handler {
|
||||
return Handler{hospitalCode: strings.ToUpper(strings.TrimSpace(hospitalCode))}
|
||||
}
|
||||
|
||||
func (h Handler) layananRepository(ctx context.Context) LayananRepository {
|
||||
if repo, ok := selectedRepository(ctx); ok {
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
)
|
||||
|
||||
type HospitalSummary struct {
|
||||
RSCode string `json:"kode_rs"`
|
||||
Name string `json:"nama"`
|
||||
Status bool `json:"status"`
|
||||
}
|
||||
|
||||
func (h DatabaseSettingsHandler) ListHospitals(w http.ResponseWriter, _ *http.Request) {
|
||||
settings := h.store.List()
|
||||
hospitals := make([]HospitalSummary, 0, len(settings))
|
||||
for _, setting := range settings {
|
||||
if !setting.Active {
|
||||
continue
|
||||
}
|
||||
hospitals = append(hospitals, hospitalSummary(setting))
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, Response{Success: true, Message: "ok", Data: hospitals})
|
||||
}
|
||||
|
||||
func hospitalSummary(setting databaseconfig.PublicSetting) HospitalSummary {
|
||||
return HospitalSummary{RSCode: setting.RSCode, Name: setting.Name, Status: setting.Active}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"primaya-api/cpone-middleware/internal/databaseconfig"
|
||||
)
|
||||
|
||||
type fakeHospitalStore struct {
|
||||
fakeDatabaseSettingsStore
|
||||
settings []databaseconfig.PublicSetting
|
||||
}
|
||||
|
||||
func (f *fakeHospitalStore) List() []databaseconfig.PublicSetting { return f.settings }
|
||||
|
||||
func TestListHospitalsOnlyReturnsActiveWithoutDatabaseCredentials(t *testing.T) {
|
||||
store := &fakeHospitalStore{settings: []databaseconfig.PublicSetting{
|
||||
{RSCode: "AWALBROS_DEV", Name: "RS Dev Awalbros", Host: "secret-host", Username: "secret-user", Active: true},
|
||||
{RSCode: "INACTIVE_RS", Name: "RS Tidak Aktif", Host: "inactive-host", Active: false},
|
||||
}}
|
||||
handler := NewDatabaseSettingsHandler(store)
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListHospitals(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/hospitals", nil))
|
||||
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
for _, expected := range []string{`"kode_rs":"AWALBROS_DEV"`, `"nama":"RS Dev Awalbros"`, `"status":true`} {
|
||||
if !strings.Contains(recorder.Body.String(), expected) {
|
||||
t.Fatalf("response missing %s: %s", expected, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{"INACTIVE_RS", "RS Tidak Aktif", "secret-host", "secret-user", `"host"`, `"username"`, `"password"`} {
|
||||
if strings.Contains(recorder.Body.String(), forbidden) {
|
||||
t.Fatalf("response contains %q: %s", forbidden, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHospitalsReturnsEmptyArray(t *testing.T) {
|
||||
handler := NewDatabaseSettingsHandler(&fakeHospitalStore{})
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ListHospitals(recorder, httptest.NewRequest(http.MethodGet, "/api/cpone/hospitals", nil))
|
||||
if recorder.Body.String() != `{"success":true,"message":"ok","data":[]}`+"\n" {
|
||||
t.Fatalf("body=%s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,7 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
|
||||
writeMedrecResponse(w, http.StatusUnprocessableEntity, false, "validation_error", message, nil)
|
||||
return
|
||||
}
|
||||
params.HospitalCode = h.hospitalCode
|
||||
|
||||
outcome, err := h.patientRepository(r.Context()).StoreMedrec(r.Context(), params)
|
||||
if err != nil {
|
||||
@@ -48,10 +49,6 @@ func (h Handler) StoreMedrec(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||
rs, message := requiredString(payload, "rs", 20, "Kode RS wajib diisi")
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
}
|
||||
name, message := requiredString(payload, "nama", 255, "Nama pasien wajib diisi")
|
||||
if message != "" {
|
||||
return repository.MedrecParams{}, message
|
||||
@@ -191,7 +188,6 @@ func validateMedrec(payload map[string]any) (repository.MedrecParams, string) {
|
||||
}
|
||||
|
||||
return repository.MedrecParams{
|
||||
HospitalCode: normalizeText(rs),
|
||||
Name: normalizeName(name),
|
||||
Gender: normalizeText(gender),
|
||||
DateOfBirth: birthDate,
|
||||
|
||||
@@ -48,9 +48,8 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
||||
IsNewPatient: true,
|
||||
},
|
||||
}}
|
||||
handler := Handler{patientRepo: repo}
|
||||
handler := Handler{patientRepo: repo, hospitalCode: "PRIMAYA"}
|
||||
body := `{
|
||||
"rs":" primaya ",
|
||||
"nama":" Budi Santos ",
|
||||
"jenis_kelamin":"l",
|
||||
"tgl_lahir":"1990-01-15",
|
||||
@@ -107,7 +106,6 @@ func TestStoreMedrecValidPayloadAndCreatedResponseParity(t *testing.T) {
|
||||
|
||||
func TestStoreMedrecValidationParity(t *testing.T) {
|
||||
baseline := map[string]any{
|
||||
"rs": "PRIMAYA",
|
||||
"nama": "BUDI SANTOS",
|
||||
"jenis_kelamin": "L",
|
||||
"tgl_lahir": "1990-01-15",
|
||||
@@ -122,7 +120,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
|
||||
mutate func(map[string]any)
|
||||
message string
|
||||
}{
|
||||
{name: "missing rs", mutate: func(p map[string]any) { delete(p, "rs") }, message: "Kode RS wajib diisi"},
|
||||
{name: "missing name", mutate: func(p map[string]any) { delete(p, "nama") }, message: "Nama pasien wajib diisi"},
|
||||
{name: "invalid gender", mutate: func(p map[string]any) { p["jenis_kelamin"] = "M" }, message: "Jenis kelamin harus L atau P"},
|
||||
{name: "invalid date format", mutate: func(p map[string]any) { p["tgl_lahir"] = "15-01-1990" }, message: "Tanggal lahir harus format Y-m-d"},
|
||||
@@ -131,7 +128,6 @@ func TestStoreMedrecValidationParity(t *testing.T) {
|
||||
{name: "invalid ktp", mutate: func(p map[string]any) { p["nomer_sosial"] = "123" }, message: "NIK/KTP harus 16 digit angka"},
|
||||
{name: "missing phone", mutate: func(p map[string]any) { delete(p, "telepon") }, message: "Telepon atau ponsel wajib diisi"},
|
||||
{name: "invalid email", mutate: func(p map[string]any) { p["email"] = "invalid" }, message: "The email must be a valid email address."},
|
||||
{name: "long hospital code", mutate: func(p map[string]any) { p["rs"] = strings.Repeat("X", 21) }, message: "The rs must not be greater than 20 characters."},
|
||||
{name: "long dependant id", mutate: func(p map[string]any) { p["dependant_id"] = "D0001" }, message: "The dependant id must not be greater than 4 characters."},
|
||||
}
|
||||
|
||||
@@ -172,7 +168,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
|
||||
Data: data,
|
||||
}}
|
||||
handler := Handler{patientRepo: repo}
|
||||
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
|
||||
body := `{"nama":"BUDI","jenis_kelamin":"P","tgl_lahir":"1990-01-15","nik":"3174011501900001","ponsel":"08123"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.StoreMedrec(recorder, request)
|
||||
@@ -184,7 +180,7 @@ func TestStoreMedrecNikFallbackAndOutcomeForwarding(t *testing.T) {
|
||||
|
||||
func TestStoreMedrecRepositoryErrorParity(t *testing.T) {
|
||||
handler := Handler{patientRepo: &fakePatientRepository{err: errors.New("database unavailable")}}
|
||||
body := `{"rs":"PRIMAYA","nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
|
||||
body := `{"nama":"BUDI","jenis_kelamin":"L","tgl_lahir":"1990-01-15","nomer_sosial":"3174011501900001","telepon":"08123"}`
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/cpone/patients/medrec", strings.NewReader(body))
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.StoreMedrec(recorder, request)
|
||||
|
||||
@@ -46,13 +46,13 @@ func SelectDatabase(resolver DatabaseResolver, next http.Handler) http.Handler {
|
||||
|
||||
repo, err := resolver.Resolve(r.Context(), code)
|
||||
if err != nil {
|
||||
if errors.Is(err, databaseconfig.ErrSettingNotFound) {
|
||||
WriteJSON(w, http.StatusNotFound, Response{Success: false,
|
||||
Message: "Setting database untuk kode RS " + code + " tidak ditemukan", Data: nil})
|
||||
if errors.Is(err, databaseconfig.ErrRSCodeMismatch) {
|
||||
WriteJSON(w, http.StatusForbidden, Response{Success: false,
|
||||
Message: "X-RS-Code tidak sesuai dengan instance CPONE", Data: nil})
|
||||
return
|
||||
}
|
||||
WriteJSON(w, http.StatusServiceUnavailable, Response{Success: false,
|
||||
Message: "Database untuk kode RS " + code + " tidak dapat diakses", Data: nil})
|
||||
Message: "Database HIS tidak dapat diakses", Data: nil})
|
||||
return
|
||||
}
|
||||
w.Header().Set("X-RS-Code", code)
|
||||
|
||||
@@ -66,14 +66,14 @@ func TestSelectDatabaseRequiresRSCode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDatabaseUnknownCode(t *testing.T) {
|
||||
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrSettingNotFound}
|
||||
func TestSelectDatabaseRejectsWrongInstanceCode(t *testing.T) {
|
||||
resolver := &fakeDatabaseResolver{err: databaseconfig.ErrRSCodeMismatch}
|
||||
request := httptest.NewRequest(http.MethodGet, "/resource?kode_rs=unknown", nil)
|
||||
recorder := httptest.NewRecorder()
|
||||
SelectDatabase(resolver, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||
t.Fatal("next handler should not be called")
|
||||
})).ServeHTTP(recorder, request)
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
if recorder.Code != http.StatusForbidden {
|
||||
t.Fatalf("status = %d, body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user