package store import ( "context" "errors" "path/filepath" "testing" "time" "glchat/internal/database" ) // newCallStore открывает тестовую БД с применёнными миграциями. func newCallStore(t *testing.T) *Store { t.Helper() ctx := context.Background() db, err := database.Open(ctx, database.Options{ Path: filepath.Join(t.TempDir(), "glchat.db"), ReadPool: 2, Migrate: true, }) if err != nil { t.Fatalf("open test database: %v", err) } t.Cleanup(func() { if err := db.Close(); err != nil { t.Errorf("close test database: %v", err) } }) return New(db) } // TestDMCallStoreLifecycle — состояние звонка в хранилище (AGENT.md 7.8): // один звонок на беседу, переход в active на втором участнике, история. func TestDMCallStoreLifecycle(t *testing.T) { ctx := t.Context() st := newCallStore(t) alice := createStoreUser(t, st, "alice_store") bob := createStoreUser(t, st, "bob_store") channel, err := st.CreateGroupDMChannel(ctx, alice.ID, "Беседа", []uint64{bob.ID}) if err != nil { t.Fatalf("create conversation: %v", err) } call, err := st.CreateDMCall(ctx, channel.ID, alice.ID, "audio", []uint64{bob.ID}) if err != nil { t.Fatalf("create call: %v", err) } if call.Status != DMCallRinging || call.StartedAt != nil { t.Fatalf("new call = %+v", call.DMCall) } if len(call.Participants) != 2 { t.Fatalf("participants = %+v", call.Participants) } // Второй одновременный звонок в беседе запрещён. if _, err := st.CreateDMCall(ctx, channel.ID, bob.ID, "audio", nil); !errors.Is(err, ErrConflict) { t.Fatalf("second call error = %v, want ErrConflict", err) } joined, err := st.JoinDMCallParticipant(ctx, call.ID, bob.ID) if err != nil { t.Fatalf("join: %v", err) } if joined.Status != DMCallActive || joined.StartedAt == nil { t.Fatalf("call after second join = %+v", joined) } if err := st.UpdateDMCallFlags(ctx, call.ID, bob.ID, DMCallFlags{SelfMute: true, Camera: true}); err != nil { t.Fatalf("update flags: %v", err) } if err := st.LeaveDMCallParticipant(ctx, call.ID, alice.ID, DMCallLeft); err != nil { t.Fatalf("leave: %v", err) } if err := st.EndDMCall(ctx, call.ID, &bob.ID, DMCallReasonCompleted); err != nil { t.Fatalf("end: %v", err) } // Текущего звонка больше нет, а история хранит состав и длительность. if _, err := st.GetLiveDMCall(ctx, channel.ID); !errors.Is(err, ErrNotFound) { t.Fatalf("live call error = %v, want ErrNotFound", err) } history, err := st.ListDMCalls(ctx, channel.ID, 10, 0) if err != nil { t.Fatalf("history: %v", err) } if len(history) != 1 { t.Fatalf("history = %+v", history) } entry := history[0] if entry.Status != DMCallEnded || entry.EndReason != DMCallReasonCompleted || entry.EndedBy == nil { t.Fatalf("history entry = %+v", entry.DMCall) } if st.DMCallDurationSeconds(&entry.DMCall) < 0 { t.Fatalf("duration = %d", st.DMCallDurationSeconds(&entry.DMCall)) } states := map[uint64]string{} for _, participant := range entry.Participants { states[participant.UserID] = participant.State } if states[alice.ID] != DMCallLeft || states[bob.ID] != DMCallLeft { t.Fatalf("participant states = %+v", states) } // После завершения можно звонить снова: живой звонок только один. next, err := st.CreateDMCall(ctx, channel.ID, bob.ID, "video", []uint64{alice.ID}) if err != nil { t.Fatalf("create next call: %v", err) } if next.Status != DMCallRinging || next.InitiatorID != bob.ID { t.Fatalf("next call = %+v", next.DMCall) } } // TestDMCallStaleSweep — сторож находит гудки без ответа и опустевшие комнаты. func TestDMCallStaleSweep(t *testing.T) { ctx := t.Context() st := newCallStore(t) alice := createStoreUser(t, st, "alice_sweep") bob := createStoreUser(t, st, "bob_sweep") channel, err := st.CreateGroupDMChannel(ctx, alice.ID, "Беседа", []uint64{bob.ID}) if err != nil { t.Fatalf("create conversation: %v", err) } call, err := st.CreateDMCall(ctx, channel.ID, alice.ID, "audio", []uint64{bob.ID}) if err != nil { t.Fatalf("create call: %v", err) } stale, err := st.ListStaleDMCalls(ctx, time.Now().UTC().Add(time.Minute), time.Now().UTC().Add(time.Minute)) if err != nil { t.Fatalf("list stale: %v", err) } if len(stale) != 1 || stale[0].ID != call.ID { t.Fatalf("stale calls = %+v", stale) } fresh, err := st.ListStaleDMCalls(ctx, time.Now().UTC().Add(-time.Minute), time.Now().UTC().Add(-time.Minute)) if err != nil { t.Fatalf("list fresh: %v", err) } if len(fresh) != 0 { t.Fatalf("fresh call must not be swept: %+v", fresh) } } func createStoreUser(t *testing.T, st *Store, username string) *User { t.Helper() user, err := st.CreateUser(t.Context(), CreateUserParams{ Username: username, DisplayName: username, EmailEnc: "enc", EmailIndex: username, PasswordHash: "hash", }) if err != nil { t.Fatalf("create user %s: %v", username, err) } return user }