2026-07-16 01:06:43 +08:00
|
|
|
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)
|
|
|
|
|
}
|
2026-07-16 02:00:56 +08:00
|
|
|
if client.findCalls != 1 || client.createCalls != 1 || client.updateCalls != 0 || !store.marked || !store.completeHasDeadline {
|
|
|
|
|
t.Fatalf("calls find=%d create=%d update=%d marked=%v deadline=%v", client.findCalls, client.createCalls, client.updateCalls, store.marked, store.completeHasDeadline)
|
2026-07-16 01:06:43 +08:00
|
|
|
}
|
|
|
|
|
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
|
|
|
|
|
}
|
2026-07-16 02:00:56 +08:00
|
|
|
if err != nil || outcome.Status != want || client.createCalls != 0 || client.updateCalls != 0 || !store.released || !store.releaseHasDeadline {
|
2026-07-16 01:06:43 +08:00
|
|
|
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
|
2026-07-16 02:00:56 +08:00
|
|
|
completeHasDeadline, releaseHasDeadline bool
|
2026-07-16 01:06:43 +08:00
|
|
|
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
|
|
|
|
|
}
|
2026-07-16 02:00:56 +08:00
|
|
|
func (f *fakeIdempotencyStore) Complete(ctx context.Context, _, token string) error {
|
|
|
|
|
_, f.completeHasDeadline = ctx.Deadline()
|
2026-07-16 01:06:43 +08:00
|
|
|
if token != "lease-1" {
|
|
|
|
|
return errors.New("stale lease")
|
|
|
|
|
}
|
|
|
|
|
f.marked = true
|
|
|
|
|
return f.completeErr
|
|
|
|
|
}
|
2026-07-16 02:00:56 +08:00
|
|
|
func (f *fakeIdempotencyStore) Release(ctx context.Context, _, token string) error {
|
|
|
|
|
_, f.releaseHasDeadline = ctx.Deadline()
|
2026-07-16 01:06:43 +08:00
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
}
|