Files
stalwart-migrator/internal/stalwartapi/task_test.go
T
jcoffey-dev 4b0bec8956 Add SPDX headers to every Go file
GPLv3's "How to Apply These Terms" asks for a notice in each source file;
this is the modern two-line SPDX form of it rather than the full paragraph.
82 files, including tests.

The blank line after the header is load-bearing. In Go a comment block
immediately preceding `package X` becomes the package doc comment, so
without the separator the SPDX lines would be absorbed into the doc for the
eleven packages whose doc.go (or main.go) opens with one, and `go doc` would
print them. Verified it doesn't.
2026-08-23 18:03:15 -07:00

284 lines
8.8 KiB
Go

// SPDX-FileCopyrightText: 2026 LINUXexpert-org
// SPDX-License-Identifier: GPL-3.0-or-later
package stalwartapi
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// taskServer answers x:Task/set and x:Task/get. queue models Stalwart's own
// task queue: a task that has run is *removed* from it, which is how
// completion is detected (see task.go's opening comment).
type taskServer struct {
mu sync.Mutex
queue map[string]string // task id -> status @type
created []map[string]any // the create objects received, in request order
notFound bool // reject every creation
}
func newTaskServer(t *testing.T) (*taskServer, *httptest.Server) {
t.Helper()
ts := &taskServer{queue: map[string]string{}}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
json.NewDecoder(r.Body).Decode(&body)
call := body["methodCalls"].([]any)[0].([]any)
name := call[0].(string)
args := call[1].(map[string]any)
ts.mu.Lock()
defer ts.mu.Unlock()
switch name {
case "x:Task/set":
create := args["create"].(map[string]any)
created := map[string]any{}
notCreated := map[string]any{}
for creationID, obj := range create {
ts.created = append(ts.created, obj.(map[string]any))
if ts.notFound {
notCreated[creationID] = map[string]any{"type": "forbidden"}
continue
}
id := "task-" + creationID
ts.queue[id] = "Pending"
created[creationID] = map[string]any{"id": id}
}
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Task/set", map[string]any{"created": created, "notCreated": notCreated}, "s"},
}})
case "x:Task/get":
var list []any
for _, raw := range args["ids"].([]any) {
id := raw.(string)
status, stillQueued := ts.queue[id]
if !stillQueued {
continue // consumed: finished
}
entry := map[string]any{"id": id, "status": map[string]any{"@type": status}}
if status == "Failed" {
entry["status"].(map[string]any)["failureReason"] = "store unavailable"
}
list = append(list, entry)
}
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Task/get", map[string]any{"list": list}, "g"},
}})
case "x:Account/query":
json.NewEncoder(w).Encode(map[string]any{"methodResponses": []any{
[]any{"x:Account/query", map[string]any{"ids": []string{"a1", "a2", "a3"}}, "q"},
}})
default:
t.Errorf("unexpected method call %s", name)
}
}))
t.Cleanup(srv.Close)
return ts, srv
}
func (ts *taskServer) finish(id string) {
ts.mu.Lock()
defer ts.mu.Unlock()
delete(ts.queue, id)
}
func (ts *taskServer) fail(id string) {
ts.mu.Lock()
defer ts.mu.Unlock()
ts.queue[id] = "Failed"
}
// The wire shape here comes from Stalwart's x:Task schema reference, so the
// test pins it: an AccountMaintenance variant with maintenanceType
// recalculateQuota, one per account, in a single Task/set call.
func TestCreateQuotaRecalculationTasksSendsOnePerAccount(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1", "a2"})
if err != nil {
t.Fatalf("CreateQuotaRecalculationTasks: %v", err)
}
if len(ids) != 2 {
t.Fatalf("created %d task ids, want 2: %v", len(ids), ids)
}
if len(ts.created) != 2 {
t.Fatalf("server received %d creations, want 2", len(ts.created))
}
seen := map[string]bool{}
for _, obj := range ts.created {
if obj["@type"] != "AccountMaintenance" {
t.Errorf("@type = %v, want AccountMaintenance", obj["@type"])
}
if obj["maintenanceType"] != "recalculateQuota" {
t.Errorf("maintenanceType = %v, want recalculateQuota", obj["maintenanceType"])
}
status := obj["status"].(map[string]any)
if status["@type"] != "Pending" {
t.Errorf("status.@type = %v, want Pending", status["@type"])
}
seen[obj["accountId"].(string)] = true
}
if !seen["a1"] || !seen["a2"] {
t.Errorf("accountIds sent = %v, want a1 and a2", seen)
}
}
func TestCreateTenantQuotaRecalculationTasksUsesTenantVariant(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if _, err := client.CreateTenantQuotaRecalculationTasks(context.Background(), []string{"t1"}); err != nil {
t.Fatal(err)
}
if got := ts.created[0]["@type"]; got != "TenantMaintenance" {
t.Errorf("@type = %v, want TenantMaintenance", got)
}
if got := ts.created[0]["tenantId"]; got != "t1" {
t.Errorf("tenantId = %v, want t1", got)
}
}
// Reporting "quotas recalculated" for an account whose task the server
// refused would be exactly the silent partial success this tool exists to
// catch.
func TestCreateQuotaRecalculationTasksFailsOnRefusedCreations(t *testing.T) {
ts, srv := newTaskServer(t)
ts.notFound = true
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
_, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1"})
if err == nil {
t.Fatal("want error when the server refuses a creation, got nil")
}
if !strings.Contains(err.Error(), "account a1") {
t.Errorf("error %q should name the account whose task was refused", err)
}
}
func TestCreateQuotaRecalculationTasksIsANoOpForNoAccounts(t *testing.T) {
_, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), nil)
if err != nil || len(ids) != 0 {
t.Errorf("want no ids and no error for an empty account list, got %v, %v", ids, err)
}
}
func TestWaitForTasksReturnsOnceTheQueueDrains(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1", "a2"})
if err != nil {
t.Fatal(err)
}
go func() {
time.Sleep(100 * time.Millisecond)
for _, id := range ids {
ts.finish(id)
}
}()
failures, err := client.WaitForTasks(context.Background(), ids, 10*time.Second)
if err != nil {
t.Fatalf("WaitForTasks: %v", err)
}
if len(failures) != 0 {
t.Errorf("failures = %v, want none", failures)
}
}
func TestWaitForTasksCollectsFailedTasksInsteadOfWaitingForever(t *testing.T) {
ts, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1", "a2"})
if err != nil {
t.Fatal(err)
}
ts.fail(ids[0])
ts.finish(ids[1])
failures, err := client.WaitForTasks(context.Background(), ids, 10*time.Second)
if err != nil {
t.Fatalf("a task reaching Failed is a result, not a polling error: %v", err)
}
if len(failures) != 1 || failures[0].TaskID != ids[0] {
t.Fatalf("failures = %v, want just %s", failures, ids[0])
}
if !strings.Contains(failures[0].Reason, "store unavailable") {
t.Errorf("failure reason = %q, want the server's own reason", failures[0].Reason)
}
}
// "Still running after the timeout" and "ran and failed" are different
// answers for an operator - one means wait longer, the other means
// something is wrong.
func TestWaitForTasksDistinguishesATimeoutFromAFailure(t *testing.T) {
_, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.CreateQuotaRecalculationTasks(context.Background(), []string{"a1"})
if err != nil {
t.Fatal(err)
}
failures, err := client.WaitForTasks(context.Background(), ids, 100*time.Millisecond)
if err == nil {
t.Fatal("want an error when tasks are still queued at the timeout, got nil")
}
if len(failures) != 0 {
t.Errorf("failures = %v, want none - a still-queued task hasn't failed", failures)
}
if !strings.Contains(err.Error(), "still queued") {
t.Errorf("error %q should say the tasks were still queued, not that they failed", err)
}
}
func TestAccountIDsSkipsTheMailboxWalk(t *testing.T) {
_, srv := newTaskServer(t)
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
ids, err := client.AccountIDs(context.Background())
if err != nil {
t.Fatal(err)
}
if len(ids) != 3 {
t.Errorf("AccountIDs = %v, want 3 ids", ids)
}
}
func TestWaitForPingReturnsOnceTheInstanceAnswers(t *testing.T) {
var mu sync.Mutex
up := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
if !up {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
json.NewEncoder(w).Encode(map[string]any{"apiUrl": "/api"})
}))
defer srv.Close()
go func() {
time.Sleep(100 * time.Millisecond)
mu.Lock()
up = true
mu.Unlock()
}()
client := &Client{BaseURL: srv.URL, Username: "admin", Password: "hunter2"}
if err := client.WaitForPing(context.Background(), 10*time.Second); err != nil {
t.Fatalf("WaitForPing: %v", err)
}
}