package service_test import ( "context" "sync" "testing" "time" roomeventsv1 "hyapp.local/api/proto/events/room/v1" "hyapp/pkg/appcode" "hyapp/services/room-service/internal/integration" "hyapp/services/room-service/internal/room/outbox" roomservice "hyapp/services/room-service/internal/room/service" "hyapp/services/room-service/internal/router" "hyapp/services/room-service/internal/testutil/mysqltest" ) type recordingOutboxPublisher struct { mu sync.Mutex envelopes []*roomeventsv1.EventEnvelope } func (p *recordingOutboxPublisher) PublishOutboxEvent(_ context.Context, envelope *roomeventsv1.EventEnvelope) error { p.mu.Lock() defer p.mu.Unlock() p.envelopes = append(p.envelopes, envelope) return nil } func (p *recordingOutboxPublisher) count() int { p.mu.Lock() defer p.mu.Unlock() return len(p.envelopes) } func TestRobotOutboxWorkerDropsStaleRecordsWithoutPublishing(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := mysqltest.NewRepository(t) publisher := &recordingOutboxPublisher{} svc := roomservice.New(roomservice.Config{ NodeID: "node-robot-stale-worker", LeaseTTL: 10 * time.Second, RobotOutboxPublisher: publisher, }, router.NewMemoryDirectory(), repository, nil, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) stale := robotOutboxRecordForTest(t, "room-robot-stale", time.Now().UTC().Add(-2*time.Minute)) if err := repository.SaveRobotOutbox(ctx, []outbox.Record{stale}); err != nil { t.Fatalf("save stale robot outbox failed: %v", err) } if err := svc.ProcessPendingRobotOutbox(ctx, roomservice.OutboxWorkerOptions{ BatchSize: 10, PublishTimeout: time.Second, StaleDiscardAfter: 60 * time.Second, }); err != nil { t.Fatalf("process stale robot outbox failed: %v", err) } if publisher.count() != 0 { t.Fatalf("stale robot outbox must not publish, got %d publishes", publisher.count()) } if _, exists := repository.RobotOutboxRecord(stale.EventID); exists { t.Fatalf("stale robot outbox must be deleted") } } func TestRobotOutboxWorkerPublishesFreshRecords(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := mysqltest.NewRepository(t) publisher := &recordingOutboxPublisher{} svc := roomservice.New(roomservice.Config{ NodeID: "node-robot-fresh-worker", LeaseTTL: 10 * time.Second, RobotOutboxPublisher: publisher, }, router.NewMemoryDirectory(), repository, nil, integration.NewNoopRoomEventPublisher(), integration.NewNoopOutboxPublisher()) fresh := robotOutboxRecordForTest(t, "room-robot-fresh", time.Now().UTC()) if err := repository.SaveRobotOutbox(ctx, []outbox.Record{fresh}); err != nil { t.Fatalf("save fresh robot outbox failed: %v", err) } if err := svc.ProcessPendingRobotOutbox(ctx, roomservice.OutboxWorkerOptions{ BatchSize: 10, PublishTimeout: time.Second, StaleDiscardAfter: 60 * time.Second, }); err != nil { t.Fatalf("process fresh robot outbox failed: %v", err) } if publisher.count() != 1 { t.Fatalf("fresh robot outbox must publish once, got %d publishes", publisher.count()) } record, exists := repository.RobotOutboxRecord(fresh.EventID) if !exists || record.Status != outbox.StatusDelivered { t.Fatalf("fresh robot outbox must be delivered, exists=%v record=%+v", exists, record) } } func TestCleanupRobotOutboxDeletesExpiredActiveAndTerminalRecords(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repository := mysqltest.NewRepository(t) now := time.Now().UTC() oldCreated := now.Add(-10 * time.Minute) freshCreated := now.Add(-30 * time.Second) oldUpdatedMS := now.Add(-2 * time.Hour).UnixMilli() freshUpdatedMS := now.UnixMilli() expiredLockMS := now.Add(-time.Minute).UnixMilli() futureLockMS := now.Add(time.Hour).UnixMilli() oldPending := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated) oldRetryable := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(time.Millisecond)) oldDeliveringExpired := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(2*time.Millisecond)) oldDeliveringLocked := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(3*time.Millisecond)) oldDelivered := robotOutboxRecordForTest(t, "room-robot-cleanup", oldCreated.Add(4*time.Millisecond)) freshPending := robotOutboxRecordForTest(t, "room-robot-cleanup", freshCreated) freshDelivered := robotOutboxRecordForTest(t, "room-robot-cleanup", freshCreated.Add(time.Millisecond)) records := []outbox.Record{oldPending, oldRetryable, oldDeliveringExpired, oldDeliveringLocked, oldDelivered, freshPending, freshDelivered} if err := repository.SaveRobotOutbox(ctx, records); err != nil { t.Fatalf("save cleanup robot outbox records failed: %v", err) } repository.SetRobotOutboxStatus(oldRetryable.EventID, outbox.StatusRetryable, oldUpdatedMS, nil) repository.SetRobotOutboxStatus(oldDeliveringExpired.EventID, outbox.StatusDelivering, oldUpdatedMS, &expiredLockMS) repository.SetRobotOutboxStatus(oldDeliveringLocked.EventID, outbox.StatusDelivering, oldUpdatedMS, &futureLockMS) repository.SetRobotOutboxStatus(oldDelivered.EventID, outbox.StatusDelivered, oldUpdatedMS, nil) repository.SetRobotOutboxStatus(freshDelivered.EventID, outbox.StatusDelivered, freshUpdatedMS, nil) activeDeleted, terminalDeleted, err := repository.CleanupRobotOutbox(ctx, now.Add(-5*time.Minute).UnixMilli(), now.Add(-time.Hour).UnixMilli(), 50000) if err != nil { t.Fatalf("cleanup robot outbox failed: %v", err) } if activeDeleted != 3 || terminalDeleted != 1 { t.Fatalf("cleanup counts mismatch active=%d terminal=%d", activeDeleted, terminalDeleted) } for _, record := range []outbox.Record{oldPending, oldRetryable, oldDeliveringExpired, oldDelivered} { if _, exists := repository.RobotOutboxRecord(record.EventID); exists { t.Fatalf("expired robot outbox %s should be deleted", record.EventID) } } for _, record := range []outbox.Record{oldDeliveringLocked, freshPending, freshDelivered} { if _, exists := repository.RobotOutboxRecord(record.EventID); !exists { t.Fatalf("fresh or locked robot outbox %s should remain", record.EventID) } } } func robotOutboxRecordForTest(t *testing.T, roomID string, occurredAt time.Time) outbox.Record { t.Helper() record, err := outbox.Build(roomID, "RoomHeatChanged", 1, occurredAt, &roomeventsv1.RoomHeatChanged{ Delta: 1, CurrentHeat: 1, }) if err != nil { t.Fatalf("build robot outbox record failed: %v", err) } return record }