Files
glchat/internal/server/dm_calls_test.go
T

516 lines
22 KiB
Go
Raw Normal View History

package server
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"glchat/internal/voice"
)
// dmCallParticipantResponse — участник звонка в ответе API.
type dmCallParticipantResponse struct {
UserID string `json:"user_id"`
State string `json:"state"`
SelfMute bool `json:"self_mute"`
SelfDeaf bool `json:"self_deaf"`
Camera bool `json:"camera"`
Screen bool `json:"screen"`
}
type dmCallResponse struct {
ID string `json:"id"`
ChannelID string `json:"channel_id"`
InitiatorID string `json:"initiator_id"`
Status string `json:"status"`
Media string `json:"media"`
EndReason string `json:"end_reason"`
DurationSeconds int `json:"duration_seconds"`
Participants []dmCallParticipantResponse `json:"participants"`
}
// dmCallPayloadResponse — разбор ответа ручек звонка.
type dmCallPayloadResponse struct {
Call dmCallResponse `json:"call"`
Token string `json:"token"`
URL string `json:"url"`
Room string `json:"room"`
}
// voiceTestServer включает голос в тестовом сервере: ручки звонков без ключей
// LiveKit отвечают voice.disabled (AGENT.md 7.14).
func voiceTestServer(t *testing.T) *Server {
t.Helper()
srv, _ := newTestServer(t)
srv.cfg.LiveKitAPIKey = "test-key"
srv.cfg.LiveKitAPISecret = "test-secret"
srv.cfg.LiveKitURL = "wss://gl.mhspx.su/rtc"
srv.voice = voice.NewIssuer(srv.cfg.LiveKitAPIKey, srv.cfg.LiveKitAPISecret, srv.cfg.LiveKitTokenTTL)
return srv
}
// openTestDM заводит личную беседу между двумя пользователями и возвращает её id.
func openTestDM(t *testing.T, srv *Server, first *http.Cookie, secondID string) string {
t.Helper()
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+secondID+`"}`, first)
if rec.Code != http.StatusOK {
t.Fatalf("open direct channel = %d, body = %s", rec.Code, rec.Body.String())
}
created := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
} `json:"channel"`
}](t, rec)
if created.Channel.ID == "" {
t.Fatalf("direct channel id is empty: %s", rec.Body.String())
}
return created.Channel.ID
}
func userIDByEmail(t *testing.T, srv *Server, email string) string {
t.Helper()
user, err := srv.auth.UserByEmail(t.Context(), email)
if err != nil {
t.Fatalf("UserByEmail(%s): %v", email, err)
}
return formatSnowflake(user.ID)
}
// registerManyUsers заводит несколько аккаунтов: лимит регистраций считается
// по IP (пять в минуту), поэтому часы лимитера сдвигаются вперёд.
func registerManyUsers(t *testing.T, srv *Server, count int, prefix string) []*http.Cookie {
t.Helper()
clock := time.Now()
srv.authLimiter.SetClock(func() time.Time { return clock })
cookies := make([]*http.Cookie, 0, count)
for index := 0; index < count; index++ {
clock = clock.Add(30 * time.Second)
username := fmt.Sprintf("%s_%02d", prefix, index)
cookies = append(cookies, registerAndLogin(t, srv, username, username+"@example.com"))
}
return cookies
}
func participantOf(t *testing.T, payload dmCallPayloadResponse, userID string) dmCallParticipantResponse {
t.Helper()
for _, participant := range payload.Call.Participants {
if participant.UserID == userID {
return participant
}
}
t.Fatalf("participant %s is missing in %+v", userID, payload.Call.Participants)
return dmCallParticipantResponse{}
}
// TestDirectCallLifecycle — звонок 1:1 целиком (AGENT.md 7.8, Фаза 7):
// создание, гудки, принятие, флаги, завершение и история.
func TestDirectCallLifecycle(t *testing.T) {
srv := voiceTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_call", "alice-call@example.com")
bobCookie := registerAndLogin(t, srv, "bob_call", "bob-call@example.com")
aliceID := userIDByEmail(t, srv, "alice-call@example.com")
bobID := userIDByEmail(t, srv, "bob-call@example.com")
channelID := openTestDM(t, srv, aliceCookie, bobID)
// Звонок начинает Алиса: комната dm_call_*, токен LiveKit выдан, гудки идут.
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{"media":"video"}`, aliceCookie)
if rec.Code != http.StatusOK {
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
}
started := decodeResponse[dmCallPayloadResponse](t, rec)
if started.Call.Status != "ringing" || started.Call.Media != "video" {
t.Fatalf("call must start ringing: %s", rec.Body.String())
}
if started.Call.InitiatorID != aliceID {
t.Fatalf("initiator = %s, want %s", started.Call.InitiatorID, aliceID)
}
if started.Token == "" || started.URL == "" {
t.Fatalf("call must return a LiveKit token and url: %s", rec.Body.String())
}
if started.Room != voice.CallRoomName(mustParseID(t, started.Call.ID)) {
t.Fatalf("room = %q", started.Room)
}
if len(started.Call.Participants) != 2 {
t.Fatalf("participants = %+v", started.Call.Participants)
}
if state := participantOf(t, started, aliceID).State; state != "joined" {
t.Fatalf("initiator state = %q, want joined", state)
}
if state := participantOf(t, started, bobID).State; state != "invited" {
t.Fatalf("invitee state = %q, want invited", state)
}
// Второй звонок в той же беседе начать нельзя.
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, bobCookie)
if rec.Code != http.StatusConflict {
t.Fatalf("second call = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "dm.call_in_progress" {
t.Fatalf("error code = %q, want dm.call_in_progress", code)
}
// Идущий звонок виден второму участнику вместе с беседой: список рисует
// маркер «идёт звонок» без отдельного запроса (AGENT.md 7.8).
list := decodeResponse[struct {
Channels []struct {
ID string `json:"id"`
ActiveCall *struct {
ID string `json:"id"`
Status string `json:"status"`
} `json:"active_call"`
} `json:"channels"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/users/@me/channels", "", bobCookie))
found := false
for _, channel := range list.Channels {
if channel.ID != channelID {
continue
}
found = true
if channel.ActiveCall == nil || channel.ActiveCall.Status != "ringing" {
t.Fatalf("active_call = %+v", channel.ActiveCall)
}
}
if !found {
t.Fatal("direct channel is missing in the list")
}
// Принятие: звонок становится active, длительность считается с ответа.
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/accept", "", bobCookie)
if rec.Code != http.StatusOK {
t.Fatalf("accept call = %d, body = %s", rec.Code, rec.Body.String())
}
accepted := decodeResponse[dmCallPayloadResponse](t, rec)
if accepted.Call.Status != "active" {
t.Fatalf("call status = %q, want active", accepted.Call.Status)
}
if accepted.Token == "" {
t.Fatal("accept must return a LiveKit token")
}
// Флаги микрофона и камеры сохраняются у участника звонка.
rec = doJSON(t, srv, http.MethodPatch, "/api/v1/channels/"+channelID+"/call/@me",
`{"self_mute":true,"camera":true}`, bobCookie)
if rec.Code != http.StatusOK {
t.Fatalf("update flags = %d, body = %s", rec.Code, rec.Body.String())
}
flags := decodeResponse[dmCallPayloadResponse](t, rec)
participant := participantOf(t, flags, bobID)
if !participant.SelfMute || !participant.Camera {
t.Fatalf("flags not saved: %+v", participant)
}
// Завершает Алиса: в беседе вдвоём уход заканчивает разговор.
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/end", "", aliceCookie)
if rec.Code != http.StatusOK {
t.Fatalf("end call = %d, body = %s", rec.Code, rec.Body.String())
}
ended := decodeResponse[dmCallPayloadResponse](t, rec)
if ended.Call.Status != "ended" || ended.Call.EndReason != "completed" {
t.Fatalf("call must be completed: %s", rec.Body.String())
}
// После завершения текущего звонка нет, а история содержит запись.
live := decodeResponse[struct {
Call *dmCallResponse `json:"call"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channelID+"/call", "", bobCookie))
if live.Call != nil {
t.Fatalf("live call = %+v, want null", live.Call)
}
history := decodeResponse[struct {
Calls []dmCallResponse `json:"calls"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channelID+"/calls", "", bobCookie))
if len(history.Calls) != 1 {
t.Fatalf("history = %+v", history.Calls)
}
entry := history.Calls[0]
if entry.Status != "ended" || entry.EndReason != "completed" || entry.InitiatorID != aliceID {
t.Fatalf("history entry = %+v", entry)
}
for _, participant := range entry.Participants {
if participant.State != "left" {
t.Fatalf("history participant = %+v, want left", participant)
}
}
}
// TestDirectCallMissedHistory — звонок, на который не ответили: у приглашённого
// он остаётся отклонённым, а сам звонок закрывается отказом (AGENT.md 7.8).
func TestDirectCallMissedHistory(t *testing.T) {
srv := voiceTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_miss", "alice-miss@example.com")
bobCookie := registerAndLogin(t, srv, "bob_miss", "bob-miss@example.com")
bobID := userIDByEmail(t, srv, "bob-miss@example.com")
channelID := openTestDM(t, srv, aliceCookie, bobID)
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
}
// Боб отклоняет — ждать больше некого, звонок завершается отказом.
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/decline", "", bobCookie)
if rec.Code != http.StatusOK {
t.Fatalf("decline call = %d, body = %s", rec.Code, rec.Body.String())
}
declined := decodeResponse[dmCallPayloadResponse](t, rec)
if declined.Call.Status != "ended" || declined.Call.EndReason != "declined" {
t.Fatalf("call must end as declined: %s", rec.Body.String())
}
if state := participantOf(t, declined, bobID).State; state != "declined" {
t.Fatalf("bob state = %q, want declined", state)
}
}
// TestDirectCallOutsiderDenied — посторонний не видит беседу, не звонит в неё
// и не принимает чужой звонок: 404, существование не подтверждаем (D-070).
func TestDirectCallOutsiderDenied(t *testing.T) {
srv := voiceTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_out", "alice-out@example.com")
bobCookie := registerAndLogin(t, srv, "bob_out", "bob-out@example.com")
eveCookie := registerAndLogin(t, srv, "eve_out", "eve-out@example.com")
bobID := userIDByEmail(t, srv, "bob-out@example.com")
channelID := openTestDM(t, srv, aliceCookie, bobID)
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
}
for _, probe := range []struct {
method string
path string
body string
}{
{http.MethodGet, "/call", ""},
{http.MethodPost, "/call/accept", ""},
{http.MethodPost, "/call/decline", ""},
{http.MethodPost, "/call/end", ""},
{http.MethodPatch, "/call/@me", `{"self_mute":true}`},
{http.MethodGet, "/calls", ""},
} {
rec := doJSON(t, srv, probe.method, "/api/v1/channels/"+channelID+probe.path, probe.body, eveCookie)
if rec.Code != http.StatusNotFound {
t.Fatalf("%s %s = %d, want 404, body = %s", probe.method, probe.path, rec.Code, rec.Body.String())
}
}
// Чужой звонок всё ещё идёт: посторонний не смог его завершить.
live := decodeResponse[struct {
Call *dmCallResponse `json:"call"`
}](t, doJSON(t, srv, http.MethodGet, "/api/v1/channels/"+channelID+"/call", "", bobCookie))
if live.Call == nil || live.Call.Status != "ringing" {
t.Fatalf("call must still be ringing: %+v", live.Call)
}
}
// TestDirectCallGroupLimit — предел звонка в десять участников (AGENT.md 7.8):
// в беседе из одиннадцати человек позвонить нельзя.
func TestDirectCallGroupLimit(t *testing.T) {
srv := voiceTestServer(t)
cookies := registerManyUsers(t, srv, maxDMCallParticipants, "call_limit")
ids := make([]string, 0, maxDMCallParticipants)
for index := 0; index < maxDMCallParticipants; index++ {
ids = append(ids, userIDByEmail(t, srv, fmt.Sprintf("call_limit_%02d@example.com", index)))
}
owner := cookies[0]
body := `{"name":"Лимит","recipient_ids":[`
for index := 1; index < maxDMCallParticipants; index++ {
if index > 1 {
body += ","
}
body += `"` + ids[index] + `"`
}
body += `]}`
rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels/group", body, owner)
if rec.Code != http.StatusOK {
t.Fatalf("create group = %d, body = %s", rec.Code, rec.Body.String())
}
channelID := decodeResponse[struct {
Channel struct {
ID string `json:"id"`
MemberCount int `json:"member_count"`
} `json:"channel"`
}](t, rec).Channel.ID
// Ровно десять участников — звонок разрешён, состав весь в приглашении.
started := decodeResponse[dmCallPayloadResponse](
t, doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, owner))
if len(started.Call.Participants) != maxDMCallParticipants {
t.Fatalf("participants = %d, want %d", len(started.Call.Participants), maxDMCallParticipants)
}
// Все девять приглашённых принимают: в комнате десять человек.
for index := 1; index < maxDMCallParticipants; index++ {
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/accept", "", cookies[index])
if rec.Code != http.StatusOK {
t.Fatalf("accept #%d = %d, body = %s", index, rec.Code, rec.Body.String())
}
}
// В групповой беседе уход одного разговор не заканчивает: звонок живёт,
// пока в комнате кто-то остаётся, и закрывается на последнем.
for index := 0; index < maxDMCallParticipants-1; index++ {
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/end", "", cookies[index])
if rec.Code != http.StatusOK {
t.Fatalf("leave #%d = %d, body = %s", index, rec.Code, rec.Body.String())
}
if payload := decodeResponse[dmCallPayloadResponse](t, rec); payload.Call.Status != "active" {
t.Fatalf("call must stay active after leave #%d: %s", index, rec.Body.String())
}
}
live := decodeResponse[dmCallPayloadResponse](t, doJSON(t, srv, http.MethodPost,
"/api/v1/channels/"+channelID+"/call/end", "", cookies[maxDMCallParticipants-1]))
if live.Call.Status != "ended" {
t.Fatalf("call status = %q, want ended", live.Call.Status)
}
// Одиннадцатый участник: беседа больше предела — звонить нельзя. Состав
// расширяем напрямую в хранилище: ручка добавления держит тот же лимит.
extra := registerManyUsers(t, srv, 1, "call_extra")
_ = extra
if err := srv.store.AddDMParticipant(t.Context(), mustParseID(t, channelID),
mustParseID(t, userIDByEmail(t, srv, "call_extra_00@example.com"))); err != nil {
t.Fatalf("add extra participant: %v", err)
}
rec = doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, owner)
if rec.Code != http.StatusConflict {
t.Fatalf("call in a big conversation = %d, want 409, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "dm.call_limit" {
t.Fatalf("error code = %q, want dm.call_limit", code)
}
}
// TestDirectCallVoiceDisabled — выключенный на инстансе голос даёт понятный
// отказ, а не сломанный звонок (AGENT.md 7.14).
func TestDirectCallVoiceDisabled(t *testing.T) {
srv, _ := newTestServer(t)
aliceCookie := registerAndLogin(t, srv, "alice_novoice", "alice-novoice@example.com")
bobCookie := registerAndLogin(t, srv, "bob_novoice", "bob-novoice@example.com")
bobID := userIDByEmail(t, srv, "bob-novoice@example.com")
channelID := openTestDM(t, srv, aliceCookie, bobID)
_ = bobCookie
rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("start call without voice = %d, want 503, body = %s", rec.Code, rec.Body.String())
}
if code := errorCodeOf(t, rec); code != "voice.disabled" {
t.Fatalf("error code = %q, want voice.disabled", code)
}
// Никакого звонка в базе не осталось.
if _, err := srv.store.GetLiveDMCall(t.Context(), mustParseID(t, channelID)); err == nil {
t.Fatal("no call must be stored when voice is disabled")
}
}
// TestDirectCallGatewayEvents — приглашение приходит событием: участники
// получают DM_CALL_RING/DM_CALL_UPDATE/DM_CALL_END, посторонний — ничего.
func TestDirectCallGatewayEvents(t *testing.T) {
srv := voiceTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
aliceCookie := registerAndLogin(t, srv, "alice_ev", "alice-ev@example.com")
bobCookie := registerAndLogin(t, srv, "bob_ev", "bob-ev@example.com")
eveCookie := registerAndLogin(t, srv, "eve_ev", "eve-ev@example.com")
bobID := userIDByEmail(t, srv, "bob-ev@example.com")
channelID := openTestDM(t, srv, aliceCookie, bobID)
aliceClient := dialGateway(t, httpServer, aliceCookie)
bobClient := dialGateway(t, httpServer, bobCookie)
eveClient := dialGateway(t, httpServer, eveCookie)
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
}
// Боб получает приглашение, Алиса — обновление (у неё может быть второе
// устройство), посторонний не получает ничего.
ring := bobClient.expectEvent("DM_CALL_RING")
var ringPayload dmCallResponse
if err := json.Unmarshal(ring.D, &ringPayload); err != nil {
t.Fatalf("decode DM_CALL_RING: %v", err)
}
if ringPayload.Status != "ringing" || ringPayload.Media != "audio" || ringPayload.ChannelID != channelID {
t.Fatalf("ring payload = %+v", ringPayload)
}
aliceClient.expectEvent("DM_CALL_UPDATE")
// Принятие видно инициатору как обновление звонка.
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/accept", "", bobCookie); rec.Code != http.StatusOK {
t.Fatalf("accept = %d, body = %s", rec.Code, rec.Body.String())
}
update := aliceClient.expectEvent("DM_CALL_UPDATE")
var updatePayload dmCallResponse
if err := json.Unmarshal(update.D, &updatePayload); err != nil {
t.Fatalf("decode DM_CALL_UPDATE: %v", err)
}
if updatePayload.Status != "active" {
t.Fatalf("status = %q, want active", updatePayload.Status)
}
// Завершение приходит отдельным событием с итогом.
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call/end", "", aliceCookie); rec.Code != http.StatusOK {
t.Fatalf("end = %d, body = %s", rec.Code, rec.Body.String())
}
end := bobClient.expectEvent("DM_CALL_END")
var endPayload dmCallResponse
if err := json.Unmarshal(end.D, &endPayload); err != nil {
t.Fatalf("decode DM_CALL_END: %v", err)
}
if endPayload.Status != "ended" || endPayload.EndReason != "completed" {
t.Fatalf("end payload = %+v", endPayload)
}
// Посторонний не получил события о звонке: беседа ему не видна.
eveClient.expectNoEvent("DM_CALL_RING", 300*time.Millisecond)
}
// TestDirectCallInReadySnapshot — «идёт звонок» переживает перезагрузку: звонок
// приходит в READY вместе с беседой (AGENT.md 8.3).
func TestDirectCallInReadySnapshot(t *testing.T) {
srv := voiceTestServer(t)
httpServer := httptest.NewServer(srv.Handler())
t.Cleanup(httpServer.Close)
aliceCookie := registerAndLogin(t, srv, "alice_ready", "alice-ready@example.com")
bobCookie := registerAndLogin(t, srv, "bob_ready", "bob-ready@example.com")
bobID := userIDByEmail(t, srv, "bob-ready@example.com")
channelID := openTestDM(t, srv, aliceCookie, bobID)
if rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/call", `{}`, aliceCookie); rec.Code != http.StatusOK {
t.Fatalf("start call = %d, body = %s", rec.Code, rec.Body.String())
}
client := dialGateway(t, httpServer, bobCookie)
var ready struct {
DMChannels []struct {
ID string `json:"id"`
ActiveCall *dmCallResponse `json:"active_call"`
} `json:"dm_channels"`
}
if err := json.Unmarshal(client.ready.D, &ready); err != nil {
t.Fatalf("decode READY: %v", err)
}
for _, channel := range ready.DMChannels {
if channel.ID != channelID {
continue
}
if channel.ActiveCall == nil || channel.ActiveCall.Status != "ringing" {
t.Fatalf("active_call = %+v", channel.ActiveCall)
}
if len(channel.ActiveCall.Participants) != 2 {
t.Fatalf("participants = %+v", channel.ActiveCall.Participants)
}
return
}
t.Fatal("direct channel is missing in READY")
}
// mustParseID переводит строковый идентификатор в uint64.
func mustParseID(t *testing.T, value string) uint64 {
t.Helper()
id, err := parseID("id", value)
if err != nil {
t.Fatalf("parse id %q: %v", value, err)
}
return id
}