diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..efbf202 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + pull_request: + push: + branches: main + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + go: ['1.23', '1.24', '1.25'] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: ${{ matrix.go }} + + - name: Run tests + run: go test -v ./... diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..a174531 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +golang 1.23.5 diff --git a/client/client_test.go b/client/client_test.go index 9ad5354..e2587b0 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,8 +2,11 @@ package client import ( "context" + "errors" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" "time" ) @@ -110,4 +113,139 @@ func TestClientRequest(t *testing.T) { if response["test"] != "response" { t.Errorf("Expected response[\"test\"] = %v, got %v", "response", response["test"]) } +} + +func TestClientRequest_ErrorResponses(t *testing.T) { + tests := []struct { + name string + statusCode int + contentType string + responseBody string + requestID string // X-Request-ID header value + wantMessage string + wantRequestID string + wantRawBody string + }{ + { + name: "401 with message field", + statusCode: 401, + contentType: "application/json", + responseBody: `{"message":"Invalid credentials"}`, + wantMessage: "Invalid credentials", + wantRawBody: `{"message":"Invalid credentials"}`, + }, + { + name: "404 with message field", + statusCode: 404, + contentType: "application/json", + responseBody: `{"message":"Resource not found"}`, + wantMessage: "Resource not found", + }, + { + name: "422 validation error", + statusCode: 422, + contentType: "application/json", + responseBody: `{"message":"Invalid card_template_id"}`, + wantMessage: "Invalid card_template_id", + }, + { + name: "500 with message field", + statusCode: 500, + contentType: "application/json", + responseBody: `{"message":"Internal server error"}`, + wantMessage: "Internal server error", + }, + { + name: "500 with error field instead of message", + statusCode: 500, + contentType: "application/json", + responseBody: `{"error":"Something went wrong"}`, + wantMessage: "Something went wrong", + }, + { + name: "500 with empty body", + statusCode: 500, + contentType: "application/json", + responseBody: "", + wantMessage: "", + }, + { + name: "503 with non-JSON body", + statusCode: 503, + contentType: "text/plain", + responseBody: "Service Unavailable", + wantMessage: "Service Unavailable", + }, + { + name: "500 with X-Request-ID header", + statusCode: 500, + contentType: "application/json", + responseBody: `{"message":"Server error"}`, + requestID: "req-abc-123", + wantMessage: "Server error", + wantRequestID: "req-abc-123", + }, + { + name: "500 with request_id in body overrides header", + statusCode: 500, + contentType: "application/json", + responseBody: `{"message":"Server error","request_id":"req-from-body"}`, + requestID: "req-from-header", + wantMessage: "Server error", + wantRequestID: "req-from-body", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tt.requestID != "" { + w.Header().Set("X-Request-ID", tt.requestID) + } + w.Header().Set("Content-Type", tt.contentType) + w.WriteHeader(tt.statusCode) + w.Write([]byte(tt.responseBody)) + })) + defer server.Close() + + c, err := NewClient("test-account", "test-secret", WithBaseURL(server.URL)) + if err != nil { + t.Fatalf("NewClient() error = %v", err) + } + + var result map[string]interface{} + err = c.Request(context.Background(), "GET", "/test", nil, &result) + + if err == nil { + t.Fatal("expected error, got nil") + } + + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected *APIError, got %T: %v", err, err) + } + + if apiErr.StatusCode != tt.statusCode { + t.Errorf("StatusCode = %d, want %d", apiErr.StatusCode, tt.statusCode) + } + + if apiErr.Message != tt.wantMessage { + t.Errorf("Message = %q, want %q", apiErr.Message, tt.wantMessage) + } + + if tt.wantRequestID != "" && apiErr.RequestID != tt.wantRequestID { + t.Errorf("RequestID = %q, want %q", apiErr.RequestID, tt.wantRequestID) + } + + if tt.wantRawBody != "" && apiErr.RawBody != tt.wantRawBody { + t.Errorf("RawBody = %q, want %q", apiErr.RawBody, tt.wantRawBody) + } + + // Verify Error() string includes status code + errStr := apiErr.Error() + if !strings.Contains(errStr, fmt.Sprintf("status %d", tt.statusCode)) { + t.Errorf("Error() = %q, expected it to contain status %d", errStr, tt.statusCode) + } + }) + } } \ No newline at end of file diff --git a/config/go_versions_test.go b/config/go_versions_test.go new file mode 100644 index 0000000..bf83951 --- /dev/null +++ b/config/go_versions_test.go @@ -0,0 +1,116 @@ +package config + +import ( + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// ══════════════════════════════════════════════════════════════════════════════ +// TARGET VERSION - Update this when upgrading Go +// ══════════════════════════════════════════════════════════════════════════════ +const TARGET_GO = "1.23.5" + +func rootDir(t *testing.T) string { + t.Helper() + // config/ is one level below the repo root + dir, err := filepath.Abs(filepath.Join("..", ".")) + if err != nil { + t.Fatalf("failed to resolve root dir: %v", err) + } + return dir +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("failed to read %s: %v", path, err) + } + return string(data) +} + +func majorMinor(version string) string { + parts := strings.SplitN(version, ".", 3) + if len(parts) < 2 { + return version + } + return parts[0] + "." + parts[1] +} + +func TestToolVersionsMatchesTarget(t *testing.T) { + root := rootDir(t) + content := readFile(t, filepath.Join(root, ".tool-versions")) + + var goVersion string + for _, line := range strings.Split(content, "\n") { + parts := strings.Fields(line) + if len(parts) == 2 && parts[0] == "golang" { + goVersion = parts[1] + break + } + } + + if goVersion == "" { + t.Fatal(".tool-versions does not contain a golang entry") + } + + if goVersion != TARGET_GO { + t.Errorf(".tool-versions golang = %q, want %q", goVersion, TARGET_GO) + } +} + +func TestGoModMatchesTarget(t *testing.T) { + root := rootDir(t) + content := readFile(t, filepath.Join(root, "go.mod")) + + re := regexp.MustCompile(`(?m)^go\s+(\S+)`) + match := re.FindStringSubmatch(content) + if match == nil { + t.Fatal("go.mod does not contain a go directive") + } + + goModVersion := match[1] + targetMM := majorMinor(TARGET_GO) + + if goModVersion != targetMM && goModVersion != TARGET_GO { + t.Errorf("go.mod go directive = %q, want %q or %q", goModVersion, targetMM, TARGET_GO) + } +} + +func TestCIMatrixIncludesTarget(t *testing.T) { + root := rootDir(t) + content := readFile(t, filepath.Join(root, ".github", "workflows", "ci.yml")) + + // Extract versions from go: ['1.23', '1.24', '1.25'] matrix + re := regexp.MustCompile(`go:\s*\[([^\]]+)\]`) + match := re.FindStringSubmatch(content) + if match == nil { + t.Fatal("CI workflow does not contain a go version matrix") + } + + raw := match[1] + var versions []string + for _, v := range strings.Split(raw, ",") { + v = strings.TrimSpace(v) + v = strings.Trim(v, "'\"") + if v != "" { + versions = append(versions, v) + } + } + + targetMM := majorMinor(TARGET_GO) + found := false + for _, v := range versions { + if v == targetMM { + found = true + break + } + } + + if !found { + t.Errorf("CI matrix %v does not include target major.minor %q", versions, targetMM) + } +} diff --git a/services/access_cards_test.go b/services/access_cards_test.go index b51b9bd..37260a1 100644 --- a/services/access_cards_test.go +++ b/services/access_cards_test.go @@ -2,8 +2,10 @@ package services import ( "context" + "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -188,3 +190,44 @@ func TestAccessCardsService_CardStateOperations(t *testing.T) { t.Errorf("Delete() error = %v", err) } } + +func TestAccessCardsService_ErrorPropagation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + w.Write([]byte(`{"message":"Invalid credentials"}`)) + })) + defer server.Close() + + c, _ := client.NewClient("test-account", "test-secret", client.WithBaseURL(server.URL)) + service := NewAccessCardsService(c) + + ctx := context.Background() + _, err := service.Provision(ctx, models.ProvisionParams{ + CardTemplateID: "0xd3adb00b5", + FullName: "Test", + }) + + if err == nil { + t.Fatal("expected error, got nil") + } + + // Verify the service wraps the error with context + if !strings.Contains(err.Error(), "error provisioning card") { + t.Errorf("expected wrapped message, got: %s", err.Error()) + } + + // Verify the underlying APIError is still accessible + var apiErr *client.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected unwrappable *client.APIError, got %T", err) + } + + if apiErr.StatusCode != 401 { + t.Errorf("StatusCode = %d, want 401", apiErr.StatusCode) + } + + if apiErr.Message != "Invalid credentials" { + t.Errorf("Message = %q, want %q", apiErr.Message, "Invalid credentials") + } +} diff --git a/services/console_test.go b/services/console_test.go index 2b054f8..7d4c829 100644 --- a/services/console_test.go +++ b/services/console_test.go @@ -2,8 +2,10 @@ package services import ( "context" + "errors" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -245,3 +247,41 @@ func TestConsoleService_EventLog(t *testing.T) { t.Errorf("EventLog() events[0].CardID = %v, want %v", events[0].CardID, "0xc4rd1d") } } + +func TestConsoleService_ErrorPropagation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"Resource not found"}`)) + })) + defer server.Close() + + c, _ := client.NewClient("test-account", "test-secret", client.WithBaseURL(server.URL)) + service := NewConsoleService(c) + + ctx := context.Background() + _, err := service.ReadTemplate(ctx, "nonexistent-id") + + if err == nil { + t.Fatal("expected error, got nil") + } + + // Verify the service wraps the error with context + if !strings.Contains(err.Error(), "error reading template") { + t.Errorf("expected wrapped message, got: %s", err.Error()) + } + + // Verify the underlying APIError is still accessible + var apiErr *client.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("expected unwrappable *client.APIError, got %T", err) + } + + if apiErr.StatusCode != 404 { + t.Errorf("StatusCode = %d, want 404", apiErr.StatusCode) + } + + if apiErr.Message != "Resource not found" { + t.Errorf("Message = %q, want %q", apiErr.Message, "Resource not found") + } +}