package mysql import ( "bytes" "context" "sync" "testing" "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/appcode" roomservice "hyapp/services/room-service/internal/room/service" ) func TestSaveSnapshotDoesNotOverwriteSameVersion(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repo := newCommandLogTestRepository(t, ctx) goodPayload := []byte("snapshot-good-v10") if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{ AppCode: appcode.Default, RoomID: "room-snapshot-version", RoomVersion: 10, Payload: goodPayload, CreatedAtMS: 1000, }); err != nil { t.Fatalf("save initial snapshot: %v", err) } if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{ AppCode: appcode.Default, RoomID: "room-snapshot-version", RoomVersion: 10, Payload: []byte("snapshot-bad-same-version"), CreatedAtMS: 2000, }); err != nil { t.Fatalf("save same-version snapshot: %v", err) } got, exists, err := repo.GetLatestSnapshot(ctx, "room-snapshot-version") if err != nil { t.Fatalf("get same-version snapshot: %v", err) } if !exists || got.RoomVersion != 10 || !bytes.Equal(got.Payload, goodPayload) || got.CreatedAtMS != 1000 { t.Fatalf("same-version snapshot overwrote recovery source: got version=%d created=%d payload=%q", got.RoomVersion, got.CreatedAtMS, got.Payload) } if err := repo.SaveSnapshot(ctx, roomservice.SnapshotRecord{ AppCode: appcode.Default, RoomID: "room-snapshot-version", RoomVersion: 11, Payload: []byte("snapshot-new-v11"), CreatedAtMS: 3000, }); err != nil { t.Fatalf("save newer snapshot: %v", err) } got, exists, err = repo.GetLatestSnapshot(ctx, "room-snapshot-version") if err != nil { t.Fatalf("get newer snapshot: %v", err) } if !exists || got.RoomVersion != 11 || !bytes.Equal(got.Payload, []byte("snapshot-new-v11")) { t.Fatalf("newer snapshot should replace old one: got version=%d payload=%q", got.RoomVersion, got.Payload) } } func TestSaveMutationRejectsDuplicateReplayableRoomVersion(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repo := newCommandLogTestRepository(t, ctx) roomID := "room-command-version" if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{ AppCode: appcode.Default, RoomID: roomID, RoomVersion: 20, CommandID: "cmd-first", CommandType: "join_room", Payload: []byte(`{"command_id":"cmd-first"}`), Replayable: true, OwnerNodeID: "node-a", LeaseToken: "lease-a", CreatedAtMS: 1000, }}); err != nil { t.Fatalf("save first replayable command: %v", err) } if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{ AppCode: appcode.Default, RoomID: roomID, RoomVersion: 20, CommandID: "cmd-second", CommandType: "leave_room", Payload: []byte(`{"command_id":"cmd-second"}`), Replayable: true, OwnerNodeID: "node-b", LeaseToken: "lease-b", CreatedAtMS: 2000, }}); err == nil { t.Fatal("duplicate replayable command version should be rejected") } if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{ AppCode: appcode.Default, RoomID: roomID, RoomVersion: 20, CommandID: "cmd-noop", CommandType: "join_room", Payload: []byte(`{"command_id":"cmd-noop"}`), Replayable: false, OwnerNodeID: "node-a", LeaseToken: "lease-a", CreatedAtMS: 3000, }}); err != nil { t.Fatalf("same-version non-replayable idempotency record should still be saved: %v", err) } } func TestSaveMutationAllowsDuplicateReplayableGiftVersion(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repo := newCommandLogTestRepository(t, ctx) roomID := "room-gift-version" for _, commandID := range []string{"cmd-gift-first", "cmd-gift-second"} { if err := repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{ AppCode: appcode.Default, RoomID: roomID, RoomVersion: 40, CommandID: commandID, CommandType: "send_gift", Payload: []byte(`{"command_id":"` + commandID + `"}`), Replayable: true, OwnerNodeID: "node-a", LeaseToken: "lease-a", CreatedAtMS: 5000, }}); err != nil { t.Fatalf("same-version send_gift should remain appendable for additive recovery facts: %v", err) } } } func TestSaveMutationSerializesConcurrentReplayableRoomVersion(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repo := newCommandLogTestRepository(t, ctx) roomID := "room-command-version-race" insertCommandLogTestRoom(t, ctx, repo, roomID) start := make(chan struct{}) errs := make(chan error, 2) var wg sync.WaitGroup for _, commandID := range []string{"cmd-race-a", "cmd-race-b"} { wg.Add(1) go func(commandID string) { defer wg.Done() <-start errs <- repo.SaveMutation(ctx, roomservice.MutationCommit{Command: roomservice.CommandRecord{ AppCode: appcode.Default, RoomID: roomID, RoomVersion: 30, CommandID: commandID, CommandType: "room_heartbeat", Payload: []byte(`{"command_id":"` + commandID + `"}`), Replayable: true, OwnerNodeID: "node-a", LeaseToken: "lease-a", CreatedAtMS: 4000, }}) }(commandID) } close(start) wg.Wait() close(errs) successes := 0 failures := 0 for err := range errs { if err == nil { successes++ continue } failures++ } if successes != 1 || failures != 1 { t.Fatalf("concurrent same-version replayable writes should produce one success and one conflict, got successes=%d failures=%d", successes, failures) } } func newCommandLogTestRepository(t *testing.T, ctx context.Context) *Repository { t.Helper() schema := mysqlschema.New(t, mysqlschema.Config{ EnvVar: "ROOM_SERVICE_MYSQL_TEST_DSN", InitDBPath: mysqlschema.InitDBPath(t, mysqlschema.CallerFile(t, 1), "..", "..", "..", "deploy", "mysql", "initdb", "001_room_service.sql"), DatabasePrefix: "hyapp_room_command_test", }) repo, err := Open(ctx, schema.DSN, 4, 4) if err != nil { t.Fatalf("open repository: %v", err) } t.Cleanup(func() { _ = repo.Close() }) return repo } func insertCommandLogTestRoom(t *testing.T, ctx context.Context, repo *Repository, roomID string) { t.Helper() if _, err := repo.db.ExecContext(ctx, `INSERT INTO rooms ( app_code, room_id, room_short_id, owner_user_id, seat_count, mode, status, visible_region_id, created_at_ms, updated_at_ms ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, appcode.Default, roomID, roomID+"-short", 1001, 8, "voice", "active", 10, 1000, 1000, ); err != nil { t.Fatalf("insert command log test room: %v", err) } }