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 }