2026-06-26 21:31:14 +08:00

155 lines
6.5 KiB
Go

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
}