1516 lines
46 KiB
Go
1516 lines
46 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
|
eventtransport "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
|
)
|
|
|
|
type personalRecordingAttemptStore struct {
|
|
mu sync.Mutex
|
|
specs []personal.AttemptSpec
|
|
lease time.Duration
|
|
claimCalls int
|
|
completeCalls int
|
|
failureCalls int
|
|
releaseCalls int
|
|
claimErr error
|
|
completeErr error
|
|
completeFailure error
|
|
failureHold personal.AttemptHold
|
|
lastFailure personal.AttemptFailure
|
|
lastSucceeded []string
|
|
}
|
|
|
|
func (s *personalRecordingAttemptStore) Claim(
|
|
specs []personal.AttemptSpec,
|
|
lease time.Duration,
|
|
) (*personal.AttemptClaim, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.claimCalls++
|
|
s.specs = append([]personal.AttemptSpec(nil), specs...)
|
|
s.lease = lease
|
|
if s.claimErr != nil {
|
|
return nil, s.claimErr
|
|
}
|
|
fingerprints := make([]string, 0, len(specs))
|
|
for _, spec := range specs {
|
|
fingerprints = append(fingerprints, spec.Fingerprint)
|
|
}
|
|
return &personal.AttemptClaim{
|
|
AttemptID: "recording-attempt",
|
|
Fingerprints: fingerprints,
|
|
}, nil
|
|
}
|
|
|
|
func (s *personalRecordingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.completeCalls++
|
|
return s.completeErr
|
|
}
|
|
|
|
func (s *personalRecordingAttemptStore) CompleteFailure(
|
|
_ *personal.AttemptClaim,
|
|
succeeded []string,
|
|
failure personal.AttemptFailure,
|
|
) (personal.AttemptHold, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.failureCalls++
|
|
s.lastFailure = failure
|
|
s.lastSucceeded = append([]string(nil), succeeded...)
|
|
if s.completeFailure != nil {
|
|
return personal.AttemptHold{}, s.completeFailure
|
|
}
|
|
hold := s.failureHold
|
|
hold.Fingerprint = failure.Fingerprint
|
|
hold.Retryability = failure.Retryability
|
|
return hold, nil
|
|
}
|
|
|
|
func (s *personalRecordingAttemptStore) Release(*personal.AttemptClaim) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.releaseCalls++
|
|
return nil
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvents(t *testing.T) {
|
|
oldFactory := personalNewSubscriptionAttemptStore
|
|
t.Cleanup(func() { personalNewSubscriptionAttemptStore = oldFactory })
|
|
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
client := personal.NewClient("https://mcp.example.test/dws", identity)
|
|
publicCount := 0
|
|
ruleTypes := make(map[string]bool)
|
|
fingerprints := make(map[string]bool)
|
|
|
|
for _, definition := range personal.Definitions() {
|
|
if !definition.Public {
|
|
continue
|
|
}
|
|
publicCount++
|
|
ruleTypes[definition.RuleType] = true
|
|
opts := personalConsumeOptions{EventKey: definition.EventKey}
|
|
switch definition.RuleType {
|
|
case "singleChat", "sender":
|
|
opts.UserID = "target-user"
|
|
case "group":
|
|
opts.GroupID = "target-group"
|
|
}
|
|
|
|
recording := &personalRecordingAttemptStore{}
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return recording
|
|
}
|
|
reservation, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
client,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{opts},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("%s reservation failed: %v", definition.EventKey, err)
|
|
}
|
|
if recording.claimCalls != 1 || len(recording.specs) != 1 {
|
|
t.Fatalf("%s claim = %d, %#v", definition.EventKey, recording.claimCalls, recording.specs)
|
|
}
|
|
spec := recording.specs[0]
|
|
if spec.EventKey != definition.EventKey || len(spec.Fingerprint) != 64 {
|
|
t.Fatalf("%s spec = %#v", definition.EventKey, spec)
|
|
}
|
|
if fingerprints[spec.Fingerprint] {
|
|
t.Fatalf("%s reused another event fingerprint", definition.EventKey)
|
|
}
|
|
fingerprints[spec.Fingerprint] = true
|
|
if err := reservation.completeSuccess(); err != nil {
|
|
t.Fatalf("%s completion failed: %v", definition.EventKey, err)
|
|
}
|
|
}
|
|
|
|
if publicCount != 23 {
|
|
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
|
|
}
|
|
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
|
if !ruleTypes[ruleType] {
|
|
t.Errorf("rule type %s was not protected", ruleType)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionFingerprintChangesWithLogicalInputs(t *testing.T) {
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
fingerprint := func(endpoint, profile string, opts personalConsumeOptions) string {
|
|
t.Helper()
|
|
prepared, err := preparePersonalSubscription(identity, opts)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return personal.Fingerprint(endpoint, prepared.Request.IdempotencyKey, profile)
|
|
}
|
|
baseOpts := personalConsumeOptions{
|
|
EventKey: personal.EventSingleChat,
|
|
UserID: "target-a",
|
|
}
|
|
base := fingerprint("https://mcp.example.test/dws", "profile-a", baseOpts)
|
|
candidates := map[string]string{
|
|
"endpoint": fingerprint("https://other.example.test/dws", "profile-a", baseOpts),
|
|
"profile": fingerprint("https://mcp.example.test/dws", "profile-b", baseOpts),
|
|
"event": fingerprint(
|
|
"https://mcp.example.test/dws",
|
|
"profile-a",
|
|
personalConsumeOptions{EventKey: personal.EventFromUser, UserID: "target-a"},
|
|
),
|
|
"target": fingerprint(
|
|
"https://mcp.example.test/dws",
|
|
"profile-a",
|
|
personalConsumeOptions{EventKey: personal.EventSingleChat, UserID: "target-b"},
|
|
),
|
|
"filter": fingerprint(
|
|
"https://mcp.example.test/dws",
|
|
"profile-a",
|
|
personalConsumeOptions{
|
|
EventKey: personal.EventSingleChat,
|
|
UserID: "target-a",
|
|
QueryCSV: "urgent",
|
|
},
|
|
),
|
|
}
|
|
for dimension, candidate := range candidates {
|
|
if candidate == base {
|
|
t.Errorf("%s did not change subscription fingerprint", dimension)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionRejectsMalformedEndpointBeforeClaim(t *testing.T) {
|
|
oldFactory := personalNewSubscriptionAttemptStore
|
|
t.Cleanup(func() { personalNewSubscriptionAttemptStore = oldFactory })
|
|
factoryCalls := 0
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
factoryCalls++
|
|
return &personalRecordingAttemptStore{}
|
|
}
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
client := personal.NewClient("://malformed", identity)
|
|
_, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
client,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{{EventKey: personal.EventMention}},
|
|
)
|
|
if err == nil {
|
|
t.Fatal("malformed subscription endpoint was accepted")
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryValidation ||
|
|
!typed.RetryableSet || typed.Retryable {
|
|
t.Fatalf("malformed endpoint error = %#v, %v", typed, err)
|
|
}
|
|
if factoryCalls != 0 {
|
|
t.Fatalf("attempt-store factory calls = %d, want 0", factoryCalls)
|
|
}
|
|
}
|
|
|
|
type personalRoundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (fn personalRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) {
|
|
return fn(request)
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionProtectionConcurrentHundredAllowsOneHTTPRequest(t *testing.T) {
|
|
oldFactory := personalNewSubscriptionAttemptStore
|
|
t.Cleanup(func() { personalNewSubscriptionAttemptStore = oldFactory })
|
|
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
|
|
return personal.NewAttemptStore(workDir)
|
|
}
|
|
|
|
const concurrency = 100
|
|
workDir := t.TempDir()
|
|
identity := personal.Identity{
|
|
AccessToken: "token",
|
|
CorpID: "corp",
|
|
UserID: "self",
|
|
ClientID: "client",
|
|
SourceID: "source",
|
|
}
|
|
opts := personalConsumeOptions{EventKey: personal.EventMention}
|
|
var requests atomic.Int64
|
|
requestStarted := make(chan struct{}, concurrency)
|
|
releaseRequest := make(chan struct{})
|
|
transport := personalRoundTripFunc(func(request *http.Request) (*http.Response, error) {
|
|
if request.URL.Path != "/dws/subscription/user" {
|
|
t.Errorf("request path = %q", request.URL.Path)
|
|
}
|
|
requests.Add(1)
|
|
requestStarted <- struct{}{}
|
|
<-releaseRequest
|
|
return &http.Response{
|
|
StatusCode: http.StatusOK,
|
|
Header: make(http.Header),
|
|
Body: io.NopCloser(strings.NewReader(
|
|
`{"success":true,"result":["sub-one"]}`,
|
|
)),
|
|
Request: request,
|
|
}, nil
|
|
})
|
|
|
|
start := make(chan struct{})
|
|
results := make(chan error, concurrency)
|
|
for i := 0; i < concurrency; i++ {
|
|
go func() {
|
|
<-start
|
|
client := personal.NewClient("https://mcp.example.test/dws", identity)
|
|
client.HTTPClient = &http.Client{Transport: transport, Timeout: 30 * time.Second}
|
|
reservation, err := reservePersonalSubscriptionAttempts(
|
|
workDir,
|
|
client,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{opts},
|
|
)
|
|
if err != nil {
|
|
results <- err
|
|
return
|
|
}
|
|
_, _, _, err = ensurePersonalSubscription(
|
|
context.Background(),
|
|
client,
|
|
identity,
|
|
opts,
|
|
)
|
|
if err == nil {
|
|
err = reservation.completeSuccess()
|
|
}
|
|
results <- err
|
|
}()
|
|
}
|
|
close(start)
|
|
|
|
select {
|
|
case <-requestStarted:
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("no subscription HTTP request started")
|
|
}
|
|
|
|
for i := 0; i < concurrency-1; i++ {
|
|
select {
|
|
case err := <-results:
|
|
var blocked *personal.AttemptBlockedError
|
|
if !errors.As(err, &blocked) || blocked.State != personal.AttemptStateInFlight {
|
|
t.Fatalf("concurrent loser error = %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatalf("only %d concurrent losers completed", i)
|
|
}
|
|
}
|
|
close(releaseRequest)
|
|
select {
|
|
case err := <-results:
|
|
if err != nil {
|
|
t.Fatalf("winning request failed: %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("winning request did not complete")
|
|
}
|
|
if got := requests.Load(); got != 1 {
|
|
t.Fatalf("subscription HTTP requests = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionFailureClassification(t *testing.T) {
|
|
retryable, nonRetryable := true, false
|
|
now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC)
|
|
networkErr := &url.Error{
|
|
Op: "Post",
|
|
URL: "https://mcp.example.test",
|
|
Err: &net.DNSError{IsTimeout: true},
|
|
}
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want personal.Retryability
|
|
wantReason string
|
|
wantAuth bool
|
|
wantRetryWait time.Duration
|
|
}{
|
|
{
|
|
name: "explicit false",
|
|
err: &personal.APIError{
|
|
Code: "GROUP_NOT_BELONG_TO_ORG",
|
|
Retryable: &nonRetryable,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_server_non_retryable",
|
|
},
|
|
{
|
|
name: "explicit true",
|
|
err: &personal.APIError{
|
|
Code: "BUSY", Retryable: &retryable,
|
|
},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_server_retryable",
|
|
},
|
|
{
|
|
name: "terminal business code",
|
|
err: &personal.APIError{
|
|
Code: "USER_NOT_FOUND",
|
|
HTTPStatus: http.StatusOK,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_business_rejected",
|
|
},
|
|
{
|
|
name: "permission",
|
|
err: &personal.APIError{
|
|
Code: "PERMISSION_DENIED", HTTPStatus: http.StatusOK,
|
|
},
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_business_rejected",
|
|
wantAuth: true,
|
|
},
|
|
{
|
|
name: "403 with existing id remains an auth rejection",
|
|
err: &personal.APIError{
|
|
Code: "PROXY_AUTH_FAILURE",
|
|
HTTPStatus: http.StatusForbidden,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_auth",
|
|
wantAuth: true,
|
|
},
|
|
{
|
|
name: "429",
|
|
err: &personal.APIError{
|
|
Code: "RATE_LIMIT", HTTPStatus: http.StatusTooManyRequests,
|
|
},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_transient_http",
|
|
},
|
|
{
|
|
name: "408",
|
|
err: &personal.APIError{
|
|
Code: "REQUEST_TIMEOUT", HTTPStatus: http.StatusRequestTimeout,
|
|
},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_transient_http",
|
|
},
|
|
{
|
|
name: "425",
|
|
err: &personal.APIError{
|
|
Code: "TOO_EARLY", HTTPStatus: http.StatusTooEarly,
|
|
},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_transient_http",
|
|
},
|
|
{
|
|
name: "429 overrides terminal-looking business marker",
|
|
err: &personal.APIError{
|
|
Code: "EVENT_KEY_NOT_SUPPORTED", HTTPStatus: http.StatusTooManyRequests,
|
|
},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_transient_http",
|
|
},
|
|
{
|
|
name: "5xx",
|
|
err: &personal.APIError{
|
|
Code: "SYSTEM_ERROR", HTTPStatus: http.StatusBadGateway,
|
|
},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_transient_http",
|
|
},
|
|
{
|
|
name: "http 200 system error remains unknown",
|
|
err: &personal.APIError{
|
|
Code: "SYSTEM_ERROR", HTTPStatus: http.StatusOK,
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unknown",
|
|
},
|
|
{
|
|
name: "unknown required code remains unknown",
|
|
err: &personal.APIError{
|
|
Code: "RETRY_REQUIRED", HTTPStatus: http.StatusOK,
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unknown",
|
|
},
|
|
{
|
|
name: "legacy DUP with existing id remains an unknown error",
|
|
err: &personal.APIError{
|
|
Code: "DUP",
|
|
HTTPStatus: http.StatusBadRequest,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unverified_existing_id",
|
|
},
|
|
{
|
|
name: "legacy SUBSCRIPTION_ALREADY_EXIST with existing id remains an unknown error",
|
|
err: &personal.APIError{
|
|
Code: "SUBSCRIPTION_ALREADY_EXIST",
|
|
HTTPStatus: http.StatusBadRequest,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unverified_existing_id",
|
|
},
|
|
{
|
|
name: "legacy ALREADY_SUBSCRIBED with existing id remains an unknown error",
|
|
err: &personal.APIError{
|
|
Code: "ALREADY_SUBSCRIBED",
|
|
HTTPStatus: http.StatusBadRequest,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unverified_existing_id",
|
|
},
|
|
{
|
|
name: "legacy DUPLICATE with existing id remains an unknown error",
|
|
err: &personal.APIError{
|
|
Code: "DUPLICATE",
|
|
HTTPStatus: http.StatusBadRequest,
|
|
Details: map[string]any{"subscribe_id": "sub-existing"},
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unverified_existing_id",
|
|
},
|
|
{
|
|
name: "network",
|
|
err: networkErr,
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_network",
|
|
},
|
|
{
|
|
name: "malformed url is local validation",
|
|
err: &url.Error{
|
|
Op: "parse", URL: "://malformed", Err: errors.New("missing protocol scheme"),
|
|
},
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_invalid",
|
|
},
|
|
{
|
|
name: "truncated response body",
|
|
err: fmt.Errorf("read response: %w", io.ErrUnexpectedEOF),
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_network",
|
|
},
|
|
{
|
|
name: "deadline",
|
|
err: context.DeadlineExceeded,
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_timeout",
|
|
},
|
|
{
|
|
name: "longest retry after wins",
|
|
err: &personal.APIError{
|
|
Code: "BUSY",
|
|
RetryAfterSeconds: int64Pointer(10),
|
|
NextRetryAt: timePointer(now.Add(45 * time.Second)),
|
|
Details: map[string]any{"retry_after": "60"},
|
|
},
|
|
want: personal.RetryabilityUnknown,
|
|
wantReason: "personal_subscription_unknown",
|
|
wantRetryWait: time.Minute,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got := classifyPersonalSubscriptionFailure(test.err, now)
|
|
if got.retryability != test.want || got.reason != test.wantReason ||
|
|
got.auth != test.wantAuth || got.retryAfter != test.wantRetryWait {
|
|
t.Fatalf("classification = %#v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionFailureErrorPreservesTriStateAndDiagnostics(t *testing.T) {
|
|
now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC)
|
|
hold := personal.AttemptHold{
|
|
State: personal.AttemptStateTerminalHold,
|
|
Retryability: personal.RetryabilityNonRetryable,
|
|
RetryAfter: time.Hour,
|
|
NextAllowedAt: now.Add(time.Hour),
|
|
}
|
|
cause := &personal.APIError{
|
|
Code: "GROUP_NOT_BELONG_TO_ORG",
|
|
Message: "group does not belong to organization",
|
|
TraceID: "trace-1",
|
|
}
|
|
classification := personalSubscriptionFailureClass{
|
|
retryability: personal.RetryabilityNonRetryable,
|
|
code: cause.Code,
|
|
traceID: cause.TraceID,
|
|
reason: "personal_subscription_business_rejected",
|
|
}
|
|
err := personalSubscriptionFailureError(cause, classification, hold)
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) {
|
|
t.Fatalf("error type = %T", err)
|
|
}
|
|
if !typed.RetryableSet || typed.Retryable ||
|
|
typed.RetryAfterSeconds == nil || *typed.RetryAfterSeconds != 3600 ||
|
|
typed.NextRetryAt == nil || !typed.NextRetryAt.Equal(now.Add(time.Hour)) ||
|
|
typed.ServerDiag.ServerErrorCode != cause.Code ||
|
|
typed.ServerDiag.TraceID != cause.TraceID {
|
|
t.Fatalf("typed error = %#v", typed)
|
|
}
|
|
|
|
unknown := personalSubscriptionFailureError(
|
|
errors.New("unknown"),
|
|
personalSubscriptionFailureClass{
|
|
retryability: personal.RetryabilityUnknown,
|
|
reason: "personal_subscription_unknown",
|
|
},
|
|
personal.AttemptHold{
|
|
Retryability: personal.RetryabilityUnknown,
|
|
RetryAfter: 30 * time.Second,
|
|
NextAllowedAt: now.Add(30 * time.Second),
|
|
},
|
|
)
|
|
if !errors.As(unknown, &typed) || typed.RetryableSet {
|
|
t.Fatalf("unknown retryability was not preserved: %#v", typed)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionBatchClaimFailureMakesZeroCreateCalls(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return identity, nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
createCalls := 0
|
|
personalEnsureSubscription = func(
|
|
context.Context,
|
|
*personal.Client,
|
|
personal.Identity,
|
|
personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
createCalls++
|
|
return nil, "", "", errors.New("must not run")
|
|
}
|
|
|
|
next := time.Now().UTC().Add(time.Minute)
|
|
recording := &personalRecordingAttemptStore{
|
|
claimErr: &personal.AttemptBlockedError{
|
|
State: personal.AttemptStateCooldown,
|
|
Retryability: personal.RetryabilityUnknown,
|
|
RetryAfter: time.Minute,
|
|
NextAllowedAt: next,
|
|
},
|
|
}
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return recording
|
|
}
|
|
|
|
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
})
|
|
if err == nil {
|
|
t.Fatal("blocked batch succeeded")
|
|
}
|
|
if createCalls != 0 {
|
|
t.Fatalf("create calls = %d, want 0", createCalls)
|
|
}
|
|
if recording.claimCalls != 1 || len(recording.specs) != 2 {
|
|
t.Fatalf("batch claim = %d, %#v", recording.claimCalls, recording.specs)
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.RetryableSet ||
|
|
typed.NextRetryAt == nil || !typed.NextRetryAt.Equal(next) {
|
|
t.Fatalf("blocked error = %#v, %v", typed, err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionAttemptGuardBypassesNonCreatePaths(t *testing.T) {
|
|
oldFactory := personalNewSubscriptionAttemptStore
|
|
oldIdentity := personalResolveEventIdentity
|
|
oldEnsure := personalEnsureSubscription
|
|
oldGet := personalGetSubscription
|
|
oldList := personalListSubscriptions
|
|
oldUpsert := personalUpsertRunState
|
|
oldDelete := personalDeleteSubscription
|
|
oldRemove := personalRemoveRunStates
|
|
oldLoad := personalLoadRunStates
|
|
oldConsume := personalConsumeRun
|
|
oldRunMany := personalConsumeRunMany
|
|
oldValidate := personalValidateConsumeConfig
|
|
oldConflict := personalValidateNoOutputConflict
|
|
oldStopConsumers := personalStopConsumers
|
|
oldStopBus := personalStopBus
|
|
t.Cleanup(func() {
|
|
personalNewSubscriptionAttemptStore = oldFactory
|
|
personalResolveEventIdentity = oldIdentity
|
|
personalEnsureSubscription = oldEnsure
|
|
personalGetSubscription = oldGet
|
|
personalListSubscriptions = oldList
|
|
personalUpsertRunState = oldUpsert
|
|
personalDeleteSubscription = oldDelete
|
|
personalRemoveRunStates = oldRemove
|
|
personalLoadRunStates = oldLoad
|
|
personalConsumeRun = oldConsume
|
|
personalConsumeRunMany = oldRunMany
|
|
personalValidateConsumeConfig = oldValidate
|
|
personalValidateNoOutputConflict = oldConflict
|
|
personalStopConsumers = oldStopConsumers
|
|
personalStopBus = oldStopBus
|
|
})
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
recording := &personalRecordingAttemptStore{}
|
|
factoryCalls := 0
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
factoryCalls++
|
|
return recording
|
|
}
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return identity, nil
|
|
}
|
|
personalEnsureSubscription = ensurePersonalSubscription
|
|
personalGetSubscription = func(
|
|
*personal.Client,
|
|
context.Context,
|
|
string,
|
|
) (*personal.Subscription, error) {
|
|
return &personal.Subscription{
|
|
SubscribeID: "sub-existing",
|
|
EventKey: personal.EventMention,
|
|
RuleType: "at",
|
|
Status: "active",
|
|
}, nil
|
|
}
|
|
personalListSubscriptions = func(
|
|
*personal.Client,
|
|
context.Context,
|
|
personal.ListOptions,
|
|
) ([]personal.Subscription, error) {
|
|
return []personal.Subscription{{
|
|
SubscribeID: "sub-existing",
|
|
EventKey: personal.EventMention,
|
|
RuleType: "at",
|
|
Status: "active",
|
|
}}, nil
|
|
}
|
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
personalLoadRunStates = func(string) ([]personal.RunState, error) { return nil, nil }
|
|
personalConsumeRun = func(context.Context, consume.Config) error { return nil }
|
|
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
|
return nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
|
personalStopConsumers = func(string, []string) (eventtransport.ConsumerStopResp, error) {
|
|
return eventtransport.ConsumerStopResp{Stopped: []string{"sub-existing"}}, nil
|
|
}
|
|
personalStopBus = func(busctl.StopConfig) error { return busctl.ErrNotRunning }
|
|
|
|
assertNoAttemptClaim := func(stage string) {
|
|
t.Helper()
|
|
recording.mu.Lock()
|
|
claimCalls := recording.claimCalls
|
|
recording.mu.Unlock()
|
|
if factoryCalls != 0 || claimCalls != 0 {
|
|
t.Fatalf(
|
|
"%s used subscription attempt guard: factory calls=%d claim calls=%d",
|
|
stage,
|
|
factoryCalls,
|
|
claimCalls,
|
|
)
|
|
}
|
|
}
|
|
|
|
if err := runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{
|
|
EventKey: personal.EventMention,
|
|
Common: commonConsumeOptions{DryRun: true},
|
|
},
|
|
); err != nil {
|
|
t.Fatalf("single dry-run error = %v", err)
|
|
}
|
|
assertNoAttemptClaim("single dry-run")
|
|
|
|
if err := runPersonalEventConsumeMany(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
Common: commonConsumeOptions{DryRun: true},
|
|
},
|
|
); err != nil {
|
|
t.Fatalf("multi dry-run error = %v", err)
|
|
}
|
|
assertNoAttemptClaim("multi dry-run")
|
|
|
|
if err := runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{SubscribeID: "sub-existing"},
|
|
); err != nil {
|
|
t.Fatalf("subscribe-id reuse error = %v", err)
|
|
}
|
|
assertNoAttemptClaim("subscribe-id reuse")
|
|
|
|
if err := runPersonalEventStatus(
|
|
newPersonalCoverageCommand(),
|
|
personalStatusOptions{Status: "all", Format: "json"},
|
|
); err != nil {
|
|
t.Fatalf("status error = %v", err)
|
|
}
|
|
assertNoAttemptClaim("status")
|
|
|
|
if err := runPersonalEventStop(
|
|
newPersonalCoverageCommand(),
|
|
personalStopOptions{SubscribeID: "sub-existing"},
|
|
); err != nil {
|
|
t.Fatalf("stop error = %v", err)
|
|
}
|
|
assertNoAttemptClaim("stop")
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionLocalValidationRunsBeforeClaimAndCreate(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return identity, nil
|
|
}
|
|
wantErr := errors.New("invalid local output")
|
|
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
|
|
recording := &personalRecordingAttemptStore{}
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return recording
|
|
}
|
|
createCalls := 0
|
|
personalEnsureSubscription = func(
|
|
context.Context,
|
|
*personal.Client,
|
|
personal.Identity,
|
|
personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
createCalls++
|
|
return nil, "", "", errors.New("must not run")
|
|
}
|
|
|
|
err := runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{EventKey: personal.EventMention},
|
|
)
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("error = %v, want %v", err, wantErr)
|
|
}
|
|
if recording.claimCalls != 0 || createCalls != 0 {
|
|
t.Fatalf("claim calls = %d, create calls = %d", recording.claimCalls, createCalls)
|
|
}
|
|
|
|
err = runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{
|
|
EventKey: personal.EventMention,
|
|
Flatten: true,
|
|
DebugRawEvents: true,
|
|
},
|
|
)
|
|
if err == nil {
|
|
t.Fatal("conflicting output modes succeeded")
|
|
}
|
|
if recording.claimCalls != 0 || createCalls != 0 {
|
|
t.Fatalf("output validation reached claim/create: %d/%d", recording.claimCalls, createCalls)
|
|
}
|
|
|
|
err = runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{
|
|
EventKey: personal.EventInChat,
|
|
Common: commonConsumeOptions{DryRun: true},
|
|
},
|
|
)
|
|
if err == nil {
|
|
t.Fatal("invalid dry-run subscription options succeeded")
|
|
}
|
|
if recording.claimCalls != 0 || createCalls != 0 {
|
|
t.Fatalf("dry-run validation reached claim/create: %d/%d", recording.claimCalls, createCalls)
|
|
}
|
|
|
|
claimErr := errors.New("claim failed")
|
|
recording.claimErr = claimErr
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
err = runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{EventKey: personal.EventMention},
|
|
)
|
|
if !errors.Is(err, claimErr) {
|
|
t.Fatalf("claim error = %v, want %v", err, claimErr)
|
|
}
|
|
if recording.claimCalls != 1 || createCalls != 0 {
|
|
t.Fatalf("failed claim calls = %d, create calls = %d", recording.claimCalls, createCalls)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionSingleAttemptCompletionErrors(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cancelContext bool
|
|
nilSub bool
|
|
upsertErr error
|
|
completeErr error
|
|
wantRelease int
|
|
wantFailure int
|
|
wantComplete int
|
|
wantDelete int
|
|
wantCanceledGC bool
|
|
wantUnknown bool
|
|
}{
|
|
{
|
|
name: "nil subscription records failure",
|
|
nilSub: true,
|
|
wantFailure: 1,
|
|
},
|
|
{
|
|
name: "completion failure cleans subscription",
|
|
completeErr: errors.New("complete success failed"),
|
|
wantComplete: 1,
|
|
wantDelete: 1,
|
|
},
|
|
{
|
|
name: "local state failure records unknown cooldown",
|
|
upsertErr: errors.New("save state failed"),
|
|
wantFailure: 1,
|
|
wantDelete: 1,
|
|
wantUnknown: true,
|
|
},
|
|
{
|
|
name: "canceled local failure releases and uses canceled cleanup context",
|
|
cancelContext: true,
|
|
upsertErr: errors.New("save state failed"),
|
|
wantRelease: 1,
|
|
wantDelete: 1,
|
|
wantCanceledGC: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
identity := personal.Identity{
|
|
AccessToken: "token",
|
|
ClientID: "client",
|
|
SourceID: "open",
|
|
LocalSubject: "subject",
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return identity, nil
|
|
}
|
|
recording := &personalRecordingAttemptStore{completeErr: test.completeErr}
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return recording
|
|
}
|
|
personalEnsureSubscription = func(
|
|
context.Context,
|
|
*personal.Client,
|
|
personal.Identity,
|
|
personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
if test.nilSub {
|
|
return nil, "", "", nil
|
|
}
|
|
return &personal.Subscription{
|
|
SubscribeID: "sub-one",
|
|
}, personal.EventMention, "at", nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalUpsertRunState = func(string, personal.RunState) error {
|
|
return test.upsertErr
|
|
}
|
|
deleteCalls := 0
|
|
personalDeleteSubscription = func(
|
|
_ *personal.Client,
|
|
cleanupCtx context.Context,
|
|
_ string,
|
|
) error {
|
|
deleteCalls++
|
|
if got := cleanupCtx.Err() != nil; got != test.wantCanceledGC {
|
|
t.Errorf("cleanup canceled = %t, want %t", got, test.wantCanceledGC)
|
|
}
|
|
return nil
|
|
}
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
|
|
cmd := newPersonalCoverageCommand()
|
|
if test.cancelContext {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
cmd.SetContext(ctx)
|
|
}
|
|
err := runPersonalEventConsumeSingle(cmd, personalConsumeOptions{
|
|
EventKey: personal.EventMention,
|
|
ControlBaseURL: "https://mcp.example.test/dws",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("single subscription error path succeeded")
|
|
}
|
|
if recording.releaseCalls != test.wantRelease ||
|
|
recording.failureCalls != test.wantFailure ||
|
|
recording.completeCalls != test.wantComplete ||
|
|
deleteCalls != test.wantDelete {
|
|
t.Fatalf(
|
|
"release=%d failure=%d complete=%d delete=%d",
|
|
recording.releaseCalls,
|
|
recording.failureCalls,
|
|
recording.completeCalls,
|
|
deleteCalls,
|
|
)
|
|
}
|
|
if test.wantUnknown {
|
|
if recording.lastFailure.Retryability != personal.RetryabilityUnknown {
|
|
t.Fatalf("local failure retryability = %q", recording.lastFailure.Retryability)
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.RetryableSet {
|
|
t.Fatalf("local failure error = %#v, %v", typed, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionManyCompletionFailureCleansBatch(t *testing.T) {
|
|
restore := installPersonalManySeams(t)
|
|
defer restore()
|
|
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
|
|
|
identity := personal.Identity{
|
|
AccessToken: "token",
|
|
ClientID: "client",
|
|
SourceID: "open",
|
|
LocalSubject: "subject",
|
|
}
|
|
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
|
return identity, nil
|
|
}
|
|
recording := &personalRecordingAttemptStore{
|
|
completeErr: errors.New("complete success failed"),
|
|
}
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return recording
|
|
}
|
|
createCalls := 0
|
|
personalEnsureSubscription = func(
|
|
_ context.Context,
|
|
_ *personal.Client,
|
|
_ personal.Identity,
|
|
opts personalConsumeOptions,
|
|
) (*personal.Subscription, string, string, error) {
|
|
createCalls++
|
|
return &personal.Subscription{
|
|
SubscribeID: fmt.Sprintf("sub-%d", createCalls),
|
|
}, opts.EventKey, "all", nil
|
|
}
|
|
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
|
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
|
deleteCalls := 0
|
|
personalDeleteSubscription = func(*personal.Client, context.Context, string) error {
|
|
deleteCalls++
|
|
return nil
|
|
}
|
|
personalRemoveRunStates = func(string, []string) error { return nil }
|
|
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
|
t.Fatal("consumer ran after attempt completion failure")
|
|
return nil
|
|
}
|
|
|
|
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
|
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
|
ControlBaseURL: "https://mcp.example.test/dws",
|
|
})
|
|
if err == nil {
|
|
t.Fatal("multi subscription completion failure succeeded")
|
|
}
|
|
if recording.completeCalls != 1 || createCalls != 2 || deleteCalls != 2 {
|
|
t.Fatalf(
|
|
"complete=%d create=%d delete=%d",
|
|
recording.completeCalls,
|
|
createCalls,
|
|
deleteCalls,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionRejectsNonPublicEventsOnChangedPaths(t *testing.T) {
|
|
const nonPublicEvent = personal.EventMention
|
|
oldLookup := personalLookupDefinition
|
|
oldGet := personalGetSubscription
|
|
t.Cleanup(func() {
|
|
personalLookupDefinition = oldLookup
|
|
personalGetSubscription = oldGet
|
|
})
|
|
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
|
|
definition, ok := personal.Lookup(eventKey)
|
|
if eventKey == nonPublicEvent {
|
|
definition.Public = false
|
|
}
|
|
return definition, ok
|
|
}
|
|
|
|
err := runPersonalEventConsumeSingle(
|
|
newPersonalCoverageCommand(),
|
|
personalConsumeOptions{EventKey: nonPublicEvent},
|
|
)
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryValidation ||
|
|
!typed.RetryableSet || typed.Retryable {
|
|
t.Fatalf("single validation error = %#v, %v", typed, err)
|
|
}
|
|
|
|
if _, err := preparePersonalSubscription(
|
|
personal.Identity{},
|
|
personalConsumeOptions{EventKey: nonPublicEvent},
|
|
); err == nil {
|
|
t.Fatal("prepared a non-public personal event")
|
|
}
|
|
|
|
personalGetSubscription = func(
|
|
*personal.Client,
|
|
context.Context,
|
|
string,
|
|
) (*personal.Subscription, error) {
|
|
return &personal.Subscription{
|
|
SubscribeID: "sub-existing",
|
|
EventKey: nonPublicEvent,
|
|
}, nil
|
|
}
|
|
if _, _, _, err := ensurePersonalSubscription(
|
|
context.Background(),
|
|
nil,
|
|
personal.Identity{},
|
|
personalConsumeOptions{SubscribeID: "sub-existing"},
|
|
); err == nil {
|
|
t.Fatal("reused a subscription for a non-public personal event")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionAttemptLeaseBounds(t *testing.T) {
|
|
client := personal.NewClient("https://mcp.example.test/dws", personal.Identity{})
|
|
tests := []struct {
|
|
timeout time.Duration
|
|
batch int
|
|
want time.Duration
|
|
}{
|
|
{timeout: 10 * time.Second, batch: 1, want: time.Minute},
|
|
{timeout: 10 * time.Second, batch: 0, want: time.Minute},
|
|
{timeout: 30 * time.Second, batch: 2, want: 90 * time.Second},
|
|
{timeout: 30 * time.Second, batch: 16, want: 510 * time.Second},
|
|
{timeout: time.Hour, batch: 16, want: 10 * time.Minute},
|
|
}
|
|
for _, test := range tests {
|
|
client.HTTPClient.Timeout = test.timeout
|
|
if got := personalSubscriptionAttemptLease(client, test.batch); got != test.want {
|
|
t.Errorf("lease(%s, %d) = %s, want %s", test.timeout, test.batch, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionAttemptGuardHelperEdges(t *testing.T) {
|
|
oldFactory := personalNewSubscriptionAttemptStore
|
|
t.Cleanup(func() { personalNewSubscriptionAttemptStore = oldFactory })
|
|
|
|
t.Run("default store factory", func(t *testing.T) {
|
|
if store := oldFactory(t.TempDir()); store == nil {
|
|
t.Fatal("default attempt-store factory returned nil")
|
|
}
|
|
})
|
|
|
|
identity := personal.Identity{
|
|
CorpID: "corp", UserID: "self", ClientID: "client", SourceID: "source",
|
|
}
|
|
validClient := personal.NewClient("https://mcp.example.test/dws", identity)
|
|
validPlan := personalConsumeOptions{EventKey: personal.EventMention}
|
|
|
|
t.Run("empty batch", func(t *testing.T) {
|
|
if _, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
validClient,
|
|
identity,
|
|
"profile-a",
|
|
nil,
|
|
); err == nil {
|
|
t.Fatal("empty attempt batch was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("nil client", func(t *testing.T) {
|
|
if _, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
nil,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{validPlan},
|
|
); err == nil {
|
|
t.Fatal("nil subscription client was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("relative endpoint", func(t *testing.T) {
|
|
client := personal.NewClient("relative/control", identity)
|
|
if _, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
client,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{validPlan},
|
|
); err == nil {
|
|
t.Fatal("relative subscription endpoint was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("nil store", func(t *testing.T) {
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return nil
|
|
}
|
|
defer func() { personalNewSubscriptionAttemptStore = oldFactory }()
|
|
if _, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
validClient,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{validPlan},
|
|
); err == nil {
|
|
t.Fatal("nil subscription attempt store was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("claim failure", func(t *testing.T) {
|
|
wantErr := errors.New("claim failed")
|
|
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
|
return &personalRecordingAttemptStore{claimErr: wantErr}
|
|
}
|
|
defer func() { personalNewSubscriptionAttemptStore = oldFactory }()
|
|
_, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
validClient,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{validPlan},
|
|
)
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("claim error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid prepared subscription", func(t *testing.T) {
|
|
if _, err := reservePersonalSubscriptionAttempts(
|
|
t.TempDir(),
|
|
validClient,
|
|
identity,
|
|
"profile-a",
|
|
[]personalConsumeOptions{{EventKey: personal.EventInChat}},
|
|
); err == nil {
|
|
t.Fatal("invalid prepared subscription was accepted")
|
|
}
|
|
})
|
|
|
|
t.Run("incomplete success reservation", func(t *testing.T) {
|
|
if err := (&personalSubscriptionAttemptReservation{}).completeSuccess(); err == nil {
|
|
t.Fatal("incomplete reservation succeeded")
|
|
}
|
|
})
|
|
|
|
t.Run("success completion failure", func(t *testing.T) {
|
|
wantErr := errors.New("complete success failed")
|
|
reservation := &personalSubscriptionAttemptReservation{
|
|
store: &personalRecordingAttemptStore{completeErr: wantErr},
|
|
claim: &personal.AttemptClaim{AttemptID: "attempt"},
|
|
}
|
|
if err := reservation.completeSuccess(); !errors.Is(err, wantErr) {
|
|
t.Fatalf("complete-success error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
|
|
t.Run("nil failure reservation", func(t *testing.T) {
|
|
wantErr := errors.New("create failed")
|
|
var reservation *personalSubscriptionAttemptReservation
|
|
if err := reservation.completeFailure(
|
|
context.Background(),
|
|
0,
|
|
0,
|
|
wantErr,
|
|
nil,
|
|
); !errors.Is(err, wantErr) {
|
|
t.Fatalf("nil reservation error = %v, want %v", err, wantErr)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid failure indexes", func(t *testing.T) {
|
|
wantErr := errors.New("create failed")
|
|
reservation := &personalSubscriptionAttemptReservation{
|
|
store: &personalRecordingAttemptStore{},
|
|
claim: &personal.AttemptClaim{AttemptID: "attempt"},
|
|
items: []personalSubscriptionAttemptItem{{fingerprint: "fingerprint"}},
|
|
}
|
|
if err := reservation.completeFailure(
|
|
context.Background(),
|
|
1,
|
|
0,
|
|
wantErr,
|
|
nil,
|
|
); !errors.Is(err, wantErr) {
|
|
t.Fatalf("invalid-index error = %v, want joined cause %v", err, wantErr)
|
|
}
|
|
})
|
|
|
|
t.Run("incomplete cancellation reservation", func(t *testing.T) {
|
|
wantErr := context.Canceled
|
|
err := (&personalSubscriptionAttemptReservation{}).completeFailure(
|
|
context.Background(),
|
|
0,
|
|
0,
|
|
wantErr,
|
|
nil,
|
|
)
|
|
if !errors.Is(err, wantErr) {
|
|
t.Fatalf("incomplete-cancellation error = %v, want joined cause %v", err, wantErr)
|
|
}
|
|
var typed *apperrors.Error
|
|
if !errors.As(err, &typed) || typed.Reason != "personal_subscription_guard_failed" {
|
|
t.Fatalf("incomplete-cancellation guard error = %#v, %v", typed, err)
|
|
}
|
|
})
|
|
|
|
t.Run("failure completion failure", func(t *testing.T) {
|
|
createErr := errors.New("create failed")
|
|
completeErr := errors.New("complete failure failed")
|
|
reservation := &personalSubscriptionAttemptReservation{
|
|
store: &personalRecordingAttemptStore{completeFailure: completeErr},
|
|
claim: &personal.AttemptClaim{AttemptID: "attempt"},
|
|
items: []personalSubscriptionAttemptItem{{
|
|
eventKey: personal.EventMention,
|
|
fingerprint: strings.Repeat("a", 64),
|
|
}},
|
|
}
|
|
err := reservation.completeFailure(
|
|
context.Background(),
|
|
0,
|
|
0,
|
|
createErr,
|
|
nil,
|
|
)
|
|
if !errors.Is(err, createErr) || !errors.Is(err, completeErr) {
|
|
t.Fatalf("completion error = %v, want joined errors", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionFailureClassificationEdges(t *testing.T) {
|
|
now := time.Date(2026, 7, 30, 10, 0, 0, 0, time.UTC)
|
|
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
want personal.Retryability
|
|
wantReason string
|
|
wantAuth bool
|
|
}{
|
|
{
|
|
name: "plain rejected http status",
|
|
err: &personal.APIError{
|
|
Code: "BUSINESS_REJECTED", HTTPStatus: http.StatusBadRequest,
|
|
},
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_http_rejected",
|
|
},
|
|
{
|
|
name: "direct network error",
|
|
err: &net.DNSError{Err: "temporary resolver failure", Name: "mcp.example.test"},
|
|
want: personal.RetryabilityRetryable,
|
|
wantReason: "personal_subscription_network",
|
|
},
|
|
{
|
|
name: "token text",
|
|
err: errors.New("access token is missing"),
|
|
want: personal.RetryabilityNonRetryable,
|
|
wantReason: "personal_subscription_auth",
|
|
wantAuth: true,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
got := classifyPersonalSubscriptionFailure(test.err, now)
|
|
if got.retryability != test.want || got.reason != test.wantReason ||
|
|
got.auth != test.wantAuth {
|
|
t.Fatalf("classification = %#v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
if delay := personalAPIRetryDelay(nil, now); delay != 0 {
|
|
t.Fatalf("nil API retry delay = %s", delay)
|
|
}
|
|
if personalSubscriptionErrorHasSubscribeID(nil) {
|
|
t.Fatal("nil API error reported a subscription ID")
|
|
}
|
|
if personalSubscriptionErrorHasSubscribeID(&personal.APIError{
|
|
Details: map[string]any{"subscribe_id": 42},
|
|
}) {
|
|
t.Fatal("non-string subscription ID was accepted")
|
|
}
|
|
httpDate := now.Add(75 * time.Second).Format(http.TimeFormat)
|
|
if delay := personalAPIRetryDelay(&personal.APIError{
|
|
Details: map[string]any{"retry_after": httpDate},
|
|
}, now); delay != 75*time.Second {
|
|
t.Fatalf("HTTP-date retry delay = %s, want 75s", delay)
|
|
}
|
|
if delay := personalRetrySeconds(0); delay != 0 {
|
|
t.Fatalf("zero retry seconds = %s", delay)
|
|
}
|
|
overflowSeconds := int64(math.MaxInt64/int64(time.Second)) + 1
|
|
if delay := personalRetrySeconds(overflowSeconds); delay != time.Duration(math.MaxInt64) {
|
|
t.Fatalf("overflow retry delay = %s", delay)
|
|
}
|
|
if delay := maxPersonalRetryDelay(time.Minute, time.Second); delay != time.Minute {
|
|
t.Fatalf("max retry delay = %s, want 1m", delay)
|
|
}
|
|
if !personalSubscriptionAuthFailure(http.StatusUnauthorized, "") {
|
|
t.Fatal("401 was not classified as an authentication failure")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoveragePersonalSubscriptionErrorConstructionEdges(t *testing.T) {
|
|
nonRetryable := personalSubscriptionFailureClass{
|
|
retryability: personal.RetryabilityNonRetryable,
|
|
reason: "personal_subscription_auth",
|
|
auth: true,
|
|
}
|
|
var typed *apperrors.Error
|
|
err := personalSubscriptionFailureError(
|
|
errors.New("authentication failed"),
|
|
nonRetryable,
|
|
personal.AttemptHold{},
|
|
)
|
|
if !errors.As(err, &typed) || typed.Category != apperrors.CategoryAuth {
|
|
t.Fatalf("authentication failure error = %#v, %v", typed, err)
|
|
}
|
|
|
|
if err := personalSubscriptionBlockedError(nil); err == nil {
|
|
t.Fatal("nil blocked error was accepted")
|
|
}
|
|
for _, test := range []struct {
|
|
name string
|
|
code string
|
|
category apperrors.Category
|
|
}{
|
|
{name: "blocked auth", code: "NO_PERMISSION", category: apperrors.CategoryAuth},
|
|
{name: "blocked validation", code: "INVALID_PARAM", category: apperrors.CategoryValidation},
|
|
} {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
err := personalSubscriptionBlockedError(&personal.AttemptBlockedError{
|
|
State: personal.AttemptStateTerminalHold,
|
|
Retryability: personal.RetryabilityNonRetryable,
|
|
ErrorCode: test.code,
|
|
})
|
|
typed = nil
|
|
if !errors.As(err, &typed) || typed.Category != test.category {
|
|
t.Fatalf("blocked error = %#v, %v", typed, err)
|
|
}
|
|
})
|
|
}
|
|
|
|
if seconds := ceilPersonalRetrySeconds(0); seconds != 0 {
|
|
t.Fatalf("ceil zero retry seconds = %d", seconds)
|
|
}
|
|
if err := personalSubscriptionGuardError(nil); err == nil {
|
|
t.Fatal("nil guard cause did not produce an error")
|
|
}
|
|
if err := personalSubscriptionValidationError(nil); err == nil {
|
|
t.Fatal("nil validation cause did not produce an error")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageNewPersonalEventControlClientSetsVersionHeaders(t *testing.T) {
|
|
oldVersion := version
|
|
t.Cleanup(func() { version = oldVersion })
|
|
version = "1.2.3-test"
|
|
client := newPersonalEventControlClient(
|
|
t.TempDir(),
|
|
"https://mcp.example.test/dws",
|
|
personal.Identity{ClientID: "client", SourceID: "source"},
|
|
)
|
|
if client.ClientVersion != "1.2.3-test" {
|
|
t.Fatalf("ClientVersion = %q", client.ClientVersion)
|
|
}
|
|
if client.UserAgent != "dws-cli/1.2.3-test" {
|
|
t.Fatalf("UserAgent = %q", client.UserAgent)
|
|
}
|
|
|
|
version = " "
|
|
client = newPersonalEventControlClient(
|
|
t.TempDir(),
|
|
"https://mcp.example.test/dws",
|
|
personal.Identity{ClientID: "client", SourceID: "source"},
|
|
)
|
|
if client.ClientVersion != "unknown" || client.UserAgent != "dws-cli/unknown" {
|
|
t.Fatalf(
|
|
"empty-version headers = %q, %q",
|
|
client.ClientVersion,
|
|
client.UserAgent,
|
|
)
|
|
}
|
|
}
|
|
|
|
func int64Pointer(value int64) *int64 {
|
|
return &value
|
|
}
|
|
|
|
func timePointer(value time.Time) *time.Time {
|
|
return &value
|
|
}
|