Files
glchat/internal/store/dm_calls_test.go
T
grendervill 50709396cd feat(dm): хранилище и миграция звонков в беседах (Фаза 7)
Звонок в личной и групповой беседе живёт в своей таблице: в voice_states
guild_id обязателен, а у беседы сервера нет. Миграция 00026 добавляет
dm_calls (статус, media, длительность, причина завершения) и
dm_call_participants (состояние участника и флаги микрофона, камеры и
экрана); уникальный частичный индекс держит один идущий звонок на беседу.

В DMParticipantProfiles сравнение с владельцем обёрнуто в COALESCE: у 1:1
dm_owner_id пуст, и NULL нельзя было прочитать в int — ручка звонка в
личной беседе падала на 500.

Тесты: жизненный цикл звонка в хранилище (гудки, active на втором
участнике, история, повторный звонок) и выборка сторожем.
2026-09-26 17:02:51 +03:00

157 lines
5.1 KiB
Go

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
}