package mysql import ( "bytes" "context" "errors" "sync" "testing" "hyapp/internal/testutil/mysqlschema" "hyapp/pkg/appcode" roomservice "hyapp/services/room-service/internal/room/service" ) func TestSaveMutationRejectsSameCommandInsteadOfContinuingSideEffects(t *testing.T) { ctx := appcode.WithContext(context.Background(), appcode.Default) repo := newCommandLogTestRepository(t, ctx) roomID := "room-command-id-race" insertCommandLogTestRoom(t, ctx, repo, roomID) first := roomservice.MutationCommit{Command: roomservice.CommandRecord{ AppCode: appcode.Default, RoomID: roomID, RoomVersion: 1, CommandID: "cmd-stable", CommandType: "send_gift", Payload: []byte(`{"command_id":"cmd-stable"}`), ResultPayload: []byte("first-result"), Replayable: true, OwnerNodeID: "node-a", LeaseToken: "lease-a", CreatedAtMS: 1000, }} if err := repo.SaveMutation(ctx, first); err != nil { t.Fatalf("save first command: %v", err) } second := first second.Command.RoomVersion = 2 second.Command.ResultPayload = []byte("must-not-overwrite") if err := repo.SaveMutation(ctx, second); !errors.Is(err, roomservice.ErrCommandAlreadyCommitted) { t.Fatalf("same command_id should return replay signal, got %v", err) } record, exists, err := repo.GetCommand(ctx, roomID, "cmd-stable") if err != nil || !exists { t.Fatalf("read first command: exists=%v err=%v", exists, err) } if record.RoomVersion != 1 || !bytes.Equal(record.ResultPayload, []byte("first-result")) { t.Fatalf("duplicate command changed original result: %+v", record) } } 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) } }