Files
paliad/internal/services/broadcast_service_live_test.go
m 52ee319fd8 feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends
personalised emails to a filter-narrowed subset of the team. Each recipient
gets their own envelope (per-recipient privacy, no shared To: list); From
stays on the SMTP infrastructure address with Reply-To set to the human
sender so replies route correctly without forging DKIM/SPF.

Backend
- Migration 057: paliad.email_broadcasts (subject, body, sender_id,
  template_key, recipient_filter jsonb, recipient_user_ids uuid[],
  send_report jsonb, sent_at). RLS: senders read own rows, global_admin
  reads all; inserts must self-attribute. No CHECK-constraint extension to
  partner_unit_events — broadcasts get their own table per the lock.
- BroadcastService (internal/services/broadcast_service.go): validates
  subject/body/recipient cap (100), enforces project_lead-OR-global_admin,
  persists audit row, dispatches via 5-deep goroutine pool with 15s
  per-send timeout. Send report (sent/failed counts + per-recipient errors)
  is captured back into email_broadcasts.send_report.
- markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**,
  *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped
  first; only whitelisted tags re-emitted. Script tags and javascript:
  URLs can't slip through.
- Placeholder substitution: {{name}}, {{first_name}},
  {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass
  through unchanged.
- mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header
  on top of the existing multipart/alternative envelope.
- TeamService.ListMembershipsIndex: visibility-gated user→project_ids
  index. Powers the /team project multi-select filter without N round
  trips per project.
- Handlers: POST /api/team/broadcast (gateOnboarded; service enforces
  authority), GET /api/team/memberships, GET /api/admin/broadcasts (list),
  GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page).
  /admin/broadcasts is gateOnboarded (not adminGate) so leads can see
  their own sends; the service applies the per-row visibility filter.

Frontend
- /team gains a project multi-select chip dropdown (visible projects
  loaded from /api/projects, intersected against the memberships index)
  alongside the existing office and role filters.
- "E-Mail an Auswahl (N)" button appears only when canBroadcast() is
  true (global_admin always; non-admin needs lead-ship on selected
  projects, or at least one project when no filter is set). Server still
  re-checks per send.
- Compose modal (broadcast.ts): subject + body textarea + optional
  template dropdown (loads existing email templates and strips Go-template
  directives) + recipient preview (first 5 + expand) + send. Hard-blocks
  empty subject/body and N=0. Shows per-send report on success.
- /admin/broadcasts viewer: read-only list with click-row-to-expand
  detail (subject, body, recipient list, send_report counts).

Tests
- broadcast_service_test.go: placeholder substitution table-driven,
  Markdown safe-render incl. XSS guards (<script>, javascript: URLs),
  validation cases (empty subject/body, recipient cap, invalid email),
  signature rendering DE/EN.
- broadcast_service_live_test.go: end-to-end Send + List + Get + visibility
  rules (lead can send on own project, member cannot, admin sees all,
  member can't read lead's row). Skips when TEST_DATABASE_URL is unset.

i18n: 60 new keys × 2 langs (broadcast modal labels, error messages,
recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/
load_error).
2026-05-07 20:58:57 +02:00

192 lines
6.1 KiB
Go

