Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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 ./...
1 change: 1 addition & 0 deletions .tool-versions
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
golang 1.23.5
138 changes: 138 additions & 0 deletions client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package client

import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
Expand Down Expand Up @@ -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)
}
})
}
}
116 changes: 116 additions & 0 deletions config/go_versions_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions services/access_cards_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ package services

import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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")
}
}
Loading
Loading