package storage import ( "context" "errors" "fmt" "os" "sync" "sync/atomic" "testing" "time" "dana/internal/onec" ) // Run only against the disposable DB created by the autonomy acceptance command. func TestAutonomousBookingLifecycle(t *testing.T) { dsn := os.Getenv("DANA_AUTONOMY_TEST_DSN") if dsn == "" { t.Skip("requires disposable autonomy database") } ctx := context.Background() s, err := New(ctx, dsn) if err != nil { t.Fatal(err) } defer s.Close() if err = s.Migrate(ctx); err != nil { t.Fatal(err) } must := func(err error) { t.Helper() if err != nil { t.Fatal(err) } } doctor := fmt.Sprintf("autonomy-%d", time.Now().UnixNano()) start := time.Now().UTC().Truncate(time.Hour).Add(48 * time.Hour) end := start.Add(30 * time.Minute) slot := onec.Slot{DoctorID: doctor, BranchID: "branch", Start: start, End: end, Status: "free"} from, to := start.Add(-time.Hour), end.Add(time.Hour) must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{slot})) if !s.OnecFresh(ctx, time.Hour) { t.Fatal("push did not mark freshness") } var wins atomic.Int32 var winner OnecAppointment var mu sync.Mutex var wg sync.WaitGroup for i := 0; i < 12; i++ { wg.Add(1) go func(i int) { defer wg.Done() a, created, err := s.BeginOnecAppointment(ctx, OnecAppointment{RequestID: fmt.Sprintf("%s-%d", doctor, i), DoctorOnec: doctor, BranchID: "branch", Start: start, Patient: "test", Phone: "test", Comment: "test no calls"}) if errors.Is(err, ErrOnecSlotBusy) { return } if err != nil { t.Error(err) return } if !created { t.Error("unexpected duplicate") return } wins.Add(1) mu.Lock() winner = a mu.Unlock() }(i) } wg.Wait() if wins.Load() != 1 { t.Fatalf("concurrent reservation winners=%d", wins.Load()) } assertFree := func(n int) { t.Helper() v, err := s.FreeOnecSlots(ctx, doctor, from, to) must(err) if len(v) != n { t.Fatalf("free slots=%d want %d", len(v), n) } } assertFree(0) same, created, err := s.BeginOnecAppointment(ctx, OnecAppointment{RequestID: winner.RequestID, DoctorOnec: doctor, Start: start}) must(err) if created || same.ID != winner.ID { t.Fatal("request retry duplicated booking") } must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{slot})) assertFree(0) // stale free snapshot cannot reopen pending pending, err := s.PendingOnecAppointments(ctx, 50) must(err) found := false for _, p := range pending { if p.ID == winner.ID { found = true if p.Start.Format("-07:00") != "+03:00" || p.Comment != "test no calls" { t.Fatalf("bad pull fields: %+v", p) } } } if !found { t.Fatal("pending booking missing") } uuid := "00000000-0000-0000-0000-000000000001" must(s.ConfirmPendingAppointment(ctx, winner.ID, uuid, &end)) must(s.ConfirmPendingAppointment(ctx, winner.ID, uuid, &end)) if !errors.Is(s.ConfirmPendingAppointment(ctx, winner.ID, "different", &end), ErrOnecTransition) { t.Fatal("mismatched confirm accepted") } if !errors.Is(s.RejectPendingAppointment(ctx, winner.ID, "late reject"), ErrOnecTransition) { t.Fatal("late reject accepted") } must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{slot})) assertFree(0) // booked also survives full Push must(s.QueueOnecCommand(ctx, winner.ID, "link_bitrix", "123", "")) must(s.QueueOnecCommand(ctx, winner.ID, "link_bitrix", "123", "")) commands, err := s.PendingOnecCommands(ctx) must(err) if len(commands) != 1 { t.Fatal("duplicate link commands") } must(s.AckOnecCommand(ctx, commands[0].ID, false, "retry")) commands, err = s.PendingOnecCommands(ctx) must(err) if len(commands) != 1 { t.Fatal("failed ACK lost command") } must(s.AckOnecCommand(ctx, commands[0].ID, true, "")) must(s.AckOnecCommand(ctx, commands[0].ID, true, "")) must(s.QueueOnecCommand(ctx, winner.ID, "cancel", "", "cancelled")) assertFree(0) commands, err = s.PendingOnecCommands(ctx) must(err) if len(commands) != 1 || commands[0].Kind != "cancel" { t.Fatal("cancel not queued") } must(s.AckOnecCommand(ctx, commands[0].ID, true, "")) assertFree(1) _, _, known, err := s.SetOnecAppointmentStatus(ctx, uuid, "booked") must(err) if known { t.Fatal("delayed booked hook resurrected cancellation") } assertFree(1) must(s.AcceptOnecSlots(ctx, from, to, []onec.Slot{})) assertFree(0) // explicit empty horizon clears old shifts }