feat(admin-console): Stage 6 — mail & notifications domain
Tests · Go / test (push) Successful in 2m2s
Tests · Go / test (pull_request) Successful in 1m59s
Tests · Integration / integration (pull_request) Successful in 1m43s

Add the mail, notifications, and broadcast pages over the mail, notification,
and diplomail services (no new business logic), completing the operator console.

- GET  /_gm/mail                         deliveries (paginated) + dead-letters
- GET  /_gm/mail/deliveries/{id}         delivery detail + attempts
- POST /_gm/mail/deliveries/{id}/resend  re-enqueue a non-sent delivery
- GET  /_gm/notifications                notifications + dead-letters + malformed
- GET/POST /_gm/broadcast                multi-game admin diplomatic broadcast

Console depends on MailAdmin / NotificationAdmin / DiplomailAdmin interfaces
(satisfied by the concrete services); pages render in tests without a database.
Delivery detail and dead-letters live under /_gm/mail/deliveries/* and
/_gm/mail/... static segments to avoid a param/static route conflict. Resend
and broadcast flow through the CSRF guard.

Tests: mail page, delivery detail (+ not-found), resend (+ bad-CSRF),
notifications overview, broadcast form + send (input assertions) + bad game
ids, and unavailable. Plus an integration test that drives /_gm end to end
through the real gateway → backend (401 challenge + authenticated dashboard).

Docs: backend/docs/admin-console.md page inventory completed.
This commit is contained in:
Ilia Denisov
2026-05-31 20:43:12 +02:00
parent 87a272166b
commit 7cac910de4
13 changed files with 856 additions and 0 deletions
@@ -33,6 +33,9 @@ type AdminConsoleHandlers struct {
runtime RuntimeAdmin
engineVersions EngineVersionAdmin
operators OperatorAdmin
mail MailAdmin
notifications NotificationAdmin
diplomail DiplomailAdmin
logger *zap.Logger
}
@@ -51,6 +54,9 @@ type AdminConsoleDeps struct {
Runtime RuntimeAdmin
EngineVersions EngineVersionAdmin
Operators OperatorAdmin
Mail MailAdmin
Notifications NotificationAdmin
Diplomail DiplomailAdmin
Logger *zap.Logger
}
@@ -92,6 +98,9 @@ func NewAdminConsoleHandlers(deps AdminConsoleDeps) *AdminConsoleHandlers {
runtime: deps.Runtime,
engineVersions: deps.EngineVersions,
operators: deps.Operators,
mail: deps.Mail,
notifications: deps.Notifications,
diplomail: deps.Diplomail,
logger: logger.Named("http.admin.console"),
}
}
@@ -0,0 +1,327 @@
package server
import (
"context"
"errors"
"fmt"
"net/http"
"strings"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/mail"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/server/clientip"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"go.uber.org/zap"
)
// MailAdmin is the subset of the mail service the console uses.
type MailAdmin interface {
AdminListDeliveries(ctx context.Context, page, pageSize int) (mail.AdminListDeliveriesPage, error)
AdminGetDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
AdminListAttempts(ctx context.Context, deliveryID uuid.UUID) ([]mail.Attempt, error)
AdminResendDelivery(ctx context.Context, deliveryID uuid.UUID) (mail.Delivery, error)
AdminListDeadLetters(ctx context.Context, page, pageSize int) (mail.AdminListDeadLettersPage, error)
}
// NotificationAdmin is the subset of the notification service the console uses.
type NotificationAdmin interface {
AdminListNotifications(ctx context.Context, page, pageSize int) (notification.AdminListNotificationsPage, error)
AdminListDeadLetters(ctx context.Context, page, pageSize int) (notification.AdminListDeadLettersPage, error)
AdminListMalformed(ctx context.Context, page, pageSize int) (notification.AdminListMalformedPage, error)
}
// DiplomailAdmin is the subset of the diplomail service the console uses.
type DiplomailAdmin interface {
SendAdminMultiGameBroadcast(ctx context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error)
}
const consoleSnapshotPageSize = 50
// MailPage renders GET /_gm/mail — paginated deliveries plus a dead-letter snapshot.
func (h *AdminConsoleHandlers) MailPage() gin.HandlerFunc {
return func(c *gin.Context) {
if h.mail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
return
}
page := parsePositiveQueryInt(c.Query("page"), 1)
pageSize := parsePositiveQueryInt(c.Query("page_size"), 50)
ctx := c.Request.Context()
deliveries, err := h.mail.AdminListDeliveries(ctx, page, pageSize)
if err != nil {
h.logger.Error("admin console: list deliveries", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load deliveries.", "bad", "/_gm/")
return
}
dead, err := h.mail.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list mail dead-letters", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load dead-letters.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "mail", "mail", "Mail", toMailData(deliveries, dead))
}
}
// MailDeliveryDetail renders GET /_gm/mail/deliveries/:delivery_id.
func (h *AdminConsoleHandlers) MailDeliveryDetail() gin.HandlerFunc {
return func(c *gin.Context) {
if h.mail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
return
}
deliveryID, ok := parseConsoleDeliveryID(c, h)
if !ok {
return
}
ctx := c.Request.Context()
delivery, err := h.mail.AdminGetDelivery(ctx, deliveryID)
if err != nil {
if errors.Is(err, mail.ErrDeliveryNotFound) {
h.renderMessage(c, http.StatusNotFound, "mail", "Delivery not found", "No such delivery.", "bad", "/_gm/mail")
return
}
h.logger.Error("admin console: get delivery", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load the delivery.", "bad", "/_gm/mail")
return
}
attempts, err := h.mail.AdminListAttempts(ctx, deliveryID)
if err != nil {
h.logger.Error("admin console: list attempts", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Mail", "Failed to load attempts.", "bad", "/_gm/mail")
return
}
h.render(c, http.StatusOK, "mail_delivery", "mail", "Delivery", toMailDeliveryDetail(delivery, attempts))
}
}
// MailResend handles POST /_gm/mail/deliveries/:delivery_id/resend.
func (h *AdminConsoleHandlers) MailResend() gin.HandlerFunc {
return func(c *gin.Context) {
if h.mail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Mail", "Mail administration is not available.", "bad", "/_gm/")
return
}
deliveryID, ok := parseConsoleDeliveryID(c, h)
if !ok {
return
}
back := "/_gm/mail/deliveries/" + deliveryID.String()
if _, err := h.mail.AdminResendDelivery(c.Request.Context(), deliveryID); err != nil {
h.logger.Error("admin console: resend delivery", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Resend failed", "Failed to resend the delivery (it may already be sent).", "bad", back)
return
}
c.Redirect(http.StatusSeeOther, back)
}
}
// NotificationsPage renders GET /_gm/notifications — notifications, dead-letters,
// and malformed intents on one overview page.
func (h *AdminConsoleHandlers) NotificationsPage() gin.HandlerFunc {
return func(c *gin.Context) {
if h.notifications == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Notifications", "Notification administration is not available.", "bad", "/_gm/")
return
}
ctx := c.Request.Context()
notifications, err := h.notifications.AdminListNotifications(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list notifications", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load notifications.", "bad", "/_gm/")
return
}
dead, err := h.notifications.AdminListDeadLetters(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list notification dead-letters", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load dead-letters.", "bad", "/_gm/")
return
}
malformed, err := h.notifications.AdminListMalformed(ctx, 1, consoleSnapshotPageSize)
if err != nil {
h.logger.Error("admin console: list malformed intents", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Notifications", "Failed to load malformed intents.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "notifications", "mail", "Notifications", toNotificationsData(notifications, dead, malformed))
}
}
// BroadcastForm renders GET /_gm/broadcast.
func (h *AdminConsoleHandlers) BroadcastForm() gin.HandlerFunc {
return func(c *gin.Context) {
if h.diplomail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
return
}
h.render(c, http.StatusOK, "broadcast", "mail", "Broadcast", nil)
}
}
// BroadcastSend handles POST /_gm/broadcast — multi-game admin broadcast.
func (h *AdminConsoleHandlers) BroadcastSend() gin.HandlerFunc {
return func(c *gin.Context) {
if h.diplomail == nil {
h.renderMessage(c, http.StatusServiceUnavailable, "mail", "Broadcast", "Broadcast is not available.", "bad", "/_gm/")
return
}
username, _ := basicauth.UsernameFromContext(c.Request.Context())
gameIDs, err := parseGameIDList(c.PostForm("game_ids"))
if err != nil {
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "Game IDs must be valid UUIDs.", "bad", "/_gm/broadcast")
return
}
_, total, err := h.diplomail.SendAdminMultiGameBroadcast(c.Request.Context(), diplomail.SendMultiGameBroadcastInput{
CallerUsername: username,
Scope: strings.TrimSpace(c.PostForm("scope")),
GameIDs: gameIDs,
RecipientScope: strings.TrimSpace(c.PostForm("recipients")),
Subject: strings.TrimSpace(c.PostForm("subject")),
Body: c.PostForm("body"),
SenderIP: clientip.ExtractSourceIP(c),
})
if err != nil {
if errors.Is(err, diplomail.ErrInvalidInput) {
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "The broadcast was rejected: check the scope, recipients, and body.", "bad", "/_gm/broadcast")
return
}
h.logger.Error("admin console: broadcast", zap.Error(err))
h.renderMessage(c, http.StatusInternalServerError, "mail", "Broadcast failed", "Failed to send the broadcast.", "bad", "/_gm/broadcast")
return
}
h.renderMessage(c, http.StatusOK, "mail", "Broadcast sent", fmt.Sprintf("Broadcast delivered to %d recipients.", total), "ok", "/_gm/broadcast")
}
}
// parseConsoleDeliveryID parses the delivery_id path parameter, rendering a
// console message page on failure.
func parseConsoleDeliveryID(c *gin.Context, h *AdminConsoleHandlers) (uuid.UUID, bool) {
parsed, err := uuid.Parse(c.Param("delivery_id"))
if err != nil {
h.renderMessage(c, http.StatusBadRequest, "mail", "Invalid input", "delivery_id must be a valid UUID.", "bad", "/_gm/mail")
return uuid.Nil, false
}
return parsed, true
}
// parseGameIDList parses a comma-separated list of UUIDs, ignoring blanks.
func parseGameIDList(raw string) ([]uuid.UUID, error) {
fields := strings.Split(raw, ",")
ids := make([]uuid.UUID, 0, len(fields))
for _, field := range fields {
field = strings.TrimSpace(field)
if field == "" {
continue
}
parsed, err := uuid.Parse(field)
if err != nil {
return nil, err
}
ids = append(ids, parsed)
}
return ids, nil
}
func toMailData(deliveries mail.AdminListDeliveriesPage, dead mail.AdminListDeadLettersPage) adminconsole.MailData {
data := adminconsole.MailData{
Deliveries: make([]adminconsole.MailDeliveryRow, 0, len(deliveries.Items)),
DeadLetters: make([]adminconsole.MailDeadLetterRow, 0, len(dead.Items)),
Page: deliveries.Page,
PageSize: deliveries.PageSize,
Total: deliveries.Total,
PrevPage: deliveries.Page - 1,
NextPage: deliveries.Page + 1,
HasPrev: deliveries.Page > 1,
HasNext: int64(deliveries.Page*deliveries.PageSize) < deliveries.Total,
}
for _, d := range deliveries.Items {
data.Deliveries = append(data.Deliveries, adminconsole.MailDeliveryRow{
DeliveryID: d.DeliveryID.String(),
Template: d.TemplateID,
Status: d.Status,
Attempts: d.Attempts,
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
Created: fmtConsoleTime(d.CreatedAt),
})
}
for _, d := range dead.Items {
data.DeadLetters = append(data.DeadLetters, adminconsole.MailDeadLetterRow{
DeliveryID: d.DeliveryID.String(),
Reason: d.Reason,
Archived: fmtConsoleTime(d.ArchivedAt),
})
}
return data
}
func toMailDeliveryDetail(d mail.Delivery, attempts []mail.Attempt) adminconsole.MailDeliveryDetail {
detail := adminconsole.MailDeliveryDetail{
DeliveryID: d.DeliveryID.String(),
Template: d.TemplateID,
Status: d.Status,
Attempts: d.Attempts,
NextAttempt: fmtConsoleTimePtr(d.NextAttemptAt),
LastError: d.LastError,
Created: fmtConsoleTime(d.CreatedAt),
Sent: fmtConsoleTimePtr(d.SentAt),
DeadLettered: fmtConsoleTimePtr(d.DeadLetteredAt),
CanResend: d.Status != mail.StatusSent,
AttemptRows: make([]adminconsole.MailAttemptRow, 0, len(attempts)),
}
for _, a := range attempts {
detail.AttemptRows = append(detail.AttemptRows, adminconsole.MailAttemptRow{
AttemptNo: a.AttemptNo,
Outcome: a.Outcome,
Started: fmtConsoleTime(a.StartedAt),
Finished: fmtConsoleTimePtr(a.FinishedAt),
Error: a.Error,
})
}
return detail
}
func toNotificationsData(notifications notification.AdminListNotificationsPage, dead notification.AdminListDeadLettersPage, malformed notification.AdminListMalformedPage) adminconsole.NotificationsData {
data := adminconsole.NotificationsData{
Notifications: make([]adminconsole.NotificationRow, 0, len(notifications.Items)),
DeadLetters: make([]adminconsole.NotificationDeadLetterRow, 0, len(dead.Items)),
Malformed: make([]adminconsole.MalformedRow, 0, len(malformed.Items)),
}
for _, n := range notifications.Items {
data.Notifications = append(data.Notifications, adminconsole.NotificationRow{
NotificationID: n.NotificationID.String(),
Kind: n.Kind,
UserID: optionalUUID(n.UserID),
Created: fmtConsoleTime(n.CreatedAt),
})
}
for _, d := range dead.Items {
data.DeadLetters = append(data.DeadLetters, adminconsole.NotificationDeadLetterRow{
NotificationID: d.NotificationID.String(),
RouteID: d.RouteID.String(),
Reason: d.Reason,
Archived: fmtConsoleTime(d.ArchivedAt),
})
}
for _, m := range malformed.Items {
data.Malformed = append(data.Malformed, adminconsole.MalformedRow{
ID: m.ID.String(),
Reason: m.Reason,
Received: fmtConsoleTime(m.ReceivedAt),
})
}
return data
}
// optionalUUID renders a nullable user id; system-scoped rows have none.
func optionalUUID(id *uuid.UUID) string {
if id == nil {
return "—"
}
return id.String()
}
@@ -0,0 +1,242 @@
package server
import (
"context"
"net/http"
"strings"
"testing"
"time"
"galaxy/backend/internal/adminconsole"
"galaxy/backend/internal/diplomail"
"galaxy/backend/internal/mail"
"galaxy/backend/internal/notification"
"galaxy/backend/internal/server/middleware/basicauth"
"github.com/google/uuid"
"go.uber.org/zap"
)
type fakeMailAdmin struct {
deliveries mail.AdminListDeliveriesPage
dead mail.AdminListDeadLettersPage
delivery mail.Delivery
getErr error
attempts []mail.Attempt
resendCalls int
}
func (f *fakeMailAdmin) AdminListDeliveries(context.Context, int, int) (mail.AdminListDeliveriesPage, error) {
return f.deliveries, nil
}
func (f *fakeMailAdmin) AdminGetDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
return f.delivery, f.getErr
}
func (f *fakeMailAdmin) AdminListAttempts(context.Context, uuid.UUID) ([]mail.Attempt, error) {
return f.attempts, nil
}
func (f *fakeMailAdmin) AdminResendDelivery(context.Context, uuid.UUID) (mail.Delivery, error) {
f.resendCalls++
return f.delivery, nil
}
func (f *fakeMailAdmin) AdminListDeadLetters(context.Context, int, int) (mail.AdminListDeadLettersPage, error) {
return f.dead, nil
}
type fakeNotificationAdmin struct {
notifications notification.AdminListNotificationsPage
dead notification.AdminListDeadLettersPage
malformed notification.AdminListMalformedPage
}
func (f *fakeNotificationAdmin) AdminListNotifications(context.Context, int, int) (notification.AdminListNotificationsPage, error) {
return f.notifications, nil
}
func (f *fakeNotificationAdmin) AdminListDeadLetters(context.Context, int, int) (notification.AdminListDeadLettersPage, error) {
return f.dead, nil
}
func (f *fakeNotificationAdmin) AdminListMalformed(context.Context, int, int) (notification.AdminListMalformedPage, error) {
return f.malformed, nil
}
type fakeDiplomailAdmin struct {
total int
err error
broadcastCalls int
last diplomail.SendMultiGameBroadcastInput
}
func (f *fakeDiplomailAdmin) SendAdminMultiGameBroadcast(_ context.Context, in diplomail.SendMultiGameBroadcastInput) ([]diplomail.Message, int, error) {
f.broadcastCalls++
f.last = in
if f.err != nil {
return nil, 0, f.err
}
return nil, f.total, nil
}
func mailConsoleRouter(t *testing.T, m MailAdmin, n NotificationAdmin, d DiplomailAdmin) (http.Handler, *adminconsole.CSRF) {
t.Helper()
csrf := adminconsole.NewCSRF([]byte("test-key"))
handler, err := NewRouter(RouterDependencies{
Logger: zap.NewNop(),
AdminVerifier: basicauth.NewStaticVerifier("secret"),
AdminConsole: NewAdminConsoleHandlers(AdminConsoleDeps{CSRF: csrf, Mail: m, Notifications: n, Diplomail: d}),
})
if err != nil {
t.Fatalf("NewRouter: %v", err)
}
return handler, csrf
}
func TestConsoleMailPage(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{
deliveries: mail.AdminListDeliveriesPage{
Items: []mail.Delivery{{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", CreatedAt: time.Now()}},
Page: 1, PageSize: 50, Total: 1,
},
dead: mail.AdminListDeadLettersPage{
Items: []mail.DeadLetter{{DeliveryID: uuid.New(), Reason: "smtp 550", ArchivedAt: time.Now()}},
},
}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consoleGet(t, router, "/_gm/mail")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"auth.login_code", "pending", "Dead-letters", "smtp 550"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("mail page missing %q", want)
}
}
}
func TestConsoleMailDeliveryDetail(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{
delivery: mail.Delivery{DeliveryID: id, TemplateID: "auth.login_code", Status: "pending", Attempts: 2},
attempts: []mail.Attempt{{AttemptNo: 1, Outcome: "transient_failure", StartedAt: time.Now(), Error: "timeout"}},
}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+id.String())
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{id.String(), "auth.login_code", "Attempts", "transient_failure", "Resend"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("delivery detail missing %q", want)
}
}
}
func TestConsoleMailDeliveryDetailNotFound(t *testing.T) {
m := &fakeMailAdmin{getErr: mail.ErrDeliveryNotFound}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consoleGet(t, router, "/_gm/mail/deliveries/"+uuid.New().String())
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rec.Code)
}
}
func TestConsoleMailResend(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
router, csrf := mailConsoleRouter(t, m, nil, nil)
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "_csrf="+csrf.Token("ops"))
if rec.Code != http.StatusSeeOther {
t.Fatalf("status = %d, want 303", rec.Code)
}
if m.resendCalls != 1 {
t.Errorf("AdminResendDelivery called %d times, want 1", m.resendCalls)
}
}
func TestConsoleMailResendRejectsBadCSRF(t *testing.T) {
id := uuid.New()
m := &fakeMailAdmin{delivery: mail.Delivery{DeliveryID: id}}
router, _ := mailConsoleRouter(t, m, nil, nil)
rec := consolePost(t, router, "/_gm/mail/deliveries/"+id.String()+"/resend", "")
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want 403", rec.Code)
}
if m.resendCalls != 0 {
t.Error("resend must not run without a CSRF token")
}
}
func TestConsoleNotificationsPage(t *testing.T) {
n := &fakeNotificationAdmin{
notifications: notification.AdminListNotificationsPage{Items: []notification.Notification{{NotificationID: uuid.New(), Kind: "lobby.invite.received"}}},
dead: notification.AdminListDeadLettersPage{Items: []notification.DeadLetter{{NotificationID: uuid.New(), RouteID: uuid.New(), Reason: "push gone"}}},
malformed: notification.AdminListMalformedPage{Items: []notification.MalformedIntent{{ID: uuid.New(), Reason: "bad shape"}}},
}
router, _ := mailConsoleRouter(t, nil, n, nil)
rec := consoleGet(t, router, "/_gm/notifications")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
for _, want := range []string{"lobby.invite.received", "push gone", "bad shape", "Malformed intents"} {
if !strings.Contains(rec.Body.String(), want) {
t.Errorf("notifications page missing %q", want)
}
}
}
func TestConsoleBroadcastForm(t *testing.T) {
router, _ := mailConsoleRouter(t, nil, nil, &fakeDiplomailAdmin{})
rec := consoleGet(t, router, "/_gm/broadcast")
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if !strings.Contains(rec.Body.String(), "Send broadcast") {
t.Error("broadcast form missing")
}
}
func TestConsoleBroadcastSend(t *testing.T) {
d := &fakeDiplomailAdmin{total: 5}
router, csrf := mailConsoleRouter(t, nil, nil, d)
form := "_csrf=" + csrf.Token("ops") + "&scope=all_running&recipients=active&body=hello"
rec := consolePost(t, router, "/_gm/broadcast", form)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "5 recipients") {
t.Errorf("broadcast result missing recipient count; body=%s", rec.Body.String())
}
if d.broadcastCalls != 1 || d.last.Scope != "all_running" || d.last.Body != "hello" || d.last.CallerUsername != "ops" {
t.Errorf("broadcast input = %+v (calls=%d)", d.last, d.broadcastCalls)
}
}
func TestConsoleBroadcastSendBadGameIDs(t *testing.T) {
d := &fakeDiplomailAdmin{}
router, csrf := mailConsoleRouter(t, nil, nil, d)
form := "_csrf=" + csrf.Token("ops") + "&scope=selected&game_ids=not-a-uuid&body=hello"
rec := consolePost(t, router, "/_gm/broadcast", form)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rec.Code)
}
if d.broadcastCalls != 0 {
t.Error("broadcast must not run with invalid game ids")
}
}
func TestConsoleMailUnavailable(t *testing.T) {
router, _ := mailConsoleRouter(t, nil, nil, nil)
rec := consoleGet(t, router, "/_gm/mail")
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status = %d, want 503", rec.Code)
}
}
+7
View File
@@ -414,6 +414,13 @@ func registerAdminConsoleRoutes(router *gin.Engine, deps RouterDependencies) {
group.POST("/operators/:username/disable", deps.AdminConsole.OperatorDisable())
group.POST("/operators/:username/enable", deps.AdminConsole.OperatorEnable())
group.POST("/operators/:username/reset-password", deps.AdminConsole.OperatorResetPassword())
group.GET("/mail", deps.AdminConsole.MailPage())
group.GET("/mail/deliveries/:delivery_id", deps.AdminConsole.MailDeliveryDetail())
group.POST("/mail/deliveries/:delivery_id/resend", deps.AdminConsole.MailResend())
group.GET("/notifications", deps.AdminConsole.NotificationsPage())
group.GET("/broadcast", deps.AdminConsole.BroadcastForm())
group.POST("/broadcast", deps.AdminConsole.BroadcastSend())
}
// allowedMethodsForPath returns the comma-separated list of methods