516 lines
22 KiB
Go
516 lines
22 KiB
Go
|
|
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
|
||
|
|
}
|