package distributor import ( "context" "errors" "fmt" "strings" "testing" "time" distributorupload "gitea.maximumdirect.net/eric/distributor/pkg/upload" "gitea.maximumdirect.net/eric/weatherreporter/internal/config" ) func TestUploadUsesConfiguredClientAndSingleFile(t *testing.T) { cfg := config.Defaults().Notify.Distributor cfg.Endpoint = "https://distributor.example.test" cfg.TokenEnv = "DISTRIBUTOR_UPLOAD_TOKEN" cfg.Timeout = 15 * time.Second t.Setenv(cfg.TokenEnv, "secret-token") factory := &fakeUploadFactory{ client: &fakeUploadClient{ result: uploadFilesResult{RunID: "run-123", Status: "accepted"}, }, } client := newClient(cfg, factory.newClient) result, err := client.Upload(context.Background(), UploadRequest{ BundleID: "weatherreporter.home.daily.run", IdempotencyKey: "weatherreporter.home.daily.run", SourcePath: "/tmp/report.md", BundlePath: "daily.md", }) if err != nil { t.Fatalf("Upload() error = %v", err) } if result.RunID != "run-123" || result.Status != "accepted" { t.Fatalf("result = %#v, want accepted run", result) } if factory.endpoint != cfg.Endpoint { t.Fatalf("factory endpoint = %q, want %q", factory.endpoint, cfg.Endpoint) } if factory.token != "secret-token" { t.Fatalf("factory token = %q, want secret-token", factory.token) } if factory.timeout != 15*time.Second { t.Fatalf("factory timeout = %s, want 15s", factory.timeout) } got := factory.client.opts if got.BundleID != "weatherreporter.home.daily.run" { t.Fatalf("BundleID = %q, want weatherreporter.home.daily.run", got.BundleID) } if got.IdempotencyKey != "weatherreporter.home.daily.run" { t.Fatalf("IdempotencyKey = %q, want weatherreporter.home.daily.run", got.IdempotencyKey) } if got.SourcePath != "/tmp/report.md" { t.Fatalf("SourcePath = %q, want /tmp/report.md", got.SourcePath) } if got.BundlePath != "daily.md" { t.Fatalf("BundlePath = %q, want daily.md", got.BundlePath) } } func TestUploadRejectsMissingInputs(t *testing.T) { cfg := config.Defaults().Notify.Distributor t.Setenv(cfg.TokenEnv, "secret-token") tests := []struct { name string mutate func(*Client, *UploadRequest) wantErr string }{ { name: "Token", mutate: func(c *Client, req *UploadRequest) { t.Setenv(c.TokenEnv, "") }, wantErr: "token environment variable", }, { name: "SourcePath", mutate: func(c *Client, req *UploadRequest) { req.SourcePath = "" }, wantErr: "source path is required", }, { name: "BundlePath", mutate: func(c *Client, req *UploadRequest) { req.BundlePath = "" }, wantErr: "bundle path is required", }, { name: "UploadClientFactory", mutate: func(c *Client, req *UploadRequest) { c.newUploadClient = nil }, wantErr: "upload client factory is required", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Setenv(cfg.TokenEnv, "secret-token") client := newClient(cfg, (&fakeUploadFactory{client: &fakeUploadClient{}}).newClient) req := validUploadRequest() tt.mutate(client, &req) _, err := client.Upload(context.Background(), req) if err == nil { t.Fatal("Upload() error = nil, want error") } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("error = %q, want %q", err.Error(), tt.wantErr) } if strings.Contains(err.Error(), "secret-token") { t.Fatalf("error = %q, want no token value", err.Error()) } }) } } func TestUploadWrapsFactoryErrorWithoutToken(t *testing.T) { cfg := config.Defaults().Notify.Distributor cfg.Endpoint = "https://distributor.example.test" t.Setenv(cfg.TokenEnv, "secret-token") factory := &fakeUploadFactory{ err: fmt.Errorf("factory failed with secret-token"), } client := newClient(cfg, factory.newClient) _, err := client.Upload(context.Background(), validUploadRequest()) if err == nil { t.Fatal("Upload() error = nil, want error") } if strings.Contains(err.Error(), "secret-token") { t.Fatalf("error = %q, want no token value", err.Error()) } if !strings.Contains(err.Error(), cfg.Endpoint) { t.Fatalf("error = %q, want endpoint context", err.Error()) } } func TestUploadWrapsUploadFailureWithContextWithoutToken(t *testing.T) { cfg := config.Defaults().Notify.Distributor cfg.Endpoint = "https://distributor.example.test" t.Setenv(cfg.TokenEnv, "secret-token") factory := &fakeUploadFactory{ client: &fakeUploadClient{err: fmt.Errorf("server rejected secret-token")}, } client := newClient(cfg, factory.newClient) req := validUploadRequest() _, err := client.Upload(context.Background(), req) if err == nil { t.Fatal("Upload() error = nil, want error") } for _, want := range []string{cfg.Endpoint, req.BundleID, req.IdempotencyKey, req.SourcePath, req.BundlePath} { if !strings.Contains(err.Error(), want) { t.Fatalf("error = %q, want context %q", err.Error(), want) } } if strings.Contains(err.Error(), "secret-token") { t.Fatalf("error = %q, want no token value", err.Error()) } } func TestUploadPreservesIdempotencyConflictDiagnosis(t *testing.T) { cfg := config.Defaults().Notify.Distributor cfg.Endpoint = "https://distributor.example.test" t.Setenv(cfg.TokenEnv, "secret-token") factory := &fakeUploadFactory{ client: &fakeUploadClient{ err: &distributorupload.IdempotencyConflictError{ HTTPError: distributorupload.HTTPError{ StatusCode: 409, Status: "409 Conflict", Message: "conflicting upload for secret-token", }, }, }, } client := newClient(cfg, factory.newClient) _, err := client.Upload(context.Background(), validUploadRequest()) if err == nil { t.Fatal("Upload() error = nil, want error") } var conflict *IdempotencyConflictError if !errors.As(err, &conflict) { t.Fatalf("Upload() error = %T %v, want IdempotencyConflictError", err, err) } if !strings.Contains(err.Error(), "idempotency conflict") { t.Fatalf("error = %q, want idempotency conflict diagnosis", err.Error()) } if strings.Contains(err.Error(), "secret-token") { t.Fatalf("error = %q, want no token value", err.Error()) } } func validUploadRequest() UploadRequest { return UploadRequest{ BundleID: "weatherreporter.home.daily.run", IdempotencyKey: "weatherreporter.home.daily.run", SourcePath: "/tmp/report.md", BundlePath: "daily.md", } } type fakeUploadFactory struct { endpoint string token string timeout time.Duration client *fakeUploadClient err error } func (f *fakeUploadFactory) newClient(endpoint, token string, timeout time.Duration) (uploadClient, error) { f.endpoint = endpoint f.token = token f.timeout = timeout if f.err != nil { return nil, f.err } return f.client, nil } type fakeUploadClient struct { opts uploadFilesOptions result uploadFilesResult err error } func (c *fakeUploadClient) UploadFiles(ctx context.Context, opts uploadFilesOptions) (uploadFilesResult, error) { c.opts = opts if c.err != nil { return uploadFilesResult{}, c.err } return c.result, nil }