package services
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestBroadcastService_SendAndAudit_Live exercises the full BroadcastService
// pipeline against a real Postgres: the row lands in paliad.email_broadcasts,
// the send_report jsonb captures per-recipient outcomes, and List/Get
// honours the visibility rules (sender sees own; global_admin sees all).
//
// SMTP delivery is not exercised — the MailService is left disabled
// (Enabled() == false) so sendOne short-circuits cleanly. That's the same
// contract the dev/preview deploys run under.
//
// Skipped when TEST_DATABASE_URL is unset.
func TestBroadcastService_SendAndAudit_Live(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
leadID := uuid.New()
memberID := uuid.New()
otherSenderID := uuid.New()
projectID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Bcast Lead', 'munich', 'standard', 'de'),
($3, $4, 'Bcast Mem', 'munich', 'standard', 'de'),
($5, $6, 'Bcast Admin', 'munich', 'global_admin', 'de')`,
leadID, "bcast-lead@hlc.com",
memberID, "bcast-member@hlc.com",
otherSenderID, "bcast-admin@hlc.com",
); err != nil {
t.Fatalf("seed users: %v", err)
}
t.Cleanup(func() {
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.email_broadcasts WHERE sender_id = ANY($1)`,
[]string{leadID.String(), otherSenderID.String()})
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
_, _ = pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = ANY($1)`,
[]string{leadID.String(), memberID.String(), otherSenderID.String()})
})
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, path, title, status, created_by)
VALUES ($1, 'project', $1::text, 'Bcast Project', 'active', $2)`,
projectID, leadID,
); err != nil {
t.Fatalf("seed project: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, inherited, added_by)
VALUES ($1, $2, 'lead', false, $2),
($1, $3, 'associate', false, $2)`,
projectID, leadID, memberID,
); err != nil {
t.Fatalf("seed team: %v", err)
}
users := NewUserService(pool)
projectSvc := NewProjectService(pool, users)
teamSvc := NewTeamService(pool, projectSvc)
mailSvc, err := NewMailService()
if err != nil {
t.Fatalf("mail svc: %v", err)
}
tplSvc := NewEmailTemplateService(pool)
mailSvc.SetTemplateService(tplSvc)
bcast := NewBroadcastService(pool, mailSvc, users, teamSvc, tplSvc)
// --- 1. lead can send a broadcast on their project --------------
pid := projectID
report, err := bcast.Send(ctx, leadID, BroadcastInput{
ProjectID: &pid,
Subject: "Hallo Team",
Body: "Hi {{first_name}}, kurze Nachricht.",
Recipients: []BroadcastRecipient{{
UserID: memberID,
Email: "bcast-member@hlc.com",
DisplayName: "Bcast Mem",
FirstName: "Bcast",
RoleOnProject: "associate",
}},
RecipientFilter: map[string]any{"project_ids": []string{pid.String()}},
})
if err != nil {
t.Fatalf("Send (lead): %v", err)
}
if report.BroadcastID == uuid.Nil {
t.Fatal("BroadcastID empty")
}
if report.Total != 1 {
t.Errorf("Total=%d, want 1", report.Total)
}
if report.Sent != 1 || report.Failed != 0 {
t.Errorf("Sent=%d Failed=%d, want Sent=1 Failed=0", report.Sent, report.Failed)
}
// --- 2. non-lead sender (member) → forbidden --------------------
_, err = bcast.Send(ctx, memberID, BroadcastInput{
ProjectID: &pid,
Subject: "Should fail",
Body: "x",
Recipients: []BroadcastRecipient{{
UserID: leadID, Email: "bcast-lead@hlc.com", DisplayName: "Bcast Lead",
}},
})
if err == nil || !errorIs(err, ErrBroadcastForbidden) {
t.Errorf("non-lead Send: got %v, want ErrBroadcastForbidden", err)
}
// --- 3. global_admin sees all rows in List ----------------------
rowsAdmin, err := bcast.List(ctx, otherSenderID, 50)
if err != nil {
t.Fatalf("List(admin): %v", err)
}
foundOurRow := false
for _, r := range rowsAdmin {
if r.ID == report.BroadcastID {
foundOurRow = true
if r.RecipientCount != 1 {
t.Errorf("RecipientCount=%d, want 1", r.RecipientCount)
}
}
}
if !foundOurRow {
t.Error("admin's List did not include our broadcast")
}
// --- 4. lead sees own rows --------------------------------------
rowsLead, err := bcast.List(ctx, leadID, 50)
if err != nil {
t.Fatalf("List(lead): %v", err)
}
if len(rowsLead) == 0 || rowsLead[0].ID != report.BroadcastID {
t.Errorf("lead List didn't return own row; got %+v", rowsLead)
}
// --- 5. non-sender, non-admin gets nothing back -----------------
rowsMember, err := bcast.List(ctx, memberID, 50)
if err != nil {
t.Fatalf("List(member): %v", err)
}
for _, r := range rowsMember {
if r.ID == report.BroadcastID {
t.Errorf("member should not see lead's broadcast %s", r.ID)
}
}
// --- 6. Get returns full detail w/ recipients -------------------
detail, err := bcast.Get(ctx, leadID, report.BroadcastID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if detail.Subject != "Hallo Team" {
t.Errorf("Subject=%q", detail.Subject)
}
if len(detail.Recipients) != 1 {
t.Errorf("Recipients=%d, want 1", len(detail.Recipients))
}
if len(detail.Recipients) >= 1 && detail.Recipients[0].UserID != memberID {
t.Errorf("Recipients[0].UserID=%s, want %s", detail.Recipients[0].UserID, memberID)
}
// --- 7. member calling Get on lead's row → forbidden -----------
if _, err := bcast.Get(ctx, memberID, report.BroadcastID); err == nil ||
!errorIs(err, ErrBroadcastForbidden) {
t.Errorf("member Get: got %v, want ErrBroadcastForbidden", err)
}
}