Archives openspec change slice3-walk-fixes and syncs its five delta specs (fedwiki-sites, entitlements, operator-panel-navigation, operator-list-scale, ui-quality-gate). - FedWiki site usage is read from active site rows in both quota readers; the reservation counter converges on the rows: raise-only after farm sync and inside the create quota check, exact at boot. The understated production counters repair on the first boot. - The People tile caption excludes the reserved system person through the same query parameter the directory uses. - The operator Domains live-claims list is a governed list: pages of 50, true total, search over root name and organization, a pending/active facet. - New lint rule table-without-list-controls refuses an unpaged page-body table unless it carries a list-scale exempt marker with a reason; six curated or detail tables carry one. Its first run caught the operator FedWiki sites list, which is now governed the same way. - Entitlement-set rule copy: "Per unit", "Multiplied by the quantity purchased or granted."
951 lines
39 KiB
Go
951 lines
39 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
||
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
||
|
||
package server
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"errors"
|
||
"html/template"
|
||
"io"
|
||
"io/fs"
|
||
"log/slog"
|
||
"regexp"
|
||
"strconv"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/config"
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/embeds"
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
||
"git.coopcloud.tech/wiki-cafe/member-console/internal/web"
|
||
"github.com/google/uuid"
|
||
)
|
||
|
||
// Render coverage for the operator landing surface's overview regions ("At a
|
||
// glance" + "System"). Template-level only: it executes operator.html against
|
||
// hand-built OperatorPageData, so it needs no database and runs in every
|
||
// environment. The loader that fills that data (loadOverview) is exercised
|
||
// separately by the pure-function tests at the bottom of this file.
|
||
|
||
// overviewTemplate parses the operator template set the same way
|
||
// NewOperatorPartialsHandler does — partials first, then the shell — so this
|
||
// test fails on the same parse errors production would hit.
|
||
func overviewTemplate(t *testing.T) *template.Template {
|
||
t.Helper()
|
||
sub, err := fs.Sub(embeds.Templates, "templates")
|
||
if err != nil {
|
||
t.Fatalf("fs.Sub: %v", err)
|
||
}
|
||
partialsSub, err := fs.Sub(embeds.Templates, "templates/partials")
|
||
if err != nil {
|
||
t.Fatalf("fs.Sub partials: %v", err)
|
||
}
|
||
tmpl := template.New("operator").Funcs(template.FuncMap{
|
||
"renderBody": func(string, any) (template.HTML, error) { return "", nil },
|
||
"routeURL": web.RouteURL,
|
||
"fieldErr": func(_, _, _, _ string, _, _ any) string { return "" },
|
||
"stripeEntityURL": func(string, string) string { return "" },
|
||
"deploymentName": config.DeploymentName,
|
||
"pageTitle": pageTitle,
|
||
"supportURL": config.SupportURL,
|
||
"helpIcon": helpIcon,
|
||
})
|
||
if tmpl, err = web.ParseUIPartials(template.Must(tmpl.ParseFS(partialsSub, "operator_*.html"))); err != nil {
|
||
t.Fatalf("ParseFS partials: %v", err)
|
||
}
|
||
if tmpl, err = tmpl.ParseFS(sub, "operator.html"); err != nil {
|
||
t.Fatalf("ParseFS operator.html: %v", err)
|
||
}
|
||
// The operator surface's root crumb (design D18); see server.go's own
|
||
// override for why this must run after ParseUIPartials.
|
||
tmpl = tmpl.Funcs(template.FuncMap{"surfaceRoot": OperatorSurfaceRoot})
|
||
return tmpl
|
||
}
|
||
|
||
func renderOperator(t *testing.T, data OperatorPageData) string {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
if err := overviewTemplate(t).ExecuteTemplate(&buf, "operator.html", data); err != nil {
|
||
t.Fatalf("ExecuteTemplate: %v", err)
|
||
}
|
||
return buf.String()
|
||
}
|
||
|
||
// landingRegion slices the rendered document down to the landing surface
|
||
// itself. The navbar, sidebar, toast container and confirmation modal are
|
||
// outside <main> (or outside #operator-body) and carry their own markup —
|
||
// including an <h5> in the modal — so assertions about this page's structure
|
||
// must not see them.
|
||
func landingRegion(t *testing.T, out string) string {
|
||
t.Helper()
|
||
const openTag = `<div id="operator-body">`
|
||
start := strings.Index(out, openTag)
|
||
if start < 0 {
|
||
t.Fatalf("rendered output has no #operator-body region")
|
||
}
|
||
end := strings.Index(out, "</main>")
|
||
if end < start {
|
||
t.Fatalf("rendered output has no </main> after #operator-body")
|
||
}
|
||
return out[start+len(openTag) : end]
|
||
}
|
||
|
||
// populatedOverview is a representative landing payload: all three tiles
|
||
// available, and an Integrations card mixing a configured provider, an
|
||
// unconfigured one, and Stripe (configured, draining queue work) — the
|
||
// same mix the "Integrations card lists every provider with one status"
|
||
// scenario describes (operator-panel-navigation).
|
||
func populatedOverview() OperatorPageData {
|
||
return OperatorPageData{
|
||
IAPosition: "runtime:landing",
|
||
Overview: OverviewData{
|
||
Stats: []OverviewStat{
|
||
{Label: "People", Value: "1,204", Caption: "212 joined in the last 30 days", Href: "/operator/persons", Available: true},
|
||
{Label: "Team organizations", Value: "38", Caption: "Personal orgs excluded", Href: "/operator/organizations", Available: true},
|
||
{Label: "Monthly recurring", Value: "$2,295.00", Caption: "205 active subscriptions; 9 trialing", Href: "/operator/billing/subscriptions", Available: true},
|
||
},
|
||
Integrations: OverviewIntegrationsCard{
|
||
Rows: []OverviewIntegrationRow{
|
||
{IntegrationRow: IntegrationRow{
|
||
Key: "fedwiki", DisplayName: "FedWiki", Kind: "provisioning", Status: "active",
|
||
SurfacePath: "/operator/integrations/fedwiki", Configured: true,
|
||
}},
|
||
{IntegrationRow: IntegrationRow{
|
||
Key: "discourse", DisplayName: "Discourse", Kind: "provisioning", Status: "active",
|
||
SettingsPath: "/operator/integrations/discourse/settings", Configured: false,
|
||
}},
|
||
{
|
||
IntegrationRow: IntegrationRow{
|
||
Key: "stripe", DisplayName: "Stripe", Kind: "payment", Status: "active",
|
||
SurfacePath: "/operator/integrations/stripe", Configured: true,
|
||
},
|
||
StripeFacts: &OverviewStripeFacts{ModeLabel: "Test mode", Queued: 4, Attention: 0, Available: true},
|
||
},
|
||
},
|
||
},
|
||
},
|
||
Activity: []ActivityEvent{{
|
||
EventType: "grant_issued",
|
||
Timestamp: "Jan 2, 2026 3:04 PM",
|
||
OrgID: "org-1",
|
||
OrgName: "Example Org",
|
||
Summary: "Issued Example Plan (promo)",
|
||
Href: "/operator/organizations/org-1",
|
||
}},
|
||
// ActivityNav pages the feed like the composite's Tier changes
|
||
// (overview-consistency D5, round 2); the fixture's one row is the
|
||
// whole (unpaged) window's length.
|
||
ActivityNav: ListNav{
|
||
BasePath: "/operator",
|
||
ParamPrefix: "act_",
|
||
Page: 1,
|
||
Total: 1,
|
||
PerPage: embeddedListDefaultPerPage,
|
||
DefaultPerPage: embeddedListDefaultPerPage,
|
||
PerPageOptions: perPageOptions,
|
||
Target: "#activity-panel",
|
||
},
|
||
}
|
||
}
|
||
|
||
// The landing surface's lookup is a wide bar: one input group spanning the
|
||
// content column, with the magnifier, the control and an outline "Look up"
|
||
// on one line (design D20). Round 3 rendered it stacked and filled, a
|
||
// width and a weight no other search had (maintainer, 2026-09-04).
|
||
func TestOperatorOverviewLookupIsAWideBar(t *testing.T) {
|
||
data := populatedOverview()
|
||
data.LookupForm = lookupForm("")
|
||
out := landingRegion(t, renderOperator(t, data))
|
||
|
||
for _, want := range []string{
|
||
`class="app-form app-form-bar-wide"`,
|
||
`role="search"`,
|
||
`<div class="input-group">`,
|
||
`<span class="input-group-text"><svg`,
|
||
`class="btn btn-outline-secondary"><span class="spinner-border`,
|
||
`>Look up</button>`,
|
||
`class="form-label visually-hidden"`,
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("the lookup is missing %q:\n%s", want, out)
|
||
}
|
||
}
|
||
for _, banned := range []string{"btn-primary", "app-form-box", "app-form-fields", "(optional)"} {
|
||
if strings.Contains(out, banned) {
|
||
t.Errorf("the lookup rendered %q, which no search bar carries:\n%s", banned, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestOperatorOverviewRendersStatTiles(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
|
||
for _, want := range []string{
|
||
`<h2 class="h5 mb-0">At a glance</h2>`,
|
||
// Each tile is a readout part inside a linked card
|
||
// (overview-consistency D3, round 2): the card is the anchor,
|
||
// marked by the chevron glyph, and the value is at the console's
|
||
// one readout size. The label is the eyebrow, never a link.
|
||
`<a class="card h-100 text-decoration-none text-body" href="/operator/persons">`,
|
||
`<div class="card-body d-flex align-items-center justify-content-between gap-3">`,
|
||
`<dt class="small fw-semibold text-uppercase text-body-secondary">People</dt>`,
|
||
`<svg class="app-chevron-icon"`,
|
||
`<dd class="fs-3 fw-semibold mb-0">1,204</dd>`, // grouped value, formatted in Go
|
||
`<small class="text-body-secondary">212 joined in the last 30 days</small>`,
|
||
"Team organizations", // deliberately created orgs, not personal ones
|
||
`href="/operator/organizations"`, // tile drills into its section
|
||
"Monthly recurring", // the money tile (design D3, round 2 restore)
|
||
`<dd class="fs-3 fw-semibold mb-0">$2,295.00</dd>`,
|
||
`href="/operator/billing/subscriptions"`,
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("landing surface missing %q", want)
|
||
}
|
||
}
|
||
|
||
// The retired tile markup: the label carrying the link style, a number
|
||
// bigger than the page title, and an anchor inside the card's own
|
||
// anchor.
|
||
for _, banned := range []string{"link-primary", "display-6"} {
|
||
if strings.Contains(out, banned) {
|
||
t.Errorf("landing surface still carries the retired tile markup %q", banned)
|
||
}
|
||
}
|
||
|
||
// The headline row is reserved for the three daily reads
|
||
// (operator-panel-navigation "Headline row carries only the three daily
|
||
// reads"): open-invoice, delivery, and catalog counts must not render
|
||
// here.
|
||
for _, forbidden := range []string{"Open invoices", "Delivering grants", "Catalog products"} {
|
||
if strings.Contains(out, forbidden) {
|
||
t.Errorf("landing surface still renders the removed tile %q", forbidden)
|
||
}
|
||
}
|
||
|
||
// A tile with no Href must not render as a link (an unresolvable count
|
||
// destination renders a static card, never an empty link target).
|
||
if strings.Contains(out, `href=""`) {
|
||
t.Errorf("hrefless stat tile rendered an empty link target")
|
||
}
|
||
}
|
||
|
||
// TestOperatorOverviewTileChevronMarksOnlyLinkedTiles covers page-anatomy
|
||
// "A linked card is marked by a glyph and the row's hover": the chevron
|
||
// glyph marks a tile that is a link; a tile with no browse surface (no
|
||
// .Href) carries no chevron (overview-consistency D3, round 2).
|
||
func TestOperatorOverviewTileChevronMarksOnlyLinkedTiles(t *testing.T) {
|
||
data := OperatorPageData{
|
||
Overview: OverviewData{Stats: []OverviewStat{
|
||
{Label: "Linked", Value: "1", Href: "/operator/persons", Available: true},
|
||
{Label: "Unlinked", Value: "2", Available: true},
|
||
}},
|
||
}
|
||
out := landingRegion(t, renderOperator(t, data))
|
||
|
||
if n := strings.Count(out, `<svg class="app-chevron-icon"`); n != 1 {
|
||
t.Errorf("expected exactly 1 chevron glyph, one per linked tile, got %d in:\n%s", n, out)
|
||
}
|
||
if !strings.Contains(out, `<div class="card h-100">`) {
|
||
t.Errorf("the unlinked tile must render a plain, unmarked card, got:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// An unavailable count must render an em dash. Rendering 0 would be an
|
||
// actively misleading answer to "how many organizations are there".
|
||
func TestOperatorOverviewUnavailableStatIsNotZero(t *testing.T) {
|
||
data := OperatorPageData{
|
||
Overview: OverviewData{Stats: []OverviewStat{
|
||
{Label: "Organizations", Caption: "Active organizations", Href: "/operator/organizations", Available: false},
|
||
}},
|
||
}
|
||
out := landingRegion(t, renderOperator(t, data))
|
||
|
||
if !strings.Contains(out, "—") {
|
||
t.Errorf("unavailable stat did not render an em dash")
|
||
}
|
||
if !strings.Contains(out, "Count unavailable") {
|
||
t.Errorf("unavailable stat did not explain itself")
|
||
}
|
||
// The tile still renders its label and link; only the number is withheld.
|
||
if !strings.Contains(out, "Organizations") {
|
||
t.Errorf("unavailable stat dropped its label")
|
||
}
|
||
if regexp.MustCompile(`fs-3[^>]*>\s*0\s*<`).MatchString(out) {
|
||
t.Errorf("unavailable stat rendered a zero value")
|
||
}
|
||
}
|
||
|
||
// TestOperatorOverviewFeedIsARecordTable covers overview-consistency D5:
|
||
// the activity feed is a record table like every other, with a plain header
|
||
// row, body-size cells, and the Summary as the row's primary cell placed
|
||
// after the event kind.
|
||
func TestOperatorOverviewFeedIsARecordTable(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
|
||
for _, want := range []string{
|
||
`<h2 class="h5 mb-0">Recent activity</h2>`,
|
||
`<div id="activity-panel">`,
|
||
`<table class="table table-hover table-sm table-record">`,
|
||
`<td class="text-nowrap">Jan 2, 2026 3:04 PM</td>`,
|
||
`<th scope="row"><a href="/operator/organizations/org-1">Issued Example Plan (promo)</a></th>`,
|
||
// The feed pages through the list scaffold's pager, the same shape
|
||
// as the composite's Tier changes (overview-consistency D5, round
|
||
// 2), replacing the retired ?limit parameter.
|
||
"Showing 1–1 of 1",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("the activity feed is missing %q:\n%s", want, out)
|
||
}
|
||
}
|
||
// The header row is plain, like every other table's (F5b): the styled
|
||
// uppercase row is gone.
|
||
if !regexp.MustCompile(`(?s)<thead>\s*<tr>`).MatchString(out) {
|
||
t.Errorf("the activity feed's header row still carries classes:\n%s", out)
|
||
}
|
||
table := out[strings.Index(out, "<table"):]
|
||
if strings.Contains(table, "<small") {
|
||
t.Errorf("the activity feed still renders a cell at small size:\n%s", table)
|
||
}
|
||
|
||
// Column order: Summary, Event, Organization, Actor, When
|
||
// (overview-consistency D5, round 2: "I wanna move summary there").
|
||
cols := regexp.MustCompile(`<th scope="col">([^<]+)</th>`).FindAllStringSubmatch(out, -1)
|
||
got := make([]string, 0, len(cols))
|
||
for _, c := range cols {
|
||
got = append(got, c[1])
|
||
}
|
||
want := []string{"Summary", "Event", "Organization", "Actor", "When"}
|
||
if strings.Join(got, ",") != strings.Join(want, ",") {
|
||
t.Errorf("activity columns = %v, want %v", got, want)
|
||
}
|
||
}
|
||
|
||
// TestOperatorOverviewSystemPanel covers design D3's System region: one
|
||
// Integrations card listing every registered provider, in registry order,
|
||
// each with its name link and single Status; Stripe's row alone adds its
|
||
// mode and outbox summary.
|
||
func TestOperatorOverviewSystemPanel(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
|
||
for _, want := range []string{
|
||
`<h2 class="h5 mb-0">System</h2>`,
|
||
// The card carries no title of its own: "Integrations" is a
|
||
// category, and a card title names an instance
|
||
// (overview-consistency D2). The providers are a flush list in the
|
||
// card body, the setup checklist's idiom.
|
||
`<div class="card-body">`,
|
||
`<ul class="list-group list-group-flush">`,
|
||
`<li class="list-group-item px-0 py-2">`,
|
||
"FedWiki",
|
||
`href="/operator/integrations/fedwiki"`,
|
||
`<span class="badge text-bg-success`, // FedWiki's Configured badge
|
||
"Discourse",
|
||
`href="/operator/integrations/discourse/settings"`,
|
||
`<span class="badge text-bg-warning`, // Discourse's Not configured badge
|
||
"Stripe",
|
||
`href="/operator/integrations/stripe"`,
|
||
"Test mode",
|
||
"4 queued, 0 need attention",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("system panel missing %q", want)
|
||
}
|
||
}
|
||
// The card lists providers, never a dead-letter table or operation
|
||
// identifiers (design D3: that detail lives on the Stripe provider
|
||
// page, D24).
|
||
for _, forbidden := range []string{"Dead-letter", "op_", "operation id"} {
|
||
if strings.Contains(out, forbidden) {
|
||
t.Errorf("system panel leaked delivery-queue detail %q", forbidden)
|
||
}
|
||
}
|
||
// The registry-status badge must not read as a live health/connectivity
|
||
// claim (integration-settings spec: "cannot read as a health check").
|
||
if strings.Contains(out, "read live") || strings.Contains(out, "Connected") || strings.Contains(out, "connected to") {
|
||
t.Errorf("system panel implies a live health/connectivity check")
|
||
}
|
||
if strings.Contains(out, "<h3") {
|
||
t.Errorf("the System card carries a title of its own; a card title names an instance, not a category:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// The System section header's one action, "Setup", always links to
|
||
// /operator/setup — setup complete or not, banner dismissed or not
|
||
// (operator-panel-navigation "The System header always offers Setup").
|
||
// Navigation, not an opener, so outline-secondary (design D19).
|
||
func TestOperatorSystemHeaderAlwaysOffersSetup(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
if !strings.Contains(out, `<a href="/operator/setup" class="btn btn-sm btn-outline-secondary">Setup</a>`) {
|
||
t.Errorf("System header did not render its Setup action, got:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// Stripe not configured reads "Not configured" without alarm styling.
|
||
func TestOperatorOverviewStripeNotConfigured(t *testing.T) {
|
||
data := populatedOverview()
|
||
data.Overview.Integrations.Rows[2] = OverviewIntegrationRow{
|
||
IntegrationRow: IntegrationRow{
|
||
Key: "stripe", DisplayName: "Stripe", Status: "active",
|
||
SurfacePath: "/operator/integrations/stripe", Configured: false,
|
||
},
|
||
StripeFacts: &OverviewStripeFacts{ModeLabel: "Test mode", Available: true},
|
||
}
|
||
out := landingRegion(t, renderOperator(t, data))
|
||
|
||
if !strings.Contains(out, `<span class="badge text-bg-warning`) {
|
||
t.Errorf("unconfigured Stripe did not render the not-configured badge")
|
||
}
|
||
if !strings.Contains(out, `href="/operator/integrations/stripe"`) {
|
||
t.Errorf("unconfigured Stripe row dropped its provider-page link")
|
||
}
|
||
}
|
||
|
||
// Dead-lettered outbox work is the one queue bucket nothing will clear on its
|
||
// own, so Stripe's row must call it out rather than showing it as another
|
||
// neutral number.
|
||
func TestOperatorOverviewStripeDeadLetterRaisesAlarm(t *testing.T) {
|
||
data := populatedOverview()
|
||
data.Overview.Integrations.Rows[2].StripeFacts = &OverviewStripeFacts{
|
||
ModeLabel: "Test mode", Queued: 2, Attention: 3, Available: true,
|
||
}
|
||
out := landingRegion(t, renderOperator(t, data))
|
||
|
||
if !strings.Contains(out, "text-danger") {
|
||
t.Errorf("dead-lettered work did not render with alarm styling")
|
||
}
|
||
if !strings.Contains(out, "2 queued, 3 need attention") {
|
||
t.Errorf("Stripe row did not state the queued/attention counts")
|
||
}
|
||
}
|
||
|
||
// The landing surface is the first thing a fresh deployment shows, so every
|
||
// region needs a legible empty state rather than a blank panel.
|
||
func TestOperatorOverviewEmptyStates(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, OperatorPageData{}))
|
||
|
||
for _, want := range []string{
|
||
"Counts are unavailable.",
|
||
"No activity recorded yet.",
|
||
"No integrations registered.",
|
||
} {
|
||
if !strings.Contains(out, want) {
|
||
t.Errorf("empty landing surface missing %q", want)
|
||
}
|
||
}
|
||
// The lookup affordance is the one thing that must work on an empty
|
||
// deployment, so it renders regardless. It names itself with
|
||
// aria-label rather than a heading (overview-consistency D1).
|
||
if !strings.Contains(out, `<section aria-label="Lookup">`) {
|
||
t.Errorf("empty landing surface dropped the lookup affordance")
|
||
}
|
||
// A zero-value Setup (no steps derived) has nothing to announce.
|
||
if strings.Contains(out, `id="setup-banner"`) {
|
||
t.Errorf("empty landing surface rendered the setup banner with no steps to report")
|
||
}
|
||
}
|
||
|
||
// TestOperatorLandingRegionOrder covers operator-panel-navigation ("Landing
|
||
// surface orders attention before totals"; maintainer 2026-08-23: the counts
|
||
// an operator checks daily outrank the system cards): the lookup precedes At
|
||
// a glance, At a glance precedes System, and System precedes Recent
|
||
// activity — so attention-shaped content is still never the page's last
|
||
// region. (The setup banner's own position, ahead of the lookup while it
|
||
// shows, is covered separately in operator_setup_render_test.go.)
|
||
func TestOperatorLandingRegionOrder(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
|
||
lookupIdx := strings.Index(out, `<section aria-label="Lookup">`)
|
||
glanceIdx := strings.Index(out, `<h2 class="h5 mb-0">At a glance</h2>`)
|
||
systemIdx := strings.Index(out, `<h2 class="h5 mb-0">System</h2>`)
|
||
activityIdx := strings.Index(out, `<h2 class="h5 mb-0">Recent activity</h2>`)
|
||
|
||
for name, idx := range map[string]int{"lookup": lookupIdx, "glance": glanceIdx, "system": systemIdx, "activity": activityIdx} {
|
||
if idx < 0 {
|
||
t.Fatalf("landing surface missing the %s region", name)
|
||
}
|
||
}
|
||
if !(lookupIdx < glanceIdx && glanceIdx < systemIdx && systemIdx < activityIdx) {
|
||
t.Errorf("landing regions out of order: lookup=%d glance=%d system=%d activity=%d, want lookup < glance < system < activity",
|
||
lookupIdx, glanceIdx, systemIdx, activityIdx)
|
||
}
|
||
}
|
||
|
||
// TestOperatorActivityFeedLinksToSubject covers operator-panel-navigation
|
||
// ("Feed entries link to their subjects"): a grant/transition entry's
|
||
// summary links to its organization's detail page, an invoice/payment
|
||
// entry's summary links to the corresponding billing view, and an entry
|
||
// with no addressable surface (Href empty) renders plain text rather than a
|
||
// dead link.
|
||
func TestOperatorActivityFeedLinksToSubject(t *testing.T) {
|
||
data := OperatorPageData{
|
||
Activity: []ActivityEvent{
|
||
{EventType: "grant_issued", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Issued Example Plan", Href: "/operator/organizations/org-1"},
|
||
{EventType: "invoice_created", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Invoice $10.00", Href: "/operator/billing/invoices"},
|
||
{EventType: "payment_received", Timestamp: "t", OrgID: "org-1", OrgName: "Example Org", Summary: "Payment $10.00", Href: "/operator/billing/payments"},
|
||
{EventType: "grant_issued", Timestamp: "t", Summary: "No addressable subject"},
|
||
},
|
||
}
|
||
out := landingRegion(t, renderOperator(t, data))
|
||
|
||
if !strings.Contains(out, `<a href="/operator/organizations/org-1">Issued Example Plan</a>`) {
|
||
t.Errorf("grant entry summary must link to the org detail page, got:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, `<a href="/operator/billing/invoices">Invoice $10.00</a>`) {
|
||
t.Errorf("invoice entry summary must link to the billing invoices view, got:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, `<a href="/operator/billing/payments">Payment $10.00</a>`) {
|
||
t.Errorf("payment entry summary must link to the billing payments view, got:\n%s", out)
|
||
}
|
||
if strings.Contains(out, `<a href="">No addressable subject</a>`) {
|
||
t.Errorf("an entry with no addressable surface must not render a dead (empty-href) link, got:\n%s", out)
|
||
}
|
||
// The Summary is the row's primary cell (overview-consistency D5), so
|
||
// an entry with no addressable surface renders it as plain text in
|
||
// that cell, at body size.
|
||
if !strings.Contains(out, `<th scope="row">No addressable subject</th>`) {
|
||
t.Errorf("an entry with no addressable surface must render plain text in the primary cell, got:\n%s", out)
|
||
}
|
||
// Organization column always links straight to the org, independent of
|
||
// the row's own Href (which for invoice/payment rows points elsewhere).
|
||
if n := strings.Count(out, `<a href="/operator/organizations/org-1">Example Org</a>`); n != 3 {
|
||
t.Errorf("expected 3 Organization-column links to the org, got %d in:\n%s", n, out)
|
||
}
|
||
}
|
||
|
||
// The landing surface is the page screenshotted for the project README, so
|
||
// its document outline has to be defensible: exactly one H1, no skipped
|
||
// levels. The older operator pages run H1 straight to H6; that pattern must
|
||
// not spread here.
|
||
func TestOperatorOverviewHeadingHierarchy(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
|
||
matches := regexp.MustCompile(`(?i)<h([1-6])[\s>]`).FindAllStringSubmatch(out, -1)
|
||
if len(matches) == 0 {
|
||
t.Fatalf("landing surface rendered no headings at all")
|
||
}
|
||
|
||
levels := make([]int, 0, len(matches))
|
||
for _, m := range matches {
|
||
n, err := strconv.Atoi(m[1])
|
||
if err != nil {
|
||
t.Fatalf("unparsable heading level %q", m[1])
|
||
}
|
||
levels = append(levels, n)
|
||
}
|
||
|
||
if levels[0] != 1 {
|
||
t.Errorf("first heading is h%d, want h1", levels[0])
|
||
}
|
||
h1Count := 0
|
||
for _, l := range levels {
|
||
if l == 1 {
|
||
h1Count++
|
||
}
|
||
}
|
||
if h1Count != 1 {
|
||
t.Errorf("found %d h1 elements, want exactly 1", h1Count)
|
||
}
|
||
for i := 1; i < len(levels); i++ {
|
||
if levels[i] > levels[i-1]+1 {
|
||
t.Errorf("heading level skips from h%d to h%d at position %d (levels: %v)",
|
||
levels[i-1], levels[i], i, levels)
|
||
}
|
||
}
|
||
// Guard the specific regression: no h4/h5/h6 on this surface at all.
|
||
for _, l := range levels {
|
||
if l > 3 {
|
||
t.Errorf("landing surface uses h%d; the overview outline stops at h3 (levels: %v)", l, levels)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestOperatorOverviewCarriesNoTrail covers design D18: the operator
|
||
// overview is one of exactly two surface roots (with the member dashboard)
|
||
// that carry no location trail, so it renders no breadcrumb nav at all —
|
||
// unlike every other operator page, which is rooted at the surface.
|
||
func TestOperatorOverviewCarriesNoTrail(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
if strings.Contains(out, `aria-label="breadcrumb"`) || strings.Contains(out, "breadcrumb-item") {
|
||
t.Errorf("the operator overview must render no breadcrumb nav, got:\n%s", out)
|
||
}
|
||
}
|
||
|
||
// Strict CSP: nothing on this surface may rely on an inline script, an inline
|
||
// event handler, or a style attribute.
|
||
func TestOperatorOverviewIsCSPClean(t *testing.T) {
|
||
out := landingRegion(t, renderOperator(t, populatedOverview()))
|
||
|
||
if regexp.MustCompile(`(?i)<script(\s[^>]*)?>[^<]`).MatchString(out) {
|
||
t.Errorf("landing surface contains an inline <script> body")
|
||
}
|
||
if m := regexp.MustCompile(`(?i)\son(click|load|change|submit|input|focus|error)\s*=`).FindString(out); m != "" {
|
||
t.Errorf("landing surface contains an inline event handler: %q", strings.TrimSpace(m))
|
||
}
|
||
if regexp.MustCompile(`(?i)\sstyle\s*=\s*"`).MatchString(out) {
|
||
t.Errorf("landing surface contains an inline style attribute")
|
||
}
|
||
}
|
||
|
||
// Brand neutrality: this is general-purpose OSS and the operator surface is
|
||
// the page a stranger evaluating the project sees first.
|
||
func TestOperatorShellIsBrandNeutral(t *testing.T) {
|
||
out := renderOperator(t, populatedOverview())
|
||
for _, forbidden := range []string{"Wiki Cafe", "wiki.cafe", "WikiCafe"} {
|
||
if strings.Contains(out, forbidden) {
|
||
t.Errorf("operator shell names a specific deployment: %q", forbidden)
|
||
}
|
||
}
|
||
if !strings.Contains(out, "<title>Operator overview - Member Console</title>") {
|
||
t.Errorf("operator shell lost its brand-neutral title")
|
||
}
|
||
}
|
||
|
||
// TestOperatorShellFlashSuccessToast covers design D20, round 2 ("a success
|
||
// toast, not a banner"): a create-then-land page carries its confirmation
|
||
// as OperatorPageData.FlashSuccess, which the shell pre-renders into the
|
||
// shared #successToast and marks data-show-on-load so success-toast.js's
|
||
// init shows it without any htmx-triggered event (the landing GET is a
|
||
// fresh top-level navigation, not an htmx exchange). Absent FlashSuccess,
|
||
// the toast renders its default body and no auto-show marker — it is
|
||
// wired but unshown, same as the error toast beside it.
|
||
func TestOperatorShellFlashSuccessToast(t *testing.T) {
|
||
out := renderOperator(t, OperatorPageData{FlashSuccess: "Product created."})
|
||
|
||
if !strings.Contains(out, `id="successToast"`) {
|
||
t.Fatalf("shell missing #successToast, got:\n%s", out)
|
||
}
|
||
if !strings.Contains(out, `data-show-on-load="true"`) {
|
||
t.Errorf("expected the toast marked data-show-on-load when FlashSuccess is set, got:\n%s", out)
|
||
}
|
||
bodyIdx := strings.Index(out, `id="successToastBody"`)
|
||
if bodyIdx < 0 || !strings.Contains(out[bodyIdx:bodyIdx+200], "Product created.") {
|
||
t.Errorf("expected the toast body to read Product created., got:\n%s", out)
|
||
}
|
||
if strings.Contains(out, "alert-success") {
|
||
t.Errorf("expected no dismissible alert-success banner anywhere in the shell, got:\n%s", out)
|
||
}
|
||
|
||
plain := renderOperator(t, OperatorPageData{})
|
||
if strings.Contains(plain, "data-show-on-load") {
|
||
t.Errorf("expected no auto-show marker when FlashSuccess is unset, got:\n%s", plain)
|
||
}
|
||
plainBodyIdx := strings.Index(plain, `id="successToastBody"`)
|
||
if plainBodyIdx < 0 || !strings.Contains(plain[plainBodyIdx:plainBodyIdx+50], "Done.") {
|
||
t.Errorf("expected the toast's default body Done. when FlashSuccess is unset, got:\n%s", plain)
|
||
}
|
||
}
|
||
|
||
func TestFormatCount(t *testing.T) {
|
||
cases := []struct {
|
||
in int64
|
||
want string
|
||
}{
|
||
{0, "0"},
|
||
{7, "7"},
|
||
{999, "999"},
|
||
{1000, "1,000"},
|
||
{1204, "1,204"},
|
||
{12480, "12,480"},
|
||
{999999, "999,999"},
|
||
{1000000, "1,000,000"},
|
||
{-4200, "-4,200"},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := formatCount(tc.in); got != tc.want {
|
||
t.Errorf("formatCount(%d) = %q, want %q", tc.in, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// The console's card idiom is a plain bordered card. The landing surface
|
||
// shipped with a bespoke elevation/hover treatment and lost it in
|
||
// operator-overview-polish; this pins the whole rendered shell, hero
|
||
// included, so it cannot come back silently.
|
||
func TestOperatorOverviewUsesPlainCardIdiom(t *testing.T) {
|
||
out := renderOperator(t, populatedOverview())
|
||
for _, forbidden := range []string{"shadow-sm", "stat-tile"} {
|
||
if strings.Contains(out, forbidden) {
|
||
t.Errorf("landing surface carries bespoke card styling %q", forbidden)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestPeopleCaption(t *testing.T) {
|
||
if got, want := peopleCaption(212, nil), "212 joined in the last 30 days"; got != want {
|
||
t.Errorf("peopleCaption = %q, want %q", got, want)
|
||
}
|
||
if got := peopleCaption(0, errors.New("boom")); !strings.Contains(got, "search above") {
|
||
t.Errorf("failed peopleCaption = %q, want the lookup fallback", got)
|
||
}
|
||
}
|
||
|
||
func TestRecurringCaption(t *testing.T) {
|
||
if got, want := recurringCaption(205, 9, nil, 1), "205 active subscriptions; 9 trialing"; got != want {
|
||
t.Errorf("recurringCaption = %q, want %q", got, want)
|
||
}
|
||
if got, want := recurringCaption(1, 0, nil, 1), "1 active subscription"; got != want {
|
||
t.Errorf("recurringCaption (singular) = %q, want %q", got, want)
|
||
}
|
||
if got, want := recurringCaption(3, 0, nil, 2), "3 active subscriptions; plus 1 more currency"; got != want {
|
||
t.Errorf("recurringCaption (extra bucket) = %q, want %q", got, want)
|
||
}
|
||
if got := recurringCaption(0, 0, errors.New("boom"), 0); got != "Active subscriptions" {
|
||
t.Errorf("failed recurringCaption = %q, want the fallback", got)
|
||
}
|
||
}
|
||
|
||
func TestNewMoneyStatPicksLargestBucket(t *testing.T) {
|
||
buckets := []moneyBucket{{currency: "usd", cents: 50000}, {currency: "usd", cents: 90000}}
|
||
stat := newMoneyStat("Monthly recurring", nil, buckets, "caption", "/href")
|
||
if stat.Value != "$900.00" {
|
||
t.Errorf("newMoneyStat picked the wrong bucket: %+v, want $900.00", stat)
|
||
}
|
||
if !stat.Available {
|
||
t.Errorf("newMoneyStat with no error reported itself unavailable")
|
||
}
|
||
empty := newMoneyStat("Monthly recurring", nil, nil, "caption", "/href")
|
||
if empty.Value != "0" {
|
||
t.Errorf("newMoneyStat with no buckets = %q, want the bare-digit fallback \"0\"", empty.Value)
|
||
}
|
||
failed := newMoneyStat("Monthly recurring", errors.New("boom"), buckets, "caption", "/href")
|
||
if failed.Available {
|
||
t.Errorf("newMoneyStat with an error reported itself available")
|
||
}
|
||
if failed.Value != "" {
|
||
t.Errorf("newMoneyStat with an error carried a value %q", failed.Value)
|
||
}
|
||
}
|
||
|
||
func TestOverviewStripeFactsNeedsAttention(t *testing.T) {
|
||
if (OverviewStripeFacts{Queued: 99, Available: true}).NeedsAttention() {
|
||
t.Errorf("queued work must not raise an alarm on its own")
|
||
}
|
||
if !(OverviewStripeFacts{Attention: 1, Available: true}).NeedsAttention() {
|
||
t.Errorf("dead-lettered work must raise an alarm")
|
||
}
|
||
}
|
||
|
||
func TestNewOverviewStatWithholdsValueOnError(t *testing.T) {
|
||
ok := newOverviewStat("Organizations", nil, 42, "caption", "/operator/organizations")
|
||
if !ok.Available || ok.Value != "42" {
|
||
t.Errorf("successful stat = %+v, want available with value 42", ok)
|
||
}
|
||
bad := newOverviewStat("Organizations", errors.New("boom"), 42, "caption", "/operator/organizations")
|
||
if bad.Available {
|
||
t.Errorf("failed stat reported itself available")
|
||
}
|
||
if bad.Value != "" {
|
||
t.Errorf("failed stat carried a value %q; the template must have nothing to print", bad.Value)
|
||
}
|
||
}
|
||
|
||
// The headline row is reserved for the three daily reads
|
||
// (operator-panel-navigation "Headline row carries only the three daily
|
||
// reads"): pinning loadOverview's labels and order keeps a feature tile — a
|
||
// removed billing/catalog one, or a future equivalent — from slipping back
|
||
// in without failing here. The fakes embed their Querier interfaces, so
|
||
// loadOverview calling any count not stubbed below panics, which doubles as
|
||
// a check on what the loader is allowed to read.
|
||
type overviewIdentityQ struct{ identity.Querier }
|
||
|
||
func (overviewIdentityQ) CountActivePersons(context.Context) (int64, error) { return 1204, nil }
|
||
func (overviewIdentityQ) CountPersonsJoinedLast30Days(context.Context, uuid.NullUUID) (int64, error) {
|
||
return 212, nil
|
||
}
|
||
|
||
type overviewOrgQ struct{ organization.Querier }
|
||
|
||
func (overviewOrgQ) CountTeamOrganizations(context.Context) (int64, error) { return 38, nil }
|
||
|
||
// ListOrganizationsByType backs the People tile's system-person exclusion
|
||
// (systemPersonID); an empty result means no System tenant is provisioned
|
||
// in this fake, so the tile counts every active person unchanged.
|
||
func (overviewOrgQ) ListOrganizationsByType(context.Context, string) ([]organization.Organization, error) {
|
||
return nil, nil
|
||
}
|
||
|
||
func TestLoadOverviewTileSet(t *testing.T) {
|
||
h := &OperatorHandler{
|
||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
OrgQ: overviewOrgQ{},
|
||
IdentityQ: overviewIdentityQ{},
|
||
}
|
||
overview := h.loadOverview(context.Background())
|
||
stats := overview.Stats
|
||
|
||
got := make([]string, len(stats))
|
||
for i, s := range stats {
|
||
got[i] = s.Label
|
||
}
|
||
want := []string{"People", "Team organizations", "Monthly recurring"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("tile labels = %v, want %v", got, want)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Errorf("tile %d = %q, want %q (full set %v)", i, got[i], want[i], got)
|
||
}
|
||
}
|
||
if ppl := stats[0]; ppl.Value != "1,204" || ppl.Caption != "212 joined in the last 30 days" || ppl.Href != "/operator/persons" {
|
||
t.Errorf("People tile = %+v, want 1,204 with the 30-day trend caption and the directory link", ppl)
|
||
}
|
||
if team := stats[1]; team.Value != "38" || team.Caption != "Personal orgs excluded" || team.Href != "/operator/organizations" {
|
||
t.Errorf("Team organizations tile = %+v, want 38 with the personal-orgs-excluded caption", team)
|
||
}
|
||
// A handler with no BillingQ (as this fake is) degrades the money tile
|
||
// to unavailable rather than panicking.
|
||
if recurring := stats[2]; recurring.Available {
|
||
t.Errorf("Monthly recurring tile reported itself available with no BillingQ: %+v", recurring)
|
||
}
|
||
// House style for this row: captions use no em dashes (the unavailable
|
||
// value marker is separate, spec-mandated semantics).
|
||
for _, s := range stats {
|
||
if strings.Contains(s.Caption, "—") {
|
||
t.Errorf("tile %q caption %q contains an em dash", s.Label, s.Caption)
|
||
}
|
||
}
|
||
// A handler with no IntegrationQ (as this fake is) degrades the System
|
||
// region to its empty state rather than panicking.
|
||
if len(overview.Integrations.Rows) != 0 {
|
||
t.Errorf("Integrations card = %+v, want no rows with no IntegrationQ", overview.Integrations)
|
||
}
|
||
}
|
||
|
||
// TestLoadOverviewExcludesSystemPerson covers ACC-10: the People tile
|
||
// excludes the reserved system person the directory also hides, detected
|
||
// structurally (the owner of the System tenant), never by name or email.
|
||
func TestLoadOverviewExcludesSystemPerson(t *testing.T) {
|
||
h := &OperatorHandler{
|
||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
OrgQ: systemPersonOrgQ{
|
||
systemPersonID: "11111111-1111-7111-8111-111111111111",
|
||
},
|
||
IdentityQ: systemPersonIdentityQ{
|
||
systemPersonID: "11111111-1111-7111-8111-111111111111",
|
||
total: 5,
|
||
},
|
||
}
|
||
stats := h.loadOverview(context.Background()).Stats
|
||
if stats[0].Value != "4" {
|
||
t.Errorf("People tile = %q, want 4 (5 active persons minus the excluded system person)", stats[0].Value)
|
||
}
|
||
}
|
||
|
||
type systemPersonOrgQ struct {
|
||
organization.Querier
|
||
systemPersonID string
|
||
}
|
||
|
||
func (q systemPersonOrgQ) ListOrganizationsByType(context.Context, string) ([]organization.Organization, error) {
|
||
return []organization.Organization{{OwnerPersonID: q.systemPersonID}}, nil
|
||
}
|
||
func (systemPersonOrgQ) CountTeamOrganizations(context.Context) (int64, error) { return 0, nil }
|
||
|
||
type systemPersonIdentityQ struct {
|
||
identity.Querier
|
||
systemPersonID string
|
||
total int64
|
||
}
|
||
|
||
func (q systemPersonIdentityQ) CountActivePersons(context.Context) (int64, error) {
|
||
return q.total, nil
|
||
}
|
||
func (systemPersonIdentityQ) CountPersonsJoinedLast30Days(context.Context, uuid.NullUUID) (int64, error) {
|
||
return 0, nil
|
||
}
|
||
func (q systemPersonIdentityQ) GetPersonByID(_ context.Context, personID string) (identity.Person, error) {
|
||
if personID == q.systemPersonID {
|
||
return identity.Person{PersonID: personID, Status: "active"}, nil
|
||
}
|
||
return identity.Person{}, errors.New("not found")
|
||
}
|
||
|
||
// TestLoadOverviewCaptionExcludesSystemPersonInsideWindow covers design D3:
|
||
// the caption takes the same exclude_person_id parameter the headline
|
||
// resolves, so a system person created inside the 30-day window (as it is on
|
||
// a fresh deployment) never inflates the caption the way it would if the
|
||
// query only filtered by status. The fake's CountPersonsJoinedLast30Days
|
||
// models the real query: it only excludes the row when the resolved id
|
||
// arrives, exactly like WHERE person_id <> $1.
|
||
func TestLoadOverviewCaptionExcludesSystemPersonInsideWindow(t *testing.T) {
|
||
const sysID = "11111111-1111-7111-8111-111111111111"
|
||
h := &OperatorHandler{
|
||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
OrgQ: systemPersonOrgQ{
|
||
systemPersonID: sysID,
|
||
},
|
||
IdentityQ: windowIdentityQ{
|
||
systemPersonID: sysID,
|
||
activeTotal: 14, // the system person plus thirteen members
|
||
joinedWithSystem: 14, // system person created inside the window too
|
||
joinedWithoutSystem: 13,
|
||
},
|
||
}
|
||
stats := h.loadOverview(context.Background()).Stats
|
||
if stats[0].Value != "13" {
|
||
t.Errorf("People headline = %q, want 13 (14 active minus the excluded system person)", stats[0].Value)
|
||
}
|
||
if stats[0].Caption != "13 joined in the last 30 days" {
|
||
t.Errorf("People caption = %q, want \"13 joined in the last 30 days\"", stats[0].Caption)
|
||
}
|
||
}
|
||
|
||
// TestLoadOverviewCaptionUnaffectedWhenSystemPersonOutsideWindow covers the
|
||
// spec's second scenario: a system person created more than 30 days ago
|
||
// already falls outside CountPersonsJoinedLast30Days's date filter, so
|
||
// passing its id as the exclude parameter changes nothing. The fake returns
|
||
// the same count whether or not the exclude parameter arrives, modeling a
|
||
// row the date filter alone already drops.
|
||
func TestLoadOverviewCaptionUnaffectedWhenSystemPersonOutsideWindow(t *testing.T) {
|
||
const sysID = "22222222-2222-7222-8222-222222222222"
|
||
h := &OperatorHandler{
|
||
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||
OrgQ: systemPersonOrgQ{
|
||
systemPersonID: sysID,
|
||
},
|
||
IdentityQ: windowIdentityQ{
|
||
systemPersonID: sysID,
|
||
activeTotal: 3, // the system person plus two members
|
||
joinedWithSystem: 2, // the system person is outside the window either way
|
||
joinedWithoutSystem: 2,
|
||
},
|
||
}
|
||
stats := h.loadOverview(context.Background()).Stats
|
||
if stats[0].Caption != "2 joined in the last 30 days" {
|
||
t.Errorf("People caption = %q, want \"2 joined in the last 30 days\"", stats[0].Caption)
|
||
}
|
||
}
|
||
|
||
// windowIdentityQ models CountPersonsJoinedLast30Days's exclude_person_id
|
||
// parameter: joinedWithoutSystem is what the fake returns when the resolved
|
||
// system person id arrives (as the real query's WHERE person_id <> $1
|
||
// would produce), joinedWithSystem when it does not.
|
||
type windowIdentityQ struct {
|
||
identity.Querier
|
||
systemPersonID string
|
||
activeTotal int64
|
||
joinedWithSystem int64
|
||
joinedWithoutSystem int64
|
||
}
|
||
|
||
func (q windowIdentityQ) CountActivePersons(context.Context) (int64, error) {
|
||
return q.activeTotal, nil
|
||
}
|
||
|
||
func (q windowIdentityQ) CountPersonsJoinedLast30Days(_ context.Context, exclude uuid.NullUUID) (int64, error) {
|
||
if exclude.Valid && exclude.UUID.String() == q.systemPersonID {
|
||
return q.joinedWithoutSystem, nil
|
||
}
|
||
return q.joinedWithSystem, nil
|
||
}
|
||
|
||
func (q windowIdentityQ) GetPersonByID(_ context.Context, personID string) (identity.Person, error) {
|
||
if personID == q.systemPersonID {
|
||
return identity.Person{PersonID: personID, Status: "active"}, nil
|
||
}
|
||
return identity.Person{}, errors.New("not found")
|
||
}
|