feat(pipeline): 实现健康档案 upsert 编排(T-213)
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"chis_osi/contract"
|
||||
"chis_osi/mapping"
|
||||
"chis_osi/osi"
|
||||
"chis_osi/source"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusDone Status = "done"
|
||||
StatusRetry Status = "retry"
|
||||
StatusFailed Status = "failed"
|
||||
StatusManualReview Status = "manual_review"
|
||||
)
|
||||
|
||||
type Action string
|
||||
|
||||
const (
|
||||
ActionNone Action = "none"
|
||||
ActionCreate Action = "create"
|
||||
ActionUpdate Action = "update"
|
||||
ActionSkip Action = "skip"
|
||||
)
|
||||
|
||||
type Outcome struct {
|
||||
Status Status
|
||||
Action Action
|
||||
Reason string
|
||||
ServiceID string
|
||||
ResponseCode string
|
||||
PHRIDHint string
|
||||
}
|
||||
|
||||
type UpsertEvent struct {
|
||||
TraceID string
|
||||
Status Status
|
||||
Action Action
|
||||
Reason string
|
||||
ServiceID string
|
||||
ResponseCode string
|
||||
PHRIDHint string
|
||||
}
|
||||
|
||||
type PHISStatusUpdate struct {
|
||||
SourceRecordID string
|
||||
BusinessID string
|
||||
Status Status
|
||||
Reason string
|
||||
}
|
||||
|
||||
type UpdateTarget struct {
|
||||
PHRID string
|
||||
EMPIID string
|
||||
CheckID string
|
||||
IDCard string
|
||||
ManaUnitID string
|
||||
ManaDoctorID string
|
||||
Status string
|
||||
}
|
||||
|
||||
type Converter interface {
|
||||
ConvertHealthRecord(source.HealthRecordTask) (contract.HealthRecordCreate, []mapping.ValidationError)
|
||||
}
|
||||
|
||||
type ConverterFunc func(source.HealthRecordTask) (contract.HealthRecordCreate, []mapping.ValidationError)
|
||||
|
||||
func (f ConverterFunc) ConvertHealthRecord(task source.HealthRecordTask) (contract.HealthRecordCreate, []mapping.ValidationError) {
|
||||
return f(task)
|
||||
}
|
||||
|
||||
type HealthRecordClient interface {
|
||||
FindHealthRecord(context.Context, osi.FindHealthRecordQuery) ([]contract.FindHealthRecord, osi.Result, error)
|
||||
CreateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error)
|
||||
UpdateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error)
|
||||
}
|
||||
|
||||
type IdempotencyStore interface {
|
||||
Acquire(context.Context, string) (IdempotencyLease, error)
|
||||
Complete(context.Context, string, string) error
|
||||
Release(context.Context, string, string) error
|
||||
}
|
||||
|
||||
type IdempotencyState string
|
||||
|
||||
const (
|
||||
IdempotencyAcquired IdempotencyState = "acquired"
|
||||
IdempotencyCompleted IdempotencyState = "completed"
|
||||
IdempotencyInProgress IdempotencyState = "in_progress"
|
||||
)
|
||||
|
||||
type IdempotencyLease struct {
|
||||
State IdempotencyState
|
||||
Token string
|
||||
}
|
||||
|
||||
type NotificationError struct {
|
||||
Event *UpsertEvent
|
||||
Update *PHISStatusUpdate
|
||||
PriorErr error
|
||||
ReportErr error
|
||||
StatusErr error
|
||||
}
|
||||
|
||||
func (e *NotificationError) Error() string {
|
||||
return "finish upsert side effects: " + e.Unwrap().Error()
|
||||
}
|
||||
func (e *NotificationError) Unwrap() error { return errors.Join(e.PriorErr, e.ReportErr, e.StatusErr) }
|
||||
|
||||
type ReportSink interface {
|
||||
Publish(context.Context, UpsertEvent) error
|
||||
}
|
||||
|
||||
type PHISStatusWriter interface {
|
||||
WriteStatus(context.Context, PHISStatusUpdate) error
|
||||
}
|
||||
|
||||
type Dependencies struct {
|
||||
Converter Converter
|
||||
Client HealthRecordClient
|
||||
Idempotency IdempotencyStore
|
||||
Reports ReportSink
|
||||
PHISStatuses PHISStatusWriter
|
||||
}
|
||||
|
||||
type HealthRecordUpsertService struct {
|
||||
deps Dependencies
|
||||
}
|
||||
|
||||
func NewHealthRecordUpsertService(deps Dependencies) *HealthRecordUpsertService {
|
||||
return &HealthRecordUpsertService{deps: deps}
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) Upsert(ctx context.Context, task source.HealthRecordTask) (Outcome, error) {
|
||||
if err := s.validateDependencies(); err != nil {
|
||||
return Outcome{}, err
|
||||
}
|
||||
|
||||
req, validationErrors := s.deps.Converter.ConvertHealthRecord(task)
|
||||
if len(validationErrors) > 0 {
|
||||
return s.finish(ctx, task, Outcome{Status: StatusFailed, Action: ActionNone, Reason: "mapping validation failed: " + validationErrors[0].Field})
|
||||
}
|
||||
|
||||
idempotencyKey := healthRecordIdempotencyKey(req.HealthRecord.CheckID, req.HealthRecord.IDCard)
|
||||
lease, err := s.deps.Idempotency.Acquire(ctx, idempotencyKey)
|
||||
if err != nil {
|
||||
return s.finish(ctx, task, Outcome{Status: StatusRetry, Action: ActionNone, Reason: "idempotency lookup failed"}, err)
|
||||
}
|
||||
if lease.State == IdempotencyCompleted {
|
||||
return s.finish(ctx, task, Outcome{Status: StatusDone, Action: ActionSkip, Reason: "already completed"})
|
||||
}
|
||||
if lease.State == IdempotencyInProgress {
|
||||
return s.finish(ctx, task, Outcome{Status: StatusRetry, Action: ActionSkip, Reason: "another delivery is in progress"})
|
||||
}
|
||||
if lease.State != IdempotencyAcquired || strings.TrimSpace(lease.Token) == "" {
|
||||
return s.finish(ctx, task, Outcome{Status: StatusRetry, Action: ActionNone, Reason: "invalid idempotency lease"})
|
||||
}
|
||||
|
||||
records, queryResult, err := s.deps.Client.FindHealthRecord(ctx, osi.FindHealthRecordQuery{IDCard: req.HealthRecord.IDCard})
|
||||
if err != nil || !queryResult.Success {
|
||||
status := classifyFailure(queryResult)
|
||||
return s.finishAcquired(ctx, task, idempotencyKey, lease.Token, Outcome{Status: status, Action: ActionNone, Reason: "CHIS query failed", ServiceID: osi.ServiceIDJKDAFind, ResponseCode: queryResult.Code})
|
||||
}
|
||||
if records == nil {
|
||||
return s.finishAcquired(ctx, task, idempotencyKey, lease.Token, Outcome{Status: StatusFailed, Action: ActionNone, Reason: "CHIS query returned null or missing data", ServiceID: osi.ServiceIDJKDAFind, ResponseCode: queryResult.Code})
|
||||
}
|
||||
|
||||
if len(records) > 1 {
|
||||
return s.finishAcquired(ctx, task, idempotencyKey, lease.Token, Outcome{Status: StatusManualReview, Action: ActionNone, Reason: "multiple CHIS health records found", ServiceID: osi.ServiceIDJKDAFind, ResponseCode: queryResult.Code})
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return s.create(ctx, task, idempotencyKey, lease.Token, req)
|
||||
}
|
||||
|
||||
target := newUpdateTarget(records[0].HealthRecord)
|
||||
if reason := validateUpdateTarget(target, req); reason != "" {
|
||||
return s.finishAcquired(ctx, task, idempotencyKey, lease.Token, Outcome{Status: StatusManualReview, Action: ActionNone, Reason: reason, ServiceID: osi.ServiceIDJKDAFind, ResponseCode: queryResult.Code, PHRIDHint: maskIdentifier(target.PHRID)})
|
||||
}
|
||||
req.BaseInfo.PHRID = target.PHRID
|
||||
req.HealthRecord.PhrID = target.PHRID
|
||||
return s.update(ctx, task, idempotencyKey, lease.Token, req)
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) create(ctx context.Context, task source.HealthRecordTask, key, token string, req contract.HealthRecordCreate) (Outcome, error) {
|
||||
saved, result, err := s.deps.Client.CreateHealthRecord(ctx, req)
|
||||
outcome := Outcome{Status: StatusDone, Action: ActionCreate, ServiceID: osi.ServiceIDJKDACreate, ResponseCode: result.Code, PHRIDHint: maskIdentifier(saved.PhrID)}
|
||||
if err != nil || !result.Success {
|
||||
outcome.Status = classifyFailure(result)
|
||||
outcome.Reason = "CHIS create failed"
|
||||
return s.finishAcquired(ctx, task, key, token, outcome)
|
||||
}
|
||||
return s.complete(ctx, task, key, token, outcome)
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) update(ctx context.Context, task source.HealthRecordTask, key, token string, req contract.HealthRecordCreate) (Outcome, error) {
|
||||
saved, result, err := s.deps.Client.UpdateHealthRecord(ctx, req)
|
||||
phrID := saved.PhrID
|
||||
if phrID == "" {
|
||||
phrID = req.HealthRecord.PhrID
|
||||
}
|
||||
outcome := Outcome{Status: StatusDone, Action: ActionUpdate, ServiceID: osi.ServiceIDJKDAUpdate, ResponseCode: result.Code, PHRIDHint: maskIdentifier(phrID)}
|
||||
if err != nil || !result.Success {
|
||||
outcome.Status = classifyFailure(result)
|
||||
outcome.Reason = "CHIS update failed"
|
||||
return s.finishAcquired(ctx, task, key, token, outcome)
|
||||
}
|
||||
return s.complete(ctx, task, key, token, outcome)
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) complete(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) {
|
||||
if err := s.deps.Idempotency.Complete(ctx, key, token); err != nil {
|
||||
return outcome, fmt.Errorf("persist successful CHIS write before notification: %w", err)
|
||||
}
|
||||
return s.finish(ctx, task, outcome)
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) finishAcquired(ctx context.Context, task source.HealthRecordTask, key, token string, outcome Outcome) (Outcome, error) {
|
||||
return s.finish(ctx, task, outcome, s.deps.Idempotency.Release(ctx, key, token))
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) finish(ctx context.Context, task source.HealthRecordTask, outcome Outcome, prior ...error) (Outcome, error) {
|
||||
event := UpsertEvent{
|
||||
TraceID: traceID(task.SourceRecordID(), task.BusinessID), Status: outcome.Status,
|
||||
Action: outcome.Action, Reason: outcome.Reason, ServiceID: outcome.ServiceID,
|
||||
ResponseCode: outcome.ResponseCode, PHRIDHint: outcome.PHRIDHint,
|
||||
}
|
||||
status := PHISStatusUpdate{SourceRecordID: task.SourceRecordID(), BusinessID: task.BusinessID, Status: outcome.Status, Reason: outcome.Reason}
|
||||
priorErr := errors.Join(prior...)
|
||||
reportErr := s.deps.Reports.Publish(ctx, event)
|
||||
statusErr := s.deps.PHISStatuses.WriteStatus(ctx, status)
|
||||
if priorErr != nil || reportErr != nil || statusErr != nil {
|
||||
notificationErr := &NotificationError{PriorErr: priorErr, ReportErr: reportErr, StatusErr: statusErr}
|
||||
if reportErr != nil {
|
||||
notificationErr.Event = &event
|
||||
}
|
||||
if statusErr != nil {
|
||||
notificationErr.Update = &status
|
||||
}
|
||||
return outcome, notificationErr
|
||||
}
|
||||
return outcome, nil
|
||||
}
|
||||
|
||||
func (s *HealthRecordUpsertService) validateDependencies() error {
|
||||
if s == nil || s.deps.Converter == nil || s.deps.Client == nil || s.deps.Idempotency == nil || s.deps.Reports == nil || s.deps.PHISStatuses == nil {
|
||||
return fmt.Errorf("health record upsert dependencies are incomplete")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func classifyFailure(result osi.Result) Status {
|
||||
if result.Retryable {
|
||||
return StatusRetry
|
||||
}
|
||||
return StatusFailed
|
||||
}
|
||||
|
||||
func newUpdateTarget(record contract.HealthRecord) UpdateTarget {
|
||||
checkID := ""
|
||||
if record.CheckID != nil {
|
||||
checkID = *record.CheckID
|
||||
}
|
||||
return UpdateTarget{
|
||||
PHRID: record.PhrID, EMPIID: record.EmpiID, CheckID: checkID, IDCard: record.IDCard,
|
||||
ManaUnitID: record.ManaUnitID, ManaDoctorID: record.ManaDoctorID, Status: record.Status,
|
||||
}
|
||||
}
|
||||
|
||||
func validateUpdateTarget(target UpdateTarget, req contract.HealthRecordCreate) string {
|
||||
if strings.TrimSpace(target.IDCard) != strings.TrimSpace(req.HealthRecord.IDCard) {
|
||||
return "CHIS identity does not exactly match source"
|
||||
}
|
||||
if strings.TrimSpace(target.PHRID) == "" {
|
||||
return "CHIS record is missing phrId"
|
||||
}
|
||||
if strings.TrimSpace(target.Status) != "1" {
|
||||
return "CHIS record status is not updateable"
|
||||
}
|
||||
if strings.TrimSpace(target.ManaUnitID) != strings.TrimSpace(req.HealthRecord.ManaUnitID) {
|
||||
return "CHIS record belongs to another organization"
|
||||
}
|
||||
if strings.TrimSpace(target.ManaDoctorID) != strings.TrimSpace(req.HealthRecord.ManaDoctorID) {
|
||||
return "CHIS record belongs to another doctor"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func healthRecordIdempotencyKey(checkID, idCard string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(checkID) + "|" + strings.TrimSpace(idCard)))
|
||||
return fmt.Sprintf("health-record:%x", sum[:])
|
||||
}
|
||||
|
||||
func traceID(sourceRecordID, businessID string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(sourceRecordID) + "|" + strings.TrimSpace(businessID)))
|
||||
return fmt.Sprintf("hr-%x", sum[:8])
|
||||
}
|
||||
|
||||
func maskIdentifier(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) <= 4 {
|
||||
return "****"
|
||||
}
|
||||
return "****" + value[len(value)-4:]
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"chis_osi/contract"
|
||||
"chis_osi/mapping"
|
||||
"chis_osi/osi"
|
||||
"chis_osi/source"
|
||||
)
|
||||
|
||||
func TestHealthRecordUpsertCreatesWhenQueryIsEmpty(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{rows: []contract.FindHealthRecord{}, findResult: osi.Result{Success: true}, saveResult: osi.Result{Success: true, Code: "01"}, saved: contract.HealthRecordSaveResult{PhrID: "PHR-NEW"}}
|
||||
store := &fakeIdempotencyStore{}
|
||||
reports := &fakeReportSink{}
|
||||
statuses := &fakeStatusWriter{}
|
||||
service := newTestService(client, store, reports, statuses)
|
||||
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusDone || outcome.Action != ActionCreate {
|
||||
t.Fatalf("outcome=%#v err=%v", outcome, err)
|
||||
}
|
||||
if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked {
|
||||
t.Fatalf("calls find=%d create=%d update=%d marked=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked)
|
||||
}
|
||||
assertNotifications(t, reports, statuses, StatusDone, ActionCreate)
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertUpdatesOneMatchingRecord(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{
|
||||
findResult: osi.Result{Success: true},
|
||||
rows: []contract.FindHealthRecord{{HealthRecord: matchingTarget()}},
|
||||
saveResult: osi.Result{Success: true, Code: "01"},
|
||||
saved: contract.HealthRecordSaveResult{PhrID: "PHR-EXISTING"},
|
||||
}
|
||||
service := newTestService(client, &fakeIdempotencyStore{}, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusDone || outcome.Action != ActionUpdate {
|
||||
t.Fatalf("outcome=%#v err=%v", outcome, err)
|
||||
}
|
||||
if client.updateCalls != 1 || client.createCalls != 0 {
|
||||
t.Fatalf("create=%d update=%d", client.createCalls, client.updateCalls)
|
||||
}
|
||||
if client.updateReq.BaseInfo.PHRID != "PHR-EXISTING" || client.updateReq.HealthRecord.PhrID != "PHR-EXISTING" {
|
||||
t.Fatalf("update phrId not merged: %#v", client.updateReq)
|
||||
}
|
||||
if client.updateReq.HealthRecord.CheckID != "CHECK-STABLE" {
|
||||
t.Fatalf("source checkId was overwritten: %q", client.updateReq.HealthRecord.CheckID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertRoutesUnsafeMatchesToManualReview(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []contract.FindHealthRecord
|
||||
}{
|
||||
{name: "multiple", rows: []contract.FindHealthRecord{{HealthRecord: matchingTarget()}, {HealthRecord: matchingTarget()}}},
|
||||
{name: "cross unit", rows: []contract.FindHealthRecord{{HealthRecord: targetWith(func(r *contract.HealthRecord) { r.ManaUnitID = "UNIT-OTHER" })}}},
|
||||
{name: "cross doctor", rows: []contract.FindHealthRecord{{HealthRecord: targetWith(func(r *contract.HealthRecord) { r.ManaDoctorID = "DOC-OTHER" })}}},
|
||||
{name: "inactive", rows: []contract.FindHealthRecord{{HealthRecord: targetWith(func(r *contract.HealthRecord) { r.Status = "0" })}}},
|
||||
{name: "id mismatch", rows: []contract.FindHealthRecord{{HealthRecord: targetWith(func(r *contract.HealthRecord) { r.IDCard = "440000********9999" })}}},
|
||||
{name: "missing phrId", rows: []contract.FindHealthRecord{{HealthRecord: targetWith(func(r *contract.HealthRecord) { r.PhrID = "" })}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{rows: tt.rows, findResult: osi.Result{Success: true}}
|
||||
service := newTestService(client, &fakeIdempotencyStore{}, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusManualReview || client.createCalls != 0 || client.updateCalls != 0 {
|
||||
t.Fatalf("outcome=%#v err=%v create=%d update=%d", outcome, err, client.createCalls, client.updateCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertNeverCreatesAfterQueryFailure(t *testing.T) {
|
||||
for _, retryable := range []bool{false, true} {
|
||||
client := &fakeHealthRecordClient{findResult: osi.Result{Retryable: retryable, Code: "405"}, findErr: errors.New("query failed")}
|
||||
store := &fakeIdempotencyStore{}
|
||||
service := newTestService(client, store, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
want := StatusFailed
|
||||
if retryable {
|
||||
want = StatusRetry
|
||||
}
|
||||
if err != nil || outcome.Status != want || client.createCalls != 0 || client.updateCalls != 0 || !store.released {
|
||||
t.Fatalf("retryable=%v outcome=%#v err=%v", retryable, outcome, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertNeverCreatesForNullQueryData(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{rows: nil, findResult: osi.Result{Success: true, Code: "01"}}
|
||||
service := newTestService(client, &fakeIdempotencyStore{}, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusFailed || client.createCalls != 0 {
|
||||
t.Fatalf("outcome=%#v err=%v create=%d", outcome, err, client.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertClassifiesWriteFailureWithoutFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rows []contract.FindHealthRecord
|
||||
retryable bool
|
||||
action Action
|
||||
}{
|
||||
{name: "create retry", rows: []contract.FindHealthRecord{}, retryable: true, action: ActionCreate},
|
||||
{name: "create fail", rows: []contract.FindHealthRecord{}, action: ActionCreate},
|
||||
{name: "update retry", rows: []contract.FindHealthRecord{{HealthRecord: matchingTarget()}}, retryable: true, action: ActionUpdate},
|
||||
{name: "update fail", rows: []contract.FindHealthRecord{{HealthRecord: matchingTarget()}}, action: ActionUpdate},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{rows: tt.rows, findResult: osi.Result{Success: true}, saveResult: osi.Result{Retryable: tt.retryable}, saveErr: errors.New("write failed")}
|
||||
service := newTestService(client, &fakeIdempotencyStore{}, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
want := StatusFailed
|
||||
if tt.retryable {
|
||||
want = StatusRetry
|
||||
}
|
||||
if err != nil || outcome.Status != want || outcome.Action != tt.action {
|
||||
t.Fatalf("outcome=%#v err=%v", outcome, err)
|
||||
}
|
||||
if client.createCalls+client.updateCalls != 1 {
|
||||
t.Fatalf("unexpected write fallback: create=%d update=%d", client.createCalls, client.updateCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertSkipsCompletedIdempotencyKey(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{}
|
||||
service := newTestService(client, &fakeIdempotencyStore{completed: true}, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusDone || outcome.Action != ActionSkip || client.findCalls != 0 {
|
||||
t.Fatalf("outcome=%#v err=%v find=%d", outcome, err, client.findCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertDoesNotRaceAnInProgressDelivery(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{}
|
||||
service := newTestService(client, &fakeIdempotencyStore{inProgress: true}, &fakeReportSink{}, &fakeStatusWriter{})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusRetry || outcome.Action != ActionSkip || client.findCalls != 0 {
|
||||
t.Fatalf("outcome=%#v err=%v find=%d", outcome, err, client.findCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertRejectsMappingErrorsBeforeOSI(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{}
|
||||
service := NewHealthRecordUpsertService(Dependencies{
|
||||
Converter: ConverterFunc(func(source.HealthRecordTask) (contract.HealthRecordCreate, []mapping.ValidationError) {
|
||||
return contract.HealthRecordCreate{}, []mapping.ValidationError{{Field: "idCard", Reason: "required"}}
|
||||
}),
|
||||
Client: client, Idempotency: &fakeIdempotencyStore{}, Reports: &fakeReportSink{}, PHISStatuses: &fakeStatusWriter{},
|
||||
})
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err != nil || outcome.Status != StatusFailed || client.findCalls != 0 {
|
||||
t.Fatalf("outcome=%#v err=%v find=%d", outcome, err, client.findCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertDoesNotNotifyDoneWhenCompletionFails(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{rows: []contract.FindHealthRecord{}, findResult: osi.Result{Success: true}, saveResult: osi.Result{Success: true}}
|
||||
store := &fakeIdempotencyStore{completeErr: errors.New("store unavailable")}
|
||||
reports := &fakeReportSink{}
|
||||
statuses := &fakeStatusWriter{}
|
||||
service := newTestService(client, store, reports, statuses)
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
if err == nil || outcome.Status != StatusDone || len(reports.events) != 0 || len(statuses.updates) != 0 {
|
||||
t.Fatalf("outcome=%#v err=%v reports=%#v statuses=%#v", outcome, err, reports.events, statuses.updates)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthRecordUpsertNotificationErrorOnlyCarriesFailedSink(t *testing.T) {
|
||||
client := &fakeHealthRecordClient{rows: []contract.FindHealthRecord{}, findResult: osi.Result{Success: true}, saveResult: osi.Result{Success: true}}
|
||||
statuses := &fakeStatusWriter{err: errors.New("PHIS unavailable")}
|
||||
service := newTestService(client, &fakeIdempotencyStore{}, &fakeReportSink{}, statuses)
|
||||
|
||||
outcome, err := service.Upsert(context.Background(), testTask())
|
||||
var notificationErr *NotificationError
|
||||
if outcome.Status != StatusDone || !errors.As(err, ¬ificationErr) {
|
||||
t.Fatalf("outcome=%#v err=%v", outcome, err)
|
||||
}
|
||||
if notificationErr.Event != nil || notificationErr.Update == nil || notificationErr.ReportErr != nil || notificationErr.StatusErr == nil {
|
||||
t.Fatalf("notification error = %#v", notificationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(client *fakeHealthRecordClient, store *fakeIdempotencyStore, reports *fakeReportSink, statuses *fakeStatusWriter) *HealthRecordUpsertService {
|
||||
return NewHealthRecordUpsertService(Dependencies{
|
||||
Converter: ConverterFunc(func(source.HealthRecordTask) (contract.HealthRecordCreate, []mapping.ValidationError) {
|
||||
return testRequest(), nil
|
||||
}),
|
||||
Client: client, Idempotency: store, Reports: reports, PHISStatuses: statuses,
|
||||
})
|
||||
}
|
||||
|
||||
func testTask() source.HealthRecordTask {
|
||||
return source.HealthRecordTask{ArchID: "ARCH-1", BusinessID: "BUS-1"}
|
||||
}
|
||||
|
||||
func testRequest() contract.HealthRecordCreate {
|
||||
return contract.HealthRecordCreate{
|
||||
BaseInfo: contract.HealthRecordBaseInfo{IDCard: "440000********1234", PersonName: "测试居民"},
|
||||
ManageInfo: contract.ManageInfo{OperateUser: "DOC-1"},
|
||||
HealthRecord: contract.HealthRecordCreateInfo{CheckID: "CHECK-STABLE", IDCard: "440000********1234", ManaUnitID: "UNIT-1", ManaDoctorID: "DOC-1"},
|
||||
}
|
||||
}
|
||||
|
||||
func matchingTarget() contract.HealthRecord {
|
||||
return contract.HealthRecord{IDCard: "440000********1234", PhrID: "PHR-EXISTING", EmpiID: "EMPI-1", ManaUnitID: "UNIT-1", ManaDoctorID: "DOC-1", Status: "1"}
|
||||
}
|
||||
|
||||
func targetWith(change func(*contract.HealthRecord)) contract.HealthRecord {
|
||||
r := matchingTarget()
|
||||
change(&r)
|
||||
return r
|
||||
}
|
||||
|
||||
type fakeHealthRecordClient struct {
|
||||
rows []contract.FindHealthRecord
|
||||
findResult, saveResult osi.Result
|
||||
findErr, saveErr error
|
||||
saved contract.HealthRecordSaveResult
|
||||
findCalls, createCalls, updateCalls int
|
||||
updateReq contract.HealthRecordCreate
|
||||
}
|
||||
|
||||
func (f *fakeHealthRecordClient) FindHealthRecord(context.Context, osi.FindHealthRecordQuery) ([]contract.FindHealthRecord, osi.Result, error) {
|
||||
f.findCalls++
|
||||
return f.rows, f.findResult, f.findErr
|
||||
}
|
||||
func (f *fakeHealthRecordClient) CreateHealthRecord(context.Context, contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error) {
|
||||
f.createCalls++
|
||||
return f.saved, f.saveResult, f.saveErr
|
||||
}
|
||||
func (f *fakeHealthRecordClient) UpdateHealthRecord(_ context.Context, req contract.HealthRecordCreate) (contract.HealthRecordSaveResult, osi.Result, error) {
|
||||
f.updateCalls++
|
||||
f.updateReq = req
|
||||
return f.saved, f.saveResult, f.saveErr
|
||||
}
|
||||
|
||||
type fakeIdempotencyStore struct {
|
||||
completed, inProgress, marked, released bool
|
||||
completeErr error
|
||||
}
|
||||
|
||||
func (f *fakeIdempotencyStore) Acquire(context.Context, string) (IdempotencyLease, error) {
|
||||
if f.completed {
|
||||
return IdempotencyLease{State: IdempotencyCompleted}, nil
|
||||
}
|
||||
if f.inProgress {
|
||||
return IdempotencyLease{State: IdempotencyInProgress}, nil
|
||||
}
|
||||
return IdempotencyLease{State: IdempotencyAcquired, Token: "lease-1"}, nil
|
||||
}
|
||||
func (f *fakeIdempotencyStore) Complete(_ context.Context, _, token string) error {
|
||||
if token != "lease-1" {
|
||||
return errors.New("stale lease")
|
||||
}
|
||||
f.marked = true
|
||||
return f.completeErr
|
||||
}
|
||||
func (f *fakeIdempotencyStore) Release(_ context.Context, _, token string) error {
|
||||
if token != "lease-1" {
|
||||
return errors.New("stale lease")
|
||||
}
|
||||
f.released = true
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeReportSink struct{ events []UpsertEvent }
|
||||
|
||||
func (f *fakeReportSink) Publish(_ context.Context, event UpsertEvent) error {
|
||||
f.events = append(f.events, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeStatusWriter struct {
|
||||
updates []PHISStatusUpdate
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeStatusWriter) WriteStatus(_ context.Context, update PHISStatusUpdate) error {
|
||||
f.updates = append(f.updates, update)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func assertNotifications(t *testing.T, reports *fakeReportSink, statuses *fakeStatusWriter, status Status, action Action) {
|
||||
t.Helper()
|
||||
if len(reports.events) != 1 || reports.events[0].Status != status || reports.events[0].Action != action {
|
||||
t.Fatalf("reports=%#v", reports.events)
|
||||
}
|
||||
if len(statuses.updates) != 1 || statuses.updates[0].Status != status {
|
||||
t.Fatalf("statuses=%#v", statuses.updates)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user