package orchestration import ( "context" "encoding/json" "errors" "reflect" "testing" "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/assets" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/billing" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/jobs" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/providers" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/usage" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/webhook" ) func TestUsageRecorderMapsPlatformJobAndSkipsPublicJobs(t *testing.T) { sink := &usageSinkStub{} recorder := NewUsageRecorder(sink, func() string { return "usage-1" }, func() time.Time { return time.Date(2026, 8, 13, 8, 0, 0, 0, time.UTC) }) job := jobs.Job{ID: "job-1", OwnerID: "owner-1", Capability: "image.generate", Provider: "bailian", ReqKey: "wanx", UsageContext: json.RawMessage(`{"source":"platform","username":"alice","displayName":"Alice","tenantId":"tenant-1","organizationId":"org-1","organizationName":"Org"}`), Billing: json.RawMessage(`{"quantity":2,"unit":"image","amountFen":35,"currency":"CNY"}`)} if err := recorder.Record(context.Background(), job); err != nil { t.Fatal(err) } want := usage.Event{ID: "usage-1", OwnerID: "owner-1", JobID: "job-1", Source: "platform", Capability: "image.generate", Provider: "bailian", ReqKey: "wanx", AccountUsername: "alice", AccountDisplayName: "Alice", TenantID: "tenant-1", OrganizationID: "org-1", OrganizationName: "Org", Quantity: 2, EstimatedUnit: "image", ChargedAmountFen: int64Pointer(35), Currency: "CNY", CreatedAt: "2026-08-13T08:00:00Z"} if !reflect.DeepEqual(sink.event, want) { t.Fatalf("event = %#v, want %#v", sink.event, want) } if err := recorder.Record(context.Background(), jobs.Job{ID: "public", ExternalClientID: "partner"}); err != nil || sink.calls != 1 { t.Fatalf("public Record err=%v calls=%d", err, sink.calls) } } func TestUsageRecorderReturnsGenericError(t *testing.T) { recorder := NewUsageRecorder(&usageSinkStub{err: errors.New("postgres password leaked")}, func() string { return "id" }, time.Now) err := recorder.Record(context.Background(), jobs.Job{ID: "job", Provider: "bailian"}) if err == nil || err.Error() != "record generation usage" { t.Fatalf("error = %v", err) } } func TestTerminalRefundUsesLedgerKeyAndPersistsRefundedBilling(t *testing.T) { poster := &walletPosterStub{posting: billing.WalletPosting{LedgerID: "refund-ledger", CreatedAt: time.Date(2026, 8, 13, 8, 1, 0, 0, time.UTC)}} ledger := billing.Ledger{Poster: poster, NewID: func() string { return "refund-ledger" }} writer := &stateWriterStub{} adapter := NewTerminalRefund(ledger, writer) job := jobs.Job{ID: "job-1", OwnerID: "owner", Capability: "video.generate", Status: jobs.StatusFailed, UsageContext: json.RawMessage(`{"accountId":"account-1","organizationId":"org-1"}`), Billing: json.RawMessage(`{"status":"charged","amountFen":88,"ledgerEntryId":"charge-ledger","quotaExempt":false}`)} got, err := adapter.Refund(context.Background(), job, "provider failed") if err != nil { t.Fatal(err) } if poster.params.JobID != "job-1" || poster.params.DeltaFen != 88 || poster.params.OrganizationID != "org-1" || poster.params.AccountID != "account-1" || poster.params.IdempotencyKey != "job-refund:job-1" { t.Fatalf("refund posting = %#v", poster.params) } if poster.params.Description != "视频生成失败退款 · provider failed" || writer.jobID != "job-1" { t.Fatalf("description=%q writer=%q", poster.params.Description, writer.jobID) } quote, _ := poster.params.Metadata["quote"].(map[string]any) if quote["status"] != "charged" { t.Fatalf("refund metadata quote mutated = %#v", quote) } var state map[string]any if err := json.Unmarshal(got.Billing, &state); err != nil { t.Fatal(err) } if state["status"] != "refunded" || state["refundLedgerEntryId"] != "refund-ledger" || state["refundReason"] != "provider failed" { t.Fatalf("billing = %#v", state) } } func TestTerminalRefundNoopsUnlessChargedAndHidesDependencyErrors(t *testing.T) { ledger := &refundLedgerStub{err: errors.New("wallet schema secret")} writer := &stateWriterStub{} adapter := NewTerminalRefund(ledger, writer) unchanged := jobs.Job{ID: "pending", Status: jobs.StatusFailed, Billing: json.RawMessage(`{"status":"pending","amountFen":10}`)} if got, err := adapter.Refund(context.Background(), unchanged, "x"); err != nil || string(got.Billing) != string(unchanged.Billing) || ledger.calls != 0 { t.Fatalf("noop = %#v, %v calls=%d", got, err, ledger.calls) } charged := jobs.Job{ID: "charged", Status: jobs.StatusFailed, UsageContext: json.RawMessage(`{"organizationId":"org"}`), Billing: json.RawMessage(`{"status":"charged","amountFen":10}`)} if _, err := adapter.Refund(context.Background(), charged, "x"); err == nil || err.Error() != "refund generation charge" { t.Fatalf("error = %v", err) } } func TestWebhookBridgePreservesResult(t *testing.T) { deliverer := &webhookStub{result: webhook.Result{Attempts: 2, LastStatus: &webhook.LastStatus{OK: true, Status: 204}}} bridge := NewWebhookBridge(deliverer) got, err := bridge.Deliver(context.Background(), jobs.Job{ID: "job"}) if err != nil || got.Attempts != 2 || !reflect.DeepEqual(got.LastStatus, deliverer.result.LastStatus) { t.Fatalf("Deliver = %#v, %v", got, err) } } func TestOutputRegisteringProcessorRegistersAndPersistsSuccessOutputs(t *testing.T) { inner := &processorStub{job: jobs.Job{ID: "job-1", OwnerID: "owner", Capability: "image.generate", Status: jobs.StatusSucceeded}} registrar := &outputRegistrarStub{ids: []string{"asset-1", "asset-2"}} writer := &stateWriterStub{} processor := NewOutputRegisteringProcessor(inner, registrar, writer) got, err := processor.Advance(context.Background(), jobs.Job{ID: "job-1"}) if err != nil || !reflect.DeepEqual(got.OutputAssetIDs, registrar.ids) || !reflect.DeepEqual(writer.outputIDs, registrar.ids) { t.Fatalf("Advance = %#v, %v writer=%#v", got, err, writer.outputIDs) } if _, err := processor.Advance(context.Background(), got); err != nil || registrar.calls != 1 { t.Fatalf("idempotent Advance error=%v calls=%d", err, registrar.calls) } } func TestAssetOutputRegistrarUsesGeneratedOwnerScopedAssets(t *testing.T) { creator := &assetCreatorStub{} registrar := NewAssetOutputRegistrar(creator, func(job jobs.Job) ([]string, error) { return []string{"https://cdn.test/one.png", "https://cdn.test/two.png"}, nil }) ids, err := registrar.Register(context.Background(), jobs.Job{ID: "job-1", OwnerID: "owner", Capability: "image.generate"}) if err != nil || !reflect.DeepEqual(ids, []string{"asset-1", "asset-2"}) { t.Fatalf("Register = %#v, %v", ids, err) } if creator.scopes[0] != assets.PlatformScope("owner") || creator.commands[0].Source != assets.SourceGenerated || !reflect.DeepEqual(creator.commands[0].Tags, []string{"generated", "image.generate", "job:job-1", "output:0"}) { t.Fatalf("asset request = %#v %#v", creator.scopes[0], creator.commands[0]) } creator.existing = []assets.Asset{{ID: "asset-existing", Tags: []string{"job:job-1", "output:0"}}} ids, err = registrar.Register(context.Background(), jobs.Job{ID: "job-1", OwnerID: "owner", Capability: "image.generate"}) if err != nil || ids[0] != "asset-existing" || len(creator.commands) != 3 { t.Fatalf("retry Register = %#v, %v creates=%d", ids, err, len(creator.commands)) } } func TestOutputRegisteringProcessorCreatesAndPersistsMockAssetWithoutRemoteFetch(t *testing.T) { for _, capability := range []string{"image.generate", "video.generate"} { t.Run(capability, func(t *testing.T) { creator := &assetCreatorStub{} registrar := NewAssetOutputRegistrar(creator, ResolveProviderOutputURLs) completed := jobs.Job{ ID: "job-mock", OwnerID: "owner", Provider: "mock", Capability: capability, Prompt: "mock output", Status: jobs.StatusSucceeded, ResponsePayload: json.RawMessage(`{"status":"succeeded","outputUrls":["/generated-results/mock-task"]}`), } writer := &stateWriterStub{} processor := NewOutputRegisteringProcessor(&processorStub{job: completed}, registrar, writer) got, err := processor.Advance(context.Background(), jobs.Job{ID: "job-mock"}) if err != nil || !reflect.DeepEqual(got.OutputAssetIDs, []string{"asset-1"}) || !reflect.DeepEqual(writer.outputIDs, []string{"asset-1"}) || len(creator.mockCommands) != 1 || creator.mockCommands[0].JobID != "job-mock" { t.Fatalf("Advance = %#v, %v mock=%#v persisted=%#v", got, err, creator.mockCommands, writer.outputIDs) } wantKind := assets.KindImage if capability == "video.generate" { wantKind = assets.KindVideo } if creator.mockCommands[0].Kind != wantKind || len(creator.commands) != 0 { t.Fatalf("mock command=%#v remote imports=%#v", creator.mockCommands[0], creator.commands) } }) } } func TestResolveProviderOutputURLsAcceptsCurrentShapesAndDeduplicates(t *testing.T) { job := jobs.Job{ResponsePayload: json.RawMessage(`{"data":{"image_urls":["https://cdn.test/a.png","javascript:alert(1)"],"results":[{"url":"https://cdn.test/a.png"},{"url":"http://cdn.test/b.png"}]},"unrelated":"https://secret.test/not-output"}`)} got, err := ResolveProviderOutputURLs(job) if err != nil || !reflect.DeepEqual(got, []string{"https://cdn.test/a.png", "http://cdn.test/b.png"}) { t.Fatalf("ResolveProviderOutputURLs = %#v, %v", got, err) } if _, err := ResolveProviderOutputURLs(jobs.Job{ResponsePayload: json.RawMessage(`{"status":"done"}`)}); err == nil || err.Error() != "resolve generation outputs" { t.Fatalf("empty error = %v", err) } if _, err := ResolveProviderOutputURLs(jobs.Job{ResponsePayload: json.RawMessage(`{"outputUrls":["/generated-results/mock-task"]}`)}); err == nil || err.Error() != "resolve generation outputs" { t.Fatalf("relative URL error = %v", err) } } func TestCreationCoordinatorQuotesPersistsThenChargesPlatformJob(t *testing.T) { order := []string{} creator := &creationStoreStub{order: &order} quoter := "erStub{order: &order, quote: &billing.Quote{Provider: "bailian", Capability: "image.generate", ReqKey: "wanx", Unit: billing.UnitImage, Quantity: 2, AmountFen: 35, Currency: "CNY"}} charger := &chargeLedgerStub{order: &order, posting: billing.WalletPosting{LedgerID: "charge-ledger", CreatedAt: time.Date(2026, 8, 13, 8, 2, 0, 0, time.UTC)}} state := &creationStateStub{order: &order} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, state) session := identity.Session{User: identity.User{ID: "account-1", Username: "alice", DisplayName: "Alice", TenantID: "tenant-1", OrganizationID: "org-1", OrganizationName: "Org", Role: "user"}} created, reused, err := coordinator.CreatePlatform(context.Background(), session, CreationInput{Capability: "image.generate", Body: map[string]any{"prompt": "hello", "settings": map[string]any{"imageCount": 2}}, IdempotencyKey: "idem"}) if err != nil || reused || !reflect.DeepEqual(order, []string{"quote", "create", "charge", "billing"}) { t.Fatalf("CreatePlatform = %#v,%v,%v order=%#v", created, reused, err, order) } if charger.request.JobID != "job-1" || charger.request.AmountFen != 35 || charger.request.OrganizationID != "org-1" || charger.request.AccountID != "account-1" { t.Fatalf("charge = %#v", charger.request) } var use map[string]any _ = json.Unmarshal(created.UsageContext, &use) if use["username"] != "alice" || use["organizationId"] != "org-1" || use["source"] != "platform" { t.Fatalf("usageContext = %#v", use) } var charged map[string]any _ = json.Unmarshal(created.Billing, &charged) if charged["status"] != "charged" || charged["ledgerEntryId"] != "charge-ledger" { t.Fatalf("billing = %#v", charged) } if quoter.command.Parameters["imageCount"] != float64(2) { t.Fatalf("quote parameters = %#v", quoter.command.Parameters) } } func TestCreationCoordinatorMarksPersistedJobFailedWhenChargeFails(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{quote: &billing.Quote{AmountFen: 35, Currency: "CNY"}} charger := &chargeLedgerStub{err: &billing.StatusError{Status: 402, Err: billing.ErrInsufficientBalance}} state := &creationStateStub{} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, state) session := identity.Session{User: identity.User{ID: "account", DisplayName: "A", OrganizationID: "org", Role: "user"}} _, _, err := coordinator.CreatePlatform(context.Background(), session, CreationInput{Capability: "image.generate", Body: map[string]any{}}) if billing.HTTPStatus(err) != 402 || state.failed.ID != "job-1" || state.failed.Status != jobs.StatusFailed { t.Fatalf("error=%v failed=%#v", err, state.failed) } var failedBilling map[string]any _ = json.Unmarshal(state.failed.Billing, &failedBilling) if failedBilling["status"] != "not_charged" { t.Fatalf("failed billing = %#v", failedBilling) } } func TestCreationCoordinatorReplaysPendingChargeWithSameJobID(t *testing.T) { pending := jobs.Job{ID: "existing-job", OwnerID: "account", Provider: "bailian", Capability: "image.generate", ReqKey: "wanx", Status: jobs.StatusQueued, UsageContext: json.RawMessage(`{"accountId":"account","organizationId":"org"}`), Billing: json.RawMessage(`{"status":"pending","amountFen":35,"currency":"CNY"}`)} creator := &creationStoreStub{existing: &pending} quoter := "erStub{quote: &billing.Quote{AmountFen: 35, Currency: "CNY"}} charger := &chargeLedgerStub{posting: billing.WalletPosting{LedgerID: "same-key-ledger"}} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, &creationStateStub{}) created, reused, err := coordinator.CreatePlatform(context.Background(), identity.Session{User: identity.User{ID: "account", DisplayName: "A", OrganizationID: "org"}}, CreationInput{Capability: "image.generate", Body: map[string]any{}, IdempotencyKey: "same"}) if err != nil || !reused || created.ID != "existing-job" || charger.request.JobID != "existing-job" { t.Fatalf("replay = %#v,%v,%v charge=%#v", created, reused, err, charger.request) } } func TestCreationCoordinatorKeepsPendingChargeUndispatchableAndActivatesAfterDurableCharge(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{quote: &billing.Quote{AmountFen: 35, Currency: "CNY"}} charger := &chargeLedgerStub{posting: billing.WalletPosting{LedgerID: "charge-ledger"}} state := &creationStateStub{} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, state) created, _, err := coordinator.CreatePlatform(context.Background(), identity.Session{User: identity.User{ID: "account", OrganizationID: "org"}}, CreationInput{Capability: "image.generate", Body: map[string]any{"prompt": "fresh"}}) if err != nil { t.Fatal(err) } if creator.created.DispatchReadyAt != nil { t.Fatalf("pending creation was dispatchable: %#v", creator.created) } if state.activatedID != created.ID { t.Fatalf("activation = %q, want %q", state.activatedID, created.ID) } } func TestCreationCoordinatorUsesAtomicChargeAndActivationWhenStoreSupportsIt(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{quote: &billing.Quote{AmountFen: 35, Currency: "CNY"}} state := &atomicCreationStateStub{charged: json.RawMessage(`{"status":"charged","amountFen":35,"ledgerEntryId":"atomic"}`)} charger := &chargeLedgerStub{} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, state) created, _, err := coordinator.CreatePlatform(context.Background(), identity.Session{User: identity.User{ID: "account", OrganizationID: "org"}}, CreationInput{Capability: "image.generate", Body: map[string]any{"prompt": "fresh"}}) if err != nil || state.calls != 1 || charger.calls != 0 || string(created.Billing) != string(state.charged) { t.Fatalf("created=%#v err=%v atomic=%d fallback=%d", created, err, state.calls, charger.calls) } } func TestCreationCoordinatorRetryRebuildsAnyOwnedImageJobWithFreshQuote(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{quote: &billing.Quote{AmountFen: 41, Currency: "CNY"}} charger := &chargeLedgerStub{posting: billing.WalletPosting{LedgerID: "fresh-ledger"}} state := &creationStateStub{} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, state) original := jobs.Job{ID: "old", OwnerID: "account", Capability: "image.generate", Status: jobs.StatusRunning, Priority: 73, WebhookURL: "https://hooks.test/result", InputAssetIDs: []string{"asset-1"}, Billing: json.RawMessage(`{"status":"charged","amountFen":999}`), RequestPayload: json.RawMessage(`{"capability":"image.generate","model":"wanx","prompt":"again","inputUrls":["https://in.test/a.png"],"settings":{"imageCount":2}}`)} retried, err := coordinator.RetryPlatform(context.Background(), identity.Session{User: identity.User{ID: "account", OrganizationID: "org"}}, original) if err != nil { t.Fatal(err) } if retried.RetryOf != "old" || retried.ID == "old" || charger.request.AmountFen != 41 { t.Fatalf("retry=%#v charge=%#v", retried, charger.request) } var fresh map[string]any _ = json.Unmarshal(retried.Billing, &fresh) if fresh["amountFen"] != float64(41) || fresh["ledgerEntryId"] != "fresh-ledger" { t.Fatalf("fresh billing = %#v", fresh) } if !reflect.DeepEqual(quoter.command.Payload["inputAssetIds"], []string{"asset-1"}) || quoter.command.Payload["webhookUrl"] != "https://hooks.test/result" || quoter.command.Payload["priority"] != 73 { t.Fatalf("retry payload = %#v", quoter.command.Payload) } } func TestSafeBillingErrorMapsPostgresWalletFailures(t *testing.T) { tests := []struct { name string err error want int }{ {name: "insufficient", err: errors.New("charge and activate: BILLING_INSUFFICIENT_BALANCE"), want: 402}, {name: "idempotency", err: errors.New("charge and activate: BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH"), want: 409}, {name: "unknown", err: errors.New("database unavailable"), want: 500}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { got := safeBillingError(test.err) if status := billing.HTTPStatus(got); status != test.want { t.Fatalf("status = %d, want %d (error %v)", status, test.want, got) } if test.want == 500 && got.Error() != "charge generation job" { t.Fatalf("unknown error leaked: %v", got) } }) } } func TestCreationCoordinatorPublicCreationNeverQuotesOrCharges(t *testing.T) { creator := &creationStoreStub{} quoter := "erStub{} charger := &chargeLedgerStub{} coordinator := NewCreationCoordinator(platformBuilderStub{}, creator, quoter, charger, &creationStateStub{}) created, _, err := coordinator.CreatePublic(context.Background(), CreationInput{OwnerID: "api:partner", ExternalClientID: "partner", Capability: "image.generate", Body: map[string]any{}}) if err != nil || created.ExternalClientID != "partner" || quoter.calls != 0 || charger.calls != 0 { t.Fatalf("CreatePublic = %#v,%v quote=%d charge=%d", created, err, quoter.calls, charger.calls) } } type usageSinkStub struct { event usage.Event calls int err error } func (s *usageSinkStub) Record(event usage.Event) (*usage.Event, error) { s.calls++ s.event = event return &event, s.err } type refundLedgerStub struct { request billing.RefundRequest posting billing.WalletPosting calls int err error } type walletPosterStub struct { params billing.WalletPostParams posting billing.WalletPosting err error } func (s *walletPosterStub) PostWalletEntry(_ context.Context, params billing.WalletPostParams) (billing.WalletPosting, error) { s.params = params return s.posting, s.err } func (s *refundLedgerStub) Refund(_ context.Context, request billing.RefundRequest) (billing.WalletPosting, error) { s.calls++ s.request = request return s.posting, s.err } type stateWriterStub struct { jobID string billing json.RawMessage outputIDs []string err error } func (s *stateWriterStub) WriteBilling(_ context.Context, id string, value json.RawMessage) error { s.jobID = id s.billing = append([]byte(nil), value...) return s.err } func (s *stateWriterStub) WriteOutputAssetIDs(_ context.Context, id string, ids []string) error { s.jobID = id s.outputIDs = append([]string(nil), ids...) return s.err } type webhookStub struct { result webhook.Result err error } func (s *webhookStub) Deliver(context.Context, jobs.Job) (webhook.Result, error) { return s.result, s.err } type processorStub struct { job jobs.Job err error } func (s *processorStub) Advance(context.Context, jobs.Job) (jobs.Job, error) { return s.job, s.err } type outputRegistrarStub struct { ids []string calls int err error } func (s *outputRegistrarStub) Register(context.Context, jobs.Job) ([]string, error) { s.calls++ return append([]string(nil), s.ids...), s.err } type assetCreatorStub struct { scopes []assets.Scope commands []assets.ImportGeneratedCommand mockCommands []assets.ImportMockCommand existing []assets.Asset } func (s *assetCreatorStub) List(_ context.Context, _ assets.Scope) ([]assets.Asset, error) { return append([]assets.Asset(nil), s.existing...), nil } func (s *assetCreatorStub) ImportGenerated(_ context.Context, scope assets.Scope, command assets.ImportGeneratedCommand) (assets.Asset, error) { s.scopes = append(s.scopes, scope) s.commands = append(s.commands, command) return assets.Asset{ID: "asset-" + string(rune('0'+len(s.commands)))}, nil } func (s *assetCreatorStub) ImportMock(_ context.Context, scope assets.Scope, command assets.ImportMockCommand) (assets.Asset, error) { s.scopes = append(s.scopes, scope) s.mockCommands = append(s.mockCommands, command) return assets.Asset{ID: "asset-" + string(rune('0'+len(s.mockCommands)))}, nil } type platformBuilderStub struct{} func (platformBuilderStub) Build(_ context.Context, owner, client, capability, idempotency string, body map[string]any) (jobs.CreateCommand, error) { settings, _ := body["settings"].(map[string]any) request, _ := json.Marshal(providers.Request{Capability: capability, Model: "wanx", Settings: settings}) return jobs.CreateCommand{Job: jobs.Job{ID: "job-1", OwnerID: owner, ExternalClientID: client, Capability: capability, Provider: "bailian", ReqKey: "wanx", Status: jobs.StatusQueued, IdempotencyKey: idempotency, RequestPayload: request}, IdempotencyBody: body}, nil } type creationStoreStub struct { order *[]string existing *jobs.Job created jobs.Job } func (s *creationStoreStub) Create(_ context.Context, command jobs.CreateCommand) (jobs.Job, bool, error) { if s.order != nil { *s.order = append(*s.order, "create") } if s.existing != nil { return *s.existing, true, nil } s.created = command.Job return command.Job, false, nil } type quoterStub struct { order *[]string quote *billing.Quote err error calls int command billing.QuoteCommand } func (s *quoterStub) Quote(_ context.Context, command billing.QuoteCommand) (*billing.Quote, error) { s.calls++ s.command = command if s.order != nil { *s.order = append(*s.order, "quote") } return s.quote, s.err } type chargeLedgerStub struct { order *[]string request billing.ChargeRequest posting billing.WalletPosting err error calls int } func (s *chargeLedgerStub) Charge(_ context.Context, request billing.ChargeRequest) (billing.WalletPosting, error) { s.calls++ s.request = request if s.order != nil { *s.order = append(*s.order, "charge") } return s.posting, s.err } type creationStateStub struct { order *[]string failed jobs.Job activatedID string } type atomicCreationStateStub struct { creationStateStub charged json.RawMessage calls int } func (s *atomicCreationStateStub) ChargeAndActivateCreation(_ context.Context, _ billing.ChargeRequest, _ json.RawMessage) (json.RawMessage, error) { s.calls++ return append(json.RawMessage(nil), s.charged...), nil } func (s *creationStateStub) WriteBilling(_ context.Context, _ string, _ json.RawMessage) error { if s.order != nil { *s.order = append(*s.order, "billing") } return nil } func (s *creationStateStub) FailCreation(_ context.Context, job jobs.Job) error { s.failed = job return nil } func (s *creationStateStub) ActivateCreation(_ context.Context, id string, _ json.RawMessage) error { s.activatedID = id if s.order != nil { *s.order = append(*s.order, "billing") } return nil } func int64Pointer(value int64) *int64 { return &value }