2026-07-07 19:09:20 +08:00

220 lines
6.6 KiB
Go

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)
}
}