572 lines
22 KiB
Go
572 lines
22 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/spf13/viper"
|
|
"github.com/stretchr/testify/mock"
|
|
"go.temporal.io/api/serviceerror"
|
|
"go.temporal.io/sdk/client"
|
|
"go.temporal.io/sdk/mocks"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
|
stripedb "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
|
|
stripewf "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/workflows"
|
|
)
|
|
|
|
// stripeConfigs is the installed-integration fixture for the Stripe
|
|
// provider page's configurationReadiness call, mirroring how the real
|
|
// Stripe adapter declares its required group (stripe-api-key).
|
|
var stripeConfigs = []IntegrationConfigInfo{
|
|
{
|
|
Key: "stripe",
|
|
DisplayName: "Stripe",
|
|
Keys: []config.ConfigKey{
|
|
{Name: "stripe-api-key", RequiredGroup: "Stripe", Secret: true, Usage: "Stripe secret key"},
|
|
},
|
|
},
|
|
}
|
|
|
|
// TestStripeIntegrationTemplate covers integration-settings' ADDED
|
|
// requirement ("The Stripe provider page carries its status and the
|
|
// delivery-queue report"), moved here from the settings page (design D24):
|
|
// the Status/Mode details list, and the delivery-queue's pending/retrying/
|
|
// dead-letter counts, coverage caption, and dead-letter table with
|
|
// operation identifiers as <code>. Template-level, mirroring the settings
|
|
// page's own render test.
|
|
func TestStripeIntegrationTemplate(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
data := StripeIntegrationData{
|
|
Configured: true,
|
|
ModeLabel: "Test mode",
|
|
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
|
|
DeliveryQueue: DeliveryQueue{Pending: 2, Retrying: 1, DeadLetter: 3, Available: true},
|
|
DeliveryQueueEntries: []DeadLetterEntry{
|
|
{OperationType: "create_stripe_price", ErrorMessage: "insufficient funds", Attempts: 5, UpdatedAt: "Jan 2, 2026 3:04 PM"},
|
|
},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
for _, want := range []string{
|
|
"Stripe",
|
|
"Configured",
|
|
"Test mode",
|
|
"Delivery queue",
|
|
"Tracks Stripe's queued outbound work only", // coverage caption
|
|
// The three counts render through the readout part, at the
|
|
// console's one readout size (overview-consistency D3, round 2).
|
|
`<dt class="small fw-semibold text-uppercase text-body-secondary">Pending</dt>`,
|
|
`<dd class="fs-3 fw-semibold mb-0">2</dd>`,
|
|
`<dt class="small fw-semibold text-uppercase text-body-secondary">Retrying</dt>`,
|
|
`<dd class="fs-3 fw-semibold mb-0">1</dd>`,
|
|
`<dt class="small fw-semibold text-uppercase text-body-secondary">Dead-letter</dt>`,
|
|
`<dd class="fs-3 fw-semibold mb-0 text-danger">3</dd>`, // alarm styling on the dead-letter bucket
|
|
"needs an operator", // dead-letter explanation
|
|
"<code class=\"text-nowrap\">create_stripe_price</code>", // operation identifier as <code>
|
|
"insufficient funds",
|
|
"table-record",
|
|
`href="/operator/integrations/stripe/settings"`, // link back to settings
|
|
`href="/operator/integrations"`, // trail: Operator / Integrations / Stripe
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("stripe provider page missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, "fs-4") {
|
|
t.Errorf("the delivery queue still sizes its counts by hand; the readout part owns the size, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestStripeIntegrationInboundEvents covers the Inbound events section, the
|
|
// delivery queue's inbound mirror: dead-lettered webhook events render with
|
|
// alarm styling and their event type as <code>; none renders the quiet line;
|
|
// an unavailable probe says so (stripe-integration-infrastructure "Failed
|
|
// events are retried, then dead-lettered").
|
|
func TestStripeIntegrationInboundEvents(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
render := func(data StripeIntegrationData) string {
|
|
t.Helper()
|
|
data.Configured = true
|
|
data.ModeLabel = "Test mode"
|
|
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
|
|
data.DeliveryQueue = DeliveryQueue{Available: true}
|
|
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
out := render(StripeIntegrationData{
|
|
InboundEvents: InboundEvents{DeadLetter: 2, Available: true},
|
|
InboundEventEntries: []DeadLetterEntry{
|
|
{OperationType: "invoice.paid", ErrorMessage: "resolve invoice mapping: no rows", Attempts: 5, UpdatedAt: "Jan 2, 2026 3:04 PM"},
|
|
},
|
|
})
|
|
for _, want := range []string{
|
|
"Inbound events",
|
|
"2 events could not be processed after retries and need an operator.",
|
|
`<code class="text-nowrap">invoice.paid</code>`,
|
|
"resolve invoice mapping: no rows",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("inbound events section missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, "Inbound events are processing normally") {
|
|
t.Error("the quiet line must not render alongside dead-lettered events")
|
|
}
|
|
|
|
out = render(StripeIntegrationData{InboundEvents: InboundEvents{Available: true}})
|
|
if !strings.Contains(out, "Inbound events are processing normally.") {
|
|
t.Errorf("no dead-lettered events must render the quiet line, got:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "could not be processed") {
|
|
t.Error("no alarm without dead-lettered events")
|
|
}
|
|
|
|
out = render(StripeIntegrationData{})
|
|
if !strings.Contains(out, "Inbound event health is unavailable.") {
|
|
t.Errorf("an unavailable probe must say so, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestStripeIntegrationNotConfigured covers the Not-configured state, from
|
|
// the same configurationReadiness resolution the settings page and the
|
|
// Integrations table use.
|
|
func TestStripeIntegrationNotConfigured(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
data := StripeIntegrationData{Configured: false, Missing: []string{"stripe-api-key"}, ModeLabel: "Test mode"}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if !strings.Contains(out, "Not configured") {
|
|
t.Errorf("unconfigured Stripe must read Not configured, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `title="Missing: stripe-api-key"`) {
|
|
t.Errorf("unconfigured Stripe must name its missing key, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestStripeIntegrationNoAlarmWithoutDeadLetter covers integration-settings'
|
|
// scenario "Only dead-letter outbox work raises alarm styling": pending and
|
|
// retrying entries alone render without alarm styling or a table.
|
|
func TestStripeIntegrationNoAlarmWithoutDeadLetter(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
data := StripeIntegrationData{
|
|
DeliveryQueueHeader: SectionHeader{Title: "Delivery queue"},
|
|
DeliveryQueue: DeliveryQueue{Pending: 2, Retrying: 1, Available: true},
|
|
}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
out := buf.String()
|
|
if strings.Contains(out, "text-danger") {
|
|
t.Errorf("draining queue (no dead-letter) rendered alarm styling, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "draining normally") {
|
|
t.Errorf("draining queue missing the draining-normally state")
|
|
}
|
|
}
|
|
|
|
// TestGetStripeIntegrationPageDeliveryQueue is a DB-backed end-to-end check
|
|
// that GetStripeIntegrationPage wires the shared outbox (core.outbox) into
|
|
// the Delivery queue section: a seeded dead-lettered row surfaces in the
|
|
// counts, the alarm styling, and the operation-identifier table, and the
|
|
// mode label follows the mode derived from the API key.
|
|
func TestGetStripeIntegrationPageDeliveryQueue(t *testing.T) {
|
|
database := newRollbackTestDB(t)
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO core.outbox (provider, action_type, status, attempts, error_message)
|
|
VALUES ('stripe', 'create_stripe_price', 'dead_letter', 5, 'card declined')`); err != nil {
|
|
t.Fatalf("fixture outbox row: %v", err)
|
|
}
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO core.outbox (provider, action_type, status) VALUES ('stripe', 'create_stripe_product', 'pending')`); err != nil {
|
|
t.Fatalf("fixture pending outbox row: %v", err)
|
|
}
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO core.webhook_events (provider, provider_event_id, event_type, payload, status, retry_count, error_message)
|
|
VALUES ('stripe', 'evt_page_dead', 'invoice.paid', '{}', 'dead_letter', 5, 'resolve invoice mapping: no rows')`); err != nil {
|
|
t.Fatalf("fixture dead-lettered webhook event: %v", err)
|
|
}
|
|
|
|
viper.Reset()
|
|
t.Cleanup(viper.Reset)
|
|
|
|
sm := scs.New()
|
|
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
|
|
Database: database,
|
|
StripeMode: "live",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
AuthConfig: &auth.Config{SessionManager: sm},
|
|
IntegrationConfigs: stripeConfigs,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
|
}
|
|
ctx, err := sm.Load(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("session Load: %v", err)
|
|
}
|
|
r := httptest.NewRequestWithContext(ctx, "GET", "/operator/integrations/stripe", nil)
|
|
w := httptest.NewRecorder()
|
|
h.GetStripeIntegrationPage(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("want 200, got %d, body:\n%s", w.Code, w.Body.String())
|
|
}
|
|
out := w.Body.String()
|
|
for _, want := range []string{
|
|
"Delivery queue",
|
|
"create_stripe_price",
|
|
"card declined",
|
|
"text-danger",
|
|
"needs an operator",
|
|
"Live mode", // the key is a live key
|
|
"Not configured", // stripeConfigs' required key is unresolved
|
|
// The inbound mirror: the dead-lettered webhook event, by type.
|
|
"Inbound events",
|
|
`<code class="text-nowrap">invoice.paid</code>`,
|
|
"resolve invoice mapping: no rows",
|
|
"1 event could not be processed after retries and needs an operator.",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("stripe provider page missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetStripeIntegrationPageConfigured covers the Configured state end to
|
|
// end, with the mode reading "Test mode" for a test key.
|
|
func TestGetStripeIntegrationPageConfigured(t *testing.T) {
|
|
viper.Reset()
|
|
viper.Set("stripe-api-key", "sk_test_x")
|
|
t.Cleanup(viper.Reset)
|
|
|
|
sm := scs.New()
|
|
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
|
|
StripeMode: "test",
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
AuthConfig: &auth.Config{SessionManager: sm},
|
|
IntegrationConfigs: stripeConfigs,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
|
}
|
|
ctx, err := sm.Load(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("session Load: %v", err)
|
|
}
|
|
r := httptest.NewRequestWithContext(ctx, "GET", "/operator/integrations/stripe", nil)
|
|
w := httptest.NewRecorder()
|
|
h.GetStripeIntegrationPage(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("want 200, got %d, body:\n%s", w.Code, w.Body.String())
|
|
}
|
|
out := w.Body.String()
|
|
if !strings.Contains(out, "Configured") {
|
|
t.Errorf("configured Stripe must read Configured, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Test mode") {
|
|
t.Errorf("a test key must read Test mode, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Queue health is unavailable") {
|
|
t.Errorf("a nil database must degrade the delivery queue to unavailable, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestStripeEnvironmentCheckLines covers the Environment check section's
|
|
// four resting lines (stripe-environment-stamp D3): the read-back has
|
|
// never run, one is under way, one finished under the key in force, and
|
|
// one finished under a key that has since been replaced.
|
|
func TestStripeEnvironmentCheckLines(t *testing.T) {
|
|
at := time.Date(2026, 9, 18, 15, 4, 0, 0, time.Local)
|
|
running := stripedb.EnvironmentCheckRecord{KeyFingerprint: "abc123", StartedAt: &at}
|
|
finished := stripedb.EnvironmentCheckRecord{
|
|
KeyFingerprint: "abc123", StartedAt: &at, FinishedAt: &at, Checked: 36, Stale: 2,
|
|
}
|
|
moved := finished
|
|
moved.KeyFingerprint = "deadbeef"
|
|
|
|
for _, tc := range []struct {
|
|
name string
|
|
rec stripedb.EnvironmentCheckRecord
|
|
fingerprint string
|
|
mapped int64
|
|
want string
|
|
}{
|
|
{"never run", stripedb.EnvironmentCheckRecord{}, "abc123", 0, "Not checked yet."},
|
|
{"under way", running, "abc123", 36, "Checking 36 Stripe ids under the current key."},
|
|
{"finished under this key", finished, "abc123", 0, "Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale."},
|
|
{"finished under another key", moved, "abc123", 34, "API key changed since the last check. 34 mappings unverified."},
|
|
{"one id under way", running, "abc123", 1, "Checking 1 Stripe id under the current key."},
|
|
{"one mapping unverified", moved, "abc123", 1, "API key changed since the last check. 1 mapping unverified."},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := environmentCheckLine(tc.rec, tc.fingerprint, tc.mapped); got != tc.want {
|
|
t.Errorf("environmentCheckLine = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestStripeEnvironmentCheckSection renders the section and asserts it
|
|
// carries its resting line and its one outline control, and that an
|
|
// unconfigured deployment renders neither: there is no key to read
|
|
// anything back under.
|
|
func TestStripeEnvironmentCheckSection(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
render := func(data StripeIntegrationData) string {
|
|
t.Helper()
|
|
data.ModeLabel = "Test mode"
|
|
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
|
|
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
out := render(StripeIntegrationData{
|
|
Configured: true,
|
|
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
|
|
EnvironmentCheck: EnvironmentCheck{Line: "Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale."},
|
|
})
|
|
for _, want := range []string{
|
|
"Environment check",
|
|
"Last checked Sep 18, 2026 3:04 PM. 34 verified, 2 stale.",
|
|
"Check now",
|
|
`hx-post="/partials/operator/integrations/stripe/environment-check"`,
|
|
`id="stripe-environment-check"`,
|
|
"btn-outline-secondary",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("environment check section missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
|
|
out = render(StripeIntegrationData{
|
|
Configured: false,
|
|
Missing: []string{"stripe-api-key"},
|
|
EnvironmentCheckHeader: SectionHeader{Title: "Environment check"},
|
|
EnvironmentCheck: EnvironmentCheck{Line: "Not checked yet."},
|
|
})
|
|
if strings.Contains(out, "Check now") {
|
|
t.Errorf("an unconfigured deployment must not offer the check, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestStripeInboundRefusedEvents covers the Inbound events section's half
|
|
// of stripe-environment-stamp D6: the count line renders only when events
|
|
// have been refused, and a refused row says why rather than carrying an
|
|
// error nothing produced.
|
|
func TestStripeInboundRefusedEvents(t *testing.T) {
|
|
tmpl := parseOperatorPartials(t)
|
|
render := func(data StripeIntegrationData) string {
|
|
t.Helper()
|
|
data.Configured = true
|
|
data.ModeLabel = "Live mode"
|
|
data.DeliveryQueueHeader = SectionHeader{Title: "Delivery queue"}
|
|
data.DeliveryQueue = DeliveryQueue{Available: true}
|
|
data.EnvironmentCheckHeader = SectionHeader{Title: "Environment check"}
|
|
data.EnvironmentCheck = EnvironmentCheck{Line: "Not checked yet."}
|
|
data.InboundEventsHeader = SectionHeader{Title: "Inbound events"}
|
|
var buf bytes.Buffer
|
|
if err := tmpl.ExecuteTemplate(&buf, "operator_integration_stripe.html", data); err != nil {
|
|
t.Fatalf("render: %v", err)
|
|
}
|
|
return buf.String()
|
|
}
|
|
|
|
out := render(StripeIntegrationData{
|
|
InboundEvents: InboundEvents{
|
|
Refused: 3,
|
|
RefusedLine: refusedEventsLine(3, "live"),
|
|
Available: true,
|
|
},
|
|
InboundEventEntries: []DeadLetterEntry{
|
|
{OperationType: "invoice.paid", ErrorMessage: refusedEventDetail, UpdatedAt: "Sep 18, 2026 3:04 PM", Refused: true},
|
|
},
|
|
})
|
|
for _, want := range []string{
|
|
"3 events arrived in test mode under a live key.",
|
|
"Refused, mode mismatch",
|
|
`<code class="text-nowrap">invoice.paid</code>`,
|
|
// The column holds a refused row's reason as well as a
|
|
// dead-lettered one's error, so its header names neither.
|
|
"<th>Detail</th>",
|
|
} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("inbound events section missing %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
if strings.Contains(out, "Inbound events are processing normally") {
|
|
t.Error("the quiet line must not render alongside refused events")
|
|
}
|
|
if strings.Contains(out, `<tr class="table-danger">`) {
|
|
t.Error("a refused row is not an operator's alarm; no alarm styling")
|
|
}
|
|
|
|
// No refused event, no line.
|
|
out = render(StripeIntegrationData{InboundEvents: InboundEvents{Available: true}})
|
|
if strings.Contains(out, "arrived in") {
|
|
t.Errorf("the refused line must not render when nothing was refused, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "Inbound events are processing normally.") {
|
|
t.Errorf("a quiet section must say so, got:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// TestRefusedEventsLine pins the line's wording, which names the
|
|
// environment the events came from and the one the key is in.
|
|
func TestRefusedEventsLine(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
refused int64
|
|
keyMode string
|
|
want string
|
|
}{
|
|
{3, "live", "3 events arrived in test mode under a live key."},
|
|
{1, "live", "1 event arrived in test mode under a live key."},
|
|
{2, "test", "2 events arrived in live mode under a test key."},
|
|
{0, "live", ""},
|
|
{3, "", ""},
|
|
} {
|
|
if got := refusedEventsLine(tc.refused, tc.keyMode); got != tc.want {
|
|
t.Errorf("refusedEventsLine(%d, %q) = %q, want %q", tc.refused, tc.keyMode, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestPostStripeEnvironmentCheck covers the control's four outcomes
|
|
// (stripe-environment-stamp D3). run.Get reports two different facts
|
|
// through one error, so the outcomes the operator is told apart are: the
|
|
// run finished inside the wait, the wait ran out while the run went on,
|
|
// the run failed, and Temporal refused the start because the id is already
|
|
// held.
|
|
func TestPostStripeEnvironmentCheck(t *testing.T) {
|
|
newHandler := func(t *testing.T, temporalClient client.Client) *OperatorPartialsHandler {
|
|
t.Helper()
|
|
h, err := NewOperatorPartialsHandler(OperatorPartialsConfig{
|
|
StripeMode: "test",
|
|
StripeConfigured: true,
|
|
TemporalClient: temporalClient,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
AuthConfig: &auth.Config{SessionManager: scs.New()},
|
|
IntegrationConfigs: stripeConfigs,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// toast reads the message the handler put on the wire, which is the
|
|
// whole of what the operator is told.
|
|
toast := func(t *testing.T, w *httptest.ResponseRecorder, key string) string {
|
|
t.Helper()
|
|
var fired map[string]string
|
|
if err := json.Unmarshal([]byte(w.Header().Get("HX-Trigger")), &fired); err != nil {
|
|
t.Fatalf("HX-Trigger %q: %v", w.Header().Get("HX-Trigger"), err)
|
|
}
|
|
return fired[key]
|
|
}
|
|
|
|
// post drives the handler with a request whose context carries the
|
|
// given budget, so the deadline case costs the test that long and not
|
|
// the handler's own three seconds.
|
|
post := func(t *testing.T, h *OperatorPartialsHandler, budget time.Duration) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), budget)
|
|
t.Cleanup(cancel)
|
|
r := httptest.NewRequestWithContext(ctx, "POST",
|
|
"/partials/operator/integrations/stripe/environment-check", nil)
|
|
w := httptest.NewRecorder()
|
|
h.PostStripeEnvironmentCheck(w, r)
|
|
if w.Code != 200 {
|
|
t.Fatalf("want 200, got %d, body:\n%s", w.Code, w.Body.String())
|
|
}
|
|
return w
|
|
}
|
|
|
|
withRun := func(t *testing.T, run *mocks.WorkflowRun) client.Client {
|
|
t.Helper()
|
|
c := &mocks.Client{}
|
|
c.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return(run, nil)
|
|
return c
|
|
}
|
|
|
|
t.Run("the run finishes inside the wait", func(t *testing.T) {
|
|
run := &mocks.WorkflowRun{}
|
|
run.On("Get", mock.Anything, mock.Anything).
|
|
Run(func(args mock.Arguments) {
|
|
*(args.Get(1).(*stripewf.EnvironmentCheckResult)) = stripewf.EnvironmentCheckResult{Checked: 36, Stale: 2}
|
|
}).Return(nil)
|
|
w := post(t, newHandler(t, withRun(t, run)), time.Minute)
|
|
if got := toast(t, w, "showSuccessToast"); got != "36 checked, 2 stale." {
|
|
t.Errorf("toast = %q, want the run's counts", got)
|
|
}
|
|
})
|
|
|
|
t.Run("the wait runs out while the run goes on", func(t *testing.T) {
|
|
run := &mocks.WorkflowRun{}
|
|
run.On("Get", mock.Anything, mock.Anything).
|
|
Return(func(ctx context.Context, _ interface{}) error {
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
})
|
|
w := post(t, newHandler(t, withRun(t, run)), 50*time.Millisecond)
|
|
if got := toast(t, w, "showSuccessToast"); got != "Check started." {
|
|
t.Errorf("toast = %q, want the started line", got)
|
|
}
|
|
})
|
|
|
|
t.Run("the run fails", func(t *testing.T) {
|
|
run := &mocks.WorkflowRun{}
|
|
run.On("Get", mock.Anything, mock.Anything).
|
|
Return(errors.New("stripe get product prod_x: rate limit"))
|
|
w := post(t, newHandler(t, withRun(t, run)), time.Minute)
|
|
if got := toast(t, w, "showSuccessToast"); got != "" {
|
|
t.Errorf("a failed run reported success: %q", got)
|
|
}
|
|
// The failure's own text is logged, not shown: the toast says the
|
|
// check failed, which is the fact the operator acts on.
|
|
if got := toast(t, w, "showErrorToast"); got != "The check failed." {
|
|
t.Errorf("error toast = %q, want the failure line", got)
|
|
}
|
|
})
|
|
|
|
t.Run("a run already holds the id", func(t *testing.T) {
|
|
c := &mocks.Client{}
|
|
c.On("ExecuteWorkflow", mock.Anything, mock.Anything, mock.Anything, mock.Anything).
|
|
Return(nil, serviceerror.NewWorkflowExecutionAlreadyStarted(
|
|
"already started", "start-request", "run-id"))
|
|
w := post(t, newHandler(t, c), time.Minute)
|
|
if got := toast(t, w, "showSuccessToast"); got != "Check running." {
|
|
t.Errorf("toast = %q, want the running line", got)
|
|
}
|
|
})
|
|
}
|