package server import ( "context" "net/http" "net/http/httptest" "strings" "testing" "glchat/internal/push" "glchat/internal/store" ) // recordingPush — подмена отправителя: запоминает, что сервер поставил в // очередь. Реальная доставка идёт в фоне и в тесте не наблюдаема. type recordingPush struct { enabled bool publicKey string jobs []recordedPush } type recordedPush struct { subscription store.PushSubscription payload push.Payload } func (r *recordingPush) Enabled() bool { return r.enabled } func (r *recordingPush) PublicKey() string { return r.publicKey } func (r *recordingPush) Enqueue(subscription store.PushSubscription, payload push.Payload) bool { r.jobs = append(r.jobs, recordedPush{subscription: subscription, payload: payload}) return true } // createTestDM открывает личную беседу между автором и указанным участником // и возвращает идентификатор комнаты. func createTestDM(t *testing.T, srv *Server, cookie *http.Cookie, userID string) string { t.Helper() rec := doJSON(t, srv, http.MethodPost, "/api/v1/users/@me/channels", `{"recipient_id":"`+userID+`"}`, cookie) if rec.Code != http.StatusOK { t.Fatalf("открыть личную беседу = %d, body = %s", rec.Code, rec.Body.String()) } payload := decodeResponse[struct { Channel struct { ID string `json:"id"` } `json:"channel"` }](t, rec) if payload.Channel.ID == "" { t.Fatalf("беседа без идентификатора: %s", rec.Body.String()) } return payload.Channel.ID } // savePushSubscription заводит подписку устройства напрямую в БД. func savePushSubscription(t *testing.T, st *store.Store, userID uint64, endpoint string) store.PushSubscription { t.Helper() subscription, err := st.SavePushSubscription(context.Background(), store.SavePushSubscriptionParams{ UserID: userID, Endpoint: endpoint, P256dh: "p256dh", Auth: "auth", }) if err != nil { t.Fatalf("save subscription: %v", err) } return *subscription } // TestPushNotifiesDirectMessage: личное сообщение уходит на устройство // получателя, если он не в клиенте (AGENT.md 7.16, Фаза 7). func TestPushNotifiesDirectMessage(t *testing.T) { srv, st := newPushTestServer(t, true) recorder := &recordingPush{enabled: true, publicKey: "key"} srv.push = recorder authorCookie := registerAndLogin(t, srv, "push_author", "push-author@example.com") registerAndLogin(t, srv, "push_target", "push-target@example.com") targetID := mustUserID(t, srv, "push-target@example.com") savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/target") channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID)) rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", `{"content":"привет, это личное"}`, authorCookie) if rec.Code != http.StatusOK { t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String()) } if len(recorder.jobs) != 1 { t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs)) } job := recorder.jobs[0] if job.payload.Kind != "direct" || job.payload.Body != "привет, это личное" { t.Fatalf("неожиданное уведомление: %+v", job.payload) } if job.payload.ChannelID != channelID { t.Fatalf("комната уведомления %q, ожидалась %q", job.payload.ChannelID, channelID) } if job.payload.URL != "/app/friends/"+channelID { t.Fatalf("ссылка = %q", job.payload.URL) } if job.subscription.Endpoint != "https://fcm.googleapis.com/fcm/send/target" { t.Fatalf("подписка = %q", job.subscription.Endpoint) } } // TestPushNotifiesMention: упоминание в комнате сервера уходит получателю; // обычное сообщение — нет. func TestPushNotifiesMention(t *testing.T) { fixture := newMessagingFixture(t) recorder := &recordingPush{enabled: true, publicKey: "key"} fixture.srv.push = recorder savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/member") // Обычное сообщение: упоминаний нет — push не нужен. rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages", `{"content":"просто сообщение"}`, fixture.ownerCookie) if rec.Code != http.StatusOK { t.Fatalf("отправка сообщения = %d, body = %s", rec.Code, rec.Body.String()) } if len(recorder.jobs) != 0 { t.Fatalf("без упоминания поставлено уведомлений %d", len(recorder.jobs)) } // Упоминание: уведомление уходит со ссылкой на комнату сервера. rec = doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages", `{"content":"<@`+fixture.memberID+`> посмотри"}`, fixture.ownerCookie) if rec.Code != http.StatusOK { t.Fatalf("отправка упоминания = %d, body = %s", rec.Code, rec.Body.String()) } if len(recorder.jobs) != 1 { t.Fatalf("поставлено уведомлений %d, ожидалось 1", len(recorder.jobs)) } payload := recorder.jobs[0].payload if payload.Kind != "mention" { t.Fatalf("вид уведомления = %q", payload.Kind) } if !strings.Contains(payload.Body, "посмотри") { t.Fatalf("тело уведомления = %q", payload.Body) } if payload.ChannelID != fixture.openChannel { t.Fatalf("комната уведомления %q, ожидалась %q", payload.ChannelID, fixture.openChannel) } if payload.URL != "/app/"+fixture.guildID+"/"+fixture.openChannel { t.Fatalf("ссылка = %q", payload.URL) } if payload.Title == "" || !strings.Contains(payload.Title, "#") { t.Fatalf("заголовок уведомления = %q", payload.Title) } } // TestPushSkipsOwnMessages: свои сообщения уведомлений не порождают — как в // desktopNotifications.ts. func TestPushSkipsOwnMessages(t *testing.T) { srv, st := newPushTestServer(t, true) recorder := &recordingPush{enabled: true, publicKey: "key"} srv.push = recorder cookie := registerAndLogin(t, srv, "push_self", "push-self@example.com") selfID := mustUserID(t, srv, "push-self@example.com") savePushSubscription(t, st, selfID, "https://fcm.googleapis.com/fcm/send/self") otherCookie := registerAndLogin(t, srv, "push_other", "push-other@example.com") otherID := mustUserID(t, srv, "push-other@example.com") channelID := createTestDM(t, srv, cookie, formatSnowflake(otherID)) // Отправляем другому, но получатель — тоже автор? Нет: подписка только у // автора, а сообщение уходит второму участнику — уведомлений быть не должно. rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", `{"content":"<@`+formatSnowflake(selfID)+`> себе"}`, cookie) if rec.Code != http.StatusOK { t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String()) } if len(recorder.jobs) != 0 { t.Fatalf("своё сообщение дало уведомления: %+v", recorder.jobs) } _ = otherCookie } // TestPushSkipsOnlineUsers: если получатель в клиенте (есть соединение шлюза), // уведомление покажет сам клиент — push не отправляем. func TestPushSkipsOnlineUsers(t *testing.T) { srv, st := newPushTestServer(t, true) recorder := &recordingPush{enabled: true, publicKey: "key"} srv.push = recorder authorCookie := registerAndLogin(t, srv, "push_online_author", "push-online-author@example.com") targetCookie := registerAndLogin(t, srv, "push_online_target", "push-online-target@example.com") targetID := mustUserID(t, srv, "push-online-target@example.com") savePushSubscription(t, st, targetID, "https://fcm.googleapis.com/fcm/send/online") channelID := createTestDM(t, srv, authorCookie, formatSnowflake(targetID)) // Настоящее соединение шлюза за получателя: он «в клиенте». httpServer := httptest.NewServer(srv.Handler()) t.Cleanup(httpServer.Close) dialGateway(t, httpServer, targetCookie) rec := doJSON(t, srv, http.MethodPost, "/api/v1/channels/"+channelID+"/messages", `{"content":"он в клиенте"}`, authorCookie) if rec.Code != http.StatusOK { t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String()) } if len(recorder.jobs) != 0 { t.Fatalf("пользователю в клиенте отправлен push: %+v", recorder.jobs) } } // TestPushSkippedWhenDisabled: без VAPID-ключа сервер не трогает подписки. func TestPushSkippedWhenDisabled(t *testing.T) { fixture := newMessagingFixture(t) recorder := &recordingPush{enabled: false} fixture.srv.push = recorder savePushSubscription(t, fixture.srv.store, guildIDOf(t, fixture.memberID), "https://fcm.googleapis.com/fcm/send/off") rec := doJSON(t, fixture.srv, http.MethodPost, "/api/v1/channels/"+fixture.openChannel+"/messages", `{"content":"<@`+fixture.memberID+`> привет"}`, fixture.ownerCookie) if rec.Code != http.StatusOK { t.Fatalf("отправка = %d, body = %s", rec.Code, rec.Body.String()) } if len(recorder.jobs) != 0 { t.Fatalf("выключенный push всё равно отправил: %+v", recorder.jobs) } }