A default change saved the new default outside any lock, then moved every organization of the type inside the request, from a list read after the save. At size the request ran out of time. A failure part way left the rest unmoved, with "Details are in the server logs." as the only answer and committing again as the only re-run (#187). Migration 00028 gives the default change work it can own and resume: - core.plan_act_reached_org_types records which types an act reaches. core.plan_act_owns_org_type states once that the newest act to reach a type owns it, so a newer act takes over what an older one has not moved. - core.plan_act_obligations holds one row per organization the act must move. core.plan_act_orgs_without_pool records an organization with no default pool, which is logged and counted, not owed. - core.plan_act_listing_frontier states once which organizations the listing has not reached: the writer, the listing-done read, the poller's read and the card's count all call it, and PostgreSQL inlines it into each. - Four enclosed writers list, settle, record a failure (failed after five attempts) and requeue. core.record_plan_act now writes the reached types and refuses a reorder detail that disagrees with the ladder. core.grants.act_id names the act a grant came from. The commit's first transaction runs on the runner under the exclusive rendezvous, inside a 5 s deadline. It locks the type and refuses a reserved type, a candidate that is already the default ("Already the default.") and a ladder with no rank 0. It then saves the default and records the act. With no disposition given, it rolls back when an organization holds the outgoing default, and the 422 preview is rebuilt after the rollback through the capped read. A listing then writes the obligations in batches of 500, in org type and organization id order. Each move runs in its own transaction under the shared rendezvous. It locks the pool, locks the obligation together with its type's owner, and reads the current default and its rank 0. It then migrates or grandfathers the organization, settles it as already at the default, or, under an act with no disposition, leaves it untouched with a Warn line. A failure is recorded on the obligation alone with its step, in a plain transaction that takes no pool row. The request tries up to 50 obligations within 2 s. Its lock and transaction timeouts are 500 ms and 2 s, and a move they cut records no attempt. It then starts PlanActDrainWorkflow for the rest; PollPlanActs starts a drain for any act still owing work. The toast counts what is not yet reached and what has no default pool, and leaves out any count it could not read. The org type's card shows each open act's lines: organizations not yet reached, and those not moved, each with its reason, with Retry. They are read in two statements in one read-only repeatable-read transaction, and "Pending plan changes could not be read. Reference: {request id}." replaces them when that read fails. Retry is an act of its own: it requeues the failed obligations on the types the act still owns, and a retried organization is recorded under the operator who pressed Retry. It answers a lock wait with the collision sentence and any other failure with "Failed to retry. Reference: {request id}.". Along the way: - The default change's sentences ending "Details are in the server logs.", in six places, now end "Reference: {request id}.", logged through the request's logger. - The select's label becomes "New default", so the form library's refusal reads "Choose a new default from the list." and its required message "Choose a new default.". The change's specs now carry that label for org-types and form-library. The design states the frontier function and that the request's cap counts obligations tried, not settled. docs/database-locks.md, docs/plan-management.md and the two docs quoting the label follow the code. Goldens: - Behaviour changes in contention, default-already-at, default-migrate, default-none-migrate, default-needs-disposition, default-begin-fails, default-initiate-fails, default-migrate-restore-fails, default-grandfather, default-change-twice, default-current-refused, default-save-fails, the four removal-against-default files and the four tier-change-during-conferral files. - The three default form goldens move with the label. routes.golden gains the Retry route's probes. - default-list-fails is retired: no listing runs outside the act any more. New: default-listing-fails, default-superseded, default-request-cap, default-untouched-outgoing, default-retry, default-retry-nothing, default-preview-capped and default-gate-true-preview-empty. - The other 40 plan-act goldens gain only the reached-type, without-pool and obligation sections and the grants' act column. The full suite passes.
304 lines
13 KiB
Go
304 lines
13 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
// The default change's and the reorder's previews name at most 50
|
|
// organizations per class and state each class's exact count (openspec
|
|
// change plan-act-obligations, design D4 and D27, task 3.5), and the commit
|
|
// decides that a disposition is required with the database's gate, not with
|
|
// a list's length. Reuses the otc* fixture and harness
|
|
// (operator_org_type_change_test.go).
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/dbtest"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/testkit/htmltest"
|
|
)
|
|
|
|
// capClassSize is the number of organizations in each class the cap tests
|
|
// build: three more than the 50 names a list holds.
|
|
const capClassSize = 53
|
|
|
|
// buildCappedType gives the fixture's org type capClassSize organizations on
|
|
// the outgoing default (ladder A's product), capClassSize holding ladder A's
|
|
// product through an operator grant, and capClassSize with no default pool.
|
|
// Their names sort in creation order within a class.
|
|
func buildCappedType(t *testing.T, database *sql.DB, f otcFixture) {
|
|
t.Helper()
|
|
for i := 1; i <= capClassSize; i++ {
|
|
_, pool := otcOrg(t, database, f, fmt.Sprintf("Outgoing %03d", i), true)
|
|
otcConferDefaultA(t, database, f, pool)
|
|
|
|
org, _ := otcOrg(t, database, f, fmt.Sprintf("Other %03d", i), true)
|
|
if _, err := entitlements.ConferGrant(context.Background(), database, entitlements.ConferGrantInput{
|
|
ProductID: f.prodA, OrgID: org, GrantedByPersonID: f.operatorID,
|
|
GrantReason: "manual", Quantity: 1, ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(f.operatorID), Valid: true},
|
|
TransitionReason: "preview cap fixture",
|
|
}); err != nil {
|
|
t.Fatalf("confer an operator grant: %v", err)
|
|
}
|
|
|
|
otcOrg(t, database, f, fmt.Sprintf("Pool-less %03d", i), false)
|
|
}
|
|
}
|
|
|
|
// previewMessage is the message region that holds the preview's lists.
|
|
func previewMessage(t *testing.T, body string) *htmltest.Node {
|
|
t.Helper()
|
|
for _, region := range htmltest.Parse(t, body).All(".app-form-message") {
|
|
if strings.Contains(region.Text(), "would fail") {
|
|
return region
|
|
}
|
|
}
|
|
t.Fatalf("no form message holds the preview: %.500s", body)
|
|
return nil
|
|
}
|
|
|
|
// requireCappedLists requires the three lists of a preview body to hold 50
|
|
// names each under capClassSize organizations, with "And 3 more." under each
|
|
// and the exact count beside it. Both previews open a disclosure for the
|
|
// outgoing and for the other-source list.
|
|
func requireCappedLists(t *testing.T, body string, disclosures int) {
|
|
t.Helper()
|
|
doc := previewMessage(t, body)
|
|
details := doc.All("details")
|
|
if len(details) != disclosures {
|
|
t.Fatalf("%d disclosures, want %d", len(details), disclosures)
|
|
}
|
|
wantLabel := fmt.Sprintf("show 50 of %d", capClassSize)
|
|
for _, d := range details {
|
|
if got := d.One(t, "summary").Text(); got != wantLabel {
|
|
t.Errorf("disclosure label = %q, want %q", got, wantLabel)
|
|
}
|
|
if n := d.Count("li a"); n != 50 {
|
|
t.Errorf("a disclosure lists %d names, want 50", n)
|
|
}
|
|
if last := d.All("li"); len(last) == 0 || last[len(last)-1].Text() != "And 3 more." {
|
|
t.Errorf("a disclosure does not end with %q", "And 3 more.")
|
|
}
|
|
}
|
|
failed := doc.One(t, "li.text-danger")
|
|
if n := failed.Count("li a"); n != 50 {
|
|
t.Errorf("the would-fail list names %d organizations, want 50", n)
|
|
}
|
|
if want := fmt.Sprintf("%d would fail:", capClassSize); !strings.Contains(failed.Text(), want) {
|
|
t.Errorf("the would-fail line does not state its exact count %q: %s", want, failed.Text())
|
|
}
|
|
if !strings.HasSuffix(failed.Text(), "And 3 more.") {
|
|
t.Errorf("the would-fail list does not end with %q: %s", "And 3 more.", failed.Text())
|
|
}
|
|
text := doc.Text()
|
|
for _, want := range []string{
|
|
fmt.Sprintf("%d on a plan from another source; never touched.", capClassSize),
|
|
} {
|
|
if !strings.Contains(text, want) {
|
|
t.Errorf("the preview does not state %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// requireOtherSourceNamed requires the other-source list to name an
|
|
// organization with the source holding its plan.
|
|
func requireOtherSourceNamed(t *testing.T, body string) {
|
|
t.Helper()
|
|
for _, d := range previewMessage(t, body).All("details") {
|
|
if strings.Contains(d.Text(), "Other 001 (manual grant)") {
|
|
return
|
|
}
|
|
}
|
|
t.Errorf("no disclosure names %q: %.500s", "Other 001 (manual grant)", body)
|
|
}
|
|
|
|
// The default change's preview, its radio re-post and its refusal each read
|
|
// the type in two statements whatever its size, name 50 organizations per
|
|
// list, state exact counts and end each shortened list with "And 3 more.".
|
|
func TestOrgTypeDefaultChange_PreviewListsAreCapped(t *testing.T) {
|
|
database := dbtest.Open(t)
|
|
f := newOtcFixture(t, database)
|
|
buildCappedType(t, database, f)
|
|
counted, stmts := dbtest.Counted(t)
|
|
h := newOtcHarness(t, counted, f.operatorID)
|
|
|
|
for _, c := range []struct {
|
|
name string
|
|
handler http.HandlerFunc
|
|
form url.Values
|
|
status int
|
|
}{
|
|
{"the preview", h.handler.PreviewOrgTypeDefaultChange, url.Values{"default_plan_ladder_id": {f.ladderB}}, http.StatusOK},
|
|
{"the radio re-post", h.handler.PreviewOrgTypeDefaultChange, url.Values{"default_plan_ladder_id": {f.ladderB}, "bucket2_disposition": {"grandfather"}}, http.StatusOK},
|
|
{"the refusal of a commit with no disposition", h.handler.CommitOrgTypeDefaultChange, url.Values{"default_plan_ladder_id": {f.ladderB}}, http.StatusOK},
|
|
} {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
stmts.Reset()
|
|
code, body := h.post(c.handler, f.orgType, c.form)
|
|
if code != c.status {
|
|
t.Fatalf("status = %d, want %d", code, c.status)
|
|
}
|
|
by := stmts.ByName()
|
|
if by["ListPlanPoolBucketsByOrgType"] != 1 || by["ListLivePlanSourcesByPools"] != 1 {
|
|
t.Errorf("the type was read in %v, want one ListPlanPoolBucketsByOrgType and one ListLivePlanSourcesByPools", by)
|
|
}
|
|
requireCappedLists(t, body, 2)
|
|
requireOtherSourceNamed(t, body)
|
|
if want := fmt.Sprintf("%d organizations hold the outgoing default", capClassSize); !strings.Contains(body, want) {
|
|
t.Errorf("the outgoing count is not stated as %q", want)
|
|
}
|
|
if strings.Contains(c.name, "refusal") {
|
|
want := fmt.Sprintf("%d organization(s) hold the outgoing default. Choose whether to grandfather or migrate them before committing.", capClassSize)
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("the refusal does not state %q: %.400s", want, body)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The reorder's preview reads the type in two statements, lists the same
|
|
// classes under the same cap, and its
|
|
// refusal of a commit with no disposition counts every organization on the
|
|
// outgoing default, not the 50 its list names.
|
|
func TestTierReorder_PreviewListsAreCapped(t *testing.T) {
|
|
database := dbtest.Open(t)
|
|
f := newOtcFixture(t, database)
|
|
prodC := trAddTier(t, database, f.ladderA, "cap-c-"+uuid.NewString()[:8])
|
|
buildCappedType(t, database, f)
|
|
counted, stmts := dbtest.Counted(t)
|
|
h := newOtcHarness(t, counted, f.operatorID)
|
|
|
|
stmts.Reset()
|
|
code, body, _ := h.postLadder(h.handler.PreviewPlanLadderTiersReorder, f.ladderA, url.Values{"product": {prodC, f.prodA}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("preview status = %d", code)
|
|
}
|
|
if by := stmts.ByName(); by["ListPlanPoolBucketsByOrgType"] != 1 || by["ListLivePlanSourcesByPools"] != 1 {
|
|
t.Errorf("the type was read in %v, want one ListPlanPoolBucketsByOrgType and one ListLivePlanSourcesByPools", by)
|
|
}
|
|
requireCappedLists(t, body, 2)
|
|
requireOtherSourceNamed(t, body)
|
|
|
|
code, body, _ = h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
|
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}})
|
|
if code != http.StatusUnprocessableEntity {
|
|
t.Fatalf("commit status = %d, want 422", code)
|
|
}
|
|
requireCappedLists(t, body, 2)
|
|
if want := fmt.Sprintf("Choose whether to grandfather or migrate the %d organization(s) on the outgoing default.", capClassSize); !strings.Contains(body, want) {
|
|
t.Errorf("the refusal does not count the %d organizations: %.400s", capClassSize, body)
|
|
}
|
|
}
|
|
|
|
// strayOutgoingPosition puts a default-sourced position of the fixture's
|
|
// ladder-A product in a pool of another org type, with the copy of its org
|
|
// type naming the fixture's: the gate reads the copy and the preview reads
|
|
// the pool's organization, so the gate finds an outgoing position the
|
|
// rebuilt preview does not. It stands for a move that settled the last
|
|
// outgoing organization between the gate's rollback and the rebuild.
|
|
func strayOutgoingPosition(t *testing.T, database *sql.DB, f otcFixture) {
|
|
t.Helper()
|
|
g := newOtcFixture(t, database)
|
|
_, pool := otcOrg(t, database, g, "Stray holder", true)
|
|
otcConferDefaultA(t, database, g, pool)
|
|
res, err := database.ExecContext(context.Background(),
|
|
`UPDATE core.pool_provision_ladders SET org_type = $1 WHERE pool_id = $2`, f.orgType, pool)
|
|
if err != nil {
|
|
t.Fatalf("copy the stray position's org type: %v", err)
|
|
}
|
|
if n, _ := res.RowsAffected(); n != 1 {
|
|
t.Fatalf("copied the org type of %d positions, want 1", n)
|
|
}
|
|
// The row is deliberately wrong; no other test may see it. The guard
|
|
// triggers watch the source columns, not the copies, so the restore
|
|
// passes them.
|
|
t.Cleanup(func() {
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`UPDATE core.pool_provision_ladders SET org_type = $1 WHERE pool_id = $2`, g.orgType, pool); err != nil {
|
|
t.Errorf("restore the stray position's org type: %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A gate that is true while the rebuilt preview finds nothing outgoing
|
|
// answers the form's generic failure and logs an Error naming the reached
|
|
// types and the target. It does not ask for a disposition the card has no
|
|
// radio for, and it writes nothing.
|
|
func TestOrgTypeDefaultChange_GateWithoutOutgoingIsAFailureNotARequest(t *testing.T) {
|
|
database := dbtest.Open(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
otcOrg(t, database, f, "Plan-less", true)
|
|
strayOutgoingPosition(t, database, f)
|
|
before := otcCounts(t, database, f)
|
|
|
|
code, body := h.post(h.handler.CommitOrgTypeDefaultChange, f.orgType, url.Values{"default_plan_ladder_id": {f.ladderB}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status = %d, body %.300s", code, body)
|
|
}
|
|
if !strings.Contains(body, "Failed to update the default plan. Reference: ") {
|
|
t.Errorf("the generic failure is not answered: %.500s", body)
|
|
}
|
|
if strings.Contains(body, "grandfather or migrate") || strings.Contains(body, "bucket2_disposition") {
|
|
t.Errorf("a disposition is asked for although the card lists nothing outgoing: %.500s", body)
|
|
}
|
|
for _, want := range []string{"level=ERROR", "org_types=[" + f.orgType + "]", "target=" + f.prodB, "request_id=" + referenceIn(t, body)} {
|
|
if !strings.Contains(h.logs.String(), want) {
|
|
t.Errorf("the log lacks %q: %s", want, h.logs)
|
|
}
|
|
}
|
|
if after := otcCounts(t, database, f); fmt.Sprint(before) != fmt.Sprint(after) {
|
|
t.Errorf("the failed commit wrote rows: before=%v after=%v", before, after)
|
|
}
|
|
if got := otcDefaultLadder(t, database, f.orgType); got != "" {
|
|
t.Errorf("the failed commit saved the default %q", got)
|
|
}
|
|
}
|
|
|
|
// The reorder answers the same way: "Failed to reorder tiers." and an Error
|
|
// line, and the ranks stay as they were.
|
|
func TestTierReorder_GateWithoutOutgoingIsAFailureNotARequest(t *testing.T) {
|
|
database := dbtest.Open(t)
|
|
f := newOtcFixture(t, database)
|
|
h := newOtcHarness(t, database, f.operatorID)
|
|
prodC := trAddTier(t, database, f.ladderA, "gate-c-"+uuid.NewString()[:8])
|
|
if _, err := organization.New(database).UpdateOrgTypeDefaultPlanLadder(context.Background(), organization.UpdateOrgTypeDefaultPlanLadderParams{
|
|
OrgType: f.orgType,
|
|
DefaultPlanLadderID: uuid.NullUUID{UUID: uuid.MustParse(f.ladderA), Valid: true},
|
|
}); err != nil {
|
|
t.Fatalf("set the type's default to ladder A: %v", err)
|
|
}
|
|
strayOutgoingPosition(t, database, f)
|
|
|
|
code, body, _ := h.postLadder(h.handler.ReorderPlanLadderTiers, f.ladderA,
|
|
url.Values{"order": {strings.Join([]string{prodC, f.prodA}, ",")}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("status = %d, body %.300s", code, body)
|
|
}
|
|
if !strings.Contains(body, "Failed to reorder tiers.") {
|
|
t.Errorf("the generic failure is not answered: %.500s", body)
|
|
}
|
|
if strings.Contains(body, "Choose whether to grandfather or migrate") {
|
|
t.Errorf("a disposition is asked for although the preview lists nothing outgoing: %.500s", body)
|
|
}
|
|
for _, want := range []string{"level=ERROR", "org_types=[" + f.orgType + "]", "target=" + prodC} {
|
|
if !strings.Contains(h.logs.String(), want) {
|
|
t.Errorf("the log lacks %q: %s", want, h.logs)
|
|
}
|
|
}
|
|
if r := trRank(t, database, f.ladderA, f.prodA); r != 0 {
|
|
t.Errorf("prodA rank = %d after the failed commit, want 0", r)
|
|
}
|
|
}
|