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 на втором участнике, история, повторный звонок) и выборка сторожем.
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user