Add unit tests for Client and Auth
Create client_test.go with comprehensive tests using httptest.Server: - TestNewClient_Defaults: verify default configuration - TestNewClient_WithOptions: test functional options - TestAuthenticate_Success: successful authentication flow - TestAuthenticate_InvalidCredentials: 401 error handling - TestAuthenticate_2FA: 2FA token support - TestTokenRefresh_Auto: automatic token refresh on expiry - TestTokenRefresh_Manual: manual token refresh - TestCurrentUser: get current user information - TestRetryLogic_429: retry on rate limiting - TestRetryLogic_5xx: retry on server errors - TestRetryLogic_ExhaustedRetries: max retries exceeded - TestRetryLogic_ContextCancellation: respect context cancellation - TestCalculateRetryDelay: exponential backoff calculation - TestDefaultRetryConfig: verify default retry settings Add testdata/auth/ fixtures: - login_success.json - current_user.json All 14 tests pass. Closes checkvist-api-8bn
This commit is contained in:
parent
bf4d899eb8
commit
15cf18e2d8
4 changed files with 500 additions and 1 deletions
491
client_test.go
Normal file
491
client_test.go
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
package checkvist
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// loadFixture loads a JSON fixture file from testdata.
|
||||
func loadFixture(t *testing.T, path string) []byte {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load fixture %s: %v", path, err)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func TestNewClient_Defaults(t *testing.T) {
|
||||
client := NewClient("user@example.com", "remote-key")
|
||||
|
||||
if client.baseURL != DefaultBaseURL {
|
||||
t.Errorf("expected baseURL %s, got %s", DefaultBaseURL, client.baseURL)
|
||||
}
|
||||
if client.username != "user@example.com" {
|
||||
t.Errorf("expected username user@example.com, got %s", client.username)
|
||||
}
|
||||
if client.remoteKey != "remote-key" {
|
||||
t.Errorf("expected remoteKey remote-key, got %s", client.remoteKey)
|
||||
}
|
||||
if client.httpClient == nil {
|
||||
t.Error("expected httpClient to be set")
|
||||
}
|
||||
if client.retryConf.MaxRetries != 3 {
|
||||
t.Errorf("expected MaxRetries 3, got %d", client.retryConf.MaxRetries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_WithOptions(t *testing.T) {
|
||||
customClient := &http.Client{Timeout: 60 * time.Second}
|
||||
customLogger := slog.New(slog.NewTextHandler(os.Stdout, nil))
|
||||
customRetry := RetryConfig{MaxRetries: 5, BaseDelay: 2 * time.Second}
|
||||
|
||||
client := NewClient("user@example.com", "remote-key",
|
||||
WithHTTPClient(customClient),
|
||||
WithLogger(customLogger),
|
||||
WithRetryConfig(customRetry),
|
||||
WithBaseURL("https://custom.api.com"),
|
||||
)
|
||||
|
||||
if client.httpClient != customClient {
|
||||
t.Error("expected custom HTTP client")
|
||||
}
|
||||
if client.logger != customLogger {
|
||||
t.Error("expected custom logger")
|
||||
}
|
||||
if client.retryConf.MaxRetries != 5 {
|
||||
t.Errorf("expected MaxRetries 5, got %d", client.retryConf.MaxRetries)
|
||||
}
|
||||
if client.baseURL != "https://custom.api.com" {
|
||||
t.Errorf("expected custom baseURL, got %s", client.baseURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticate_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/auth/login.json" {
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST, got %s", r.Method)
|
||||
}
|
||||
|
||||
// Verify form data
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("failed to parse form: %v", err)
|
||||
}
|
||||
if r.Form.Get("username") != "user@example.com" {
|
||||
t.Errorf("expected username user@example.com, got %s", r.Form.Get("username"))
|
||||
}
|
||||
if r.Form.Get("remote_key") != "api-key" {
|
||||
t.Errorf("expected remote_key api-key, got %s", r.Form.Get("remote_key"))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(loadFixture(t, "testdata/auth/login_success.json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL))
|
||||
err := client.Authenticate(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if client.token != "test-token-abc123" {
|
||||
t.Errorf("expected token test-token-abc123, got %s", client.token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticate_InvalidCredentials(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(`{"error": "Invalid credentials"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "wrong-key", WithBaseURL(server.URL))
|
||||
err := client.Authenticate(context.Background())
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
|
||||
var apiErr *APIError
|
||||
if !errors.As(err, &apiErr) {
|
||||
t.Fatalf("expected APIError, got %T", err)
|
||||
}
|
||||
if apiErr.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("expected status 401, got %d", apiErr.StatusCode)
|
||||
}
|
||||
if !errors.Is(err, ErrUnauthorized) {
|
||||
t.Error("expected error to wrap ErrUnauthorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticate_2FA(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("failed to parse form: %v", err)
|
||||
}
|
||||
|
||||
// Verify 2FA token is sent
|
||||
if r.Form.Get("totp") != "123456" {
|
||||
t.Errorf("expected totp 123456, got %s", r.Form.Get("totp"))
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write(loadFixture(t, "testdata/auth/login_success.json"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL))
|
||||
err := client.AuthenticateWith2FA(context.Background(), "123456")
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRefresh_Auto(t *testing.T) {
|
||||
var authCalls int32
|
||||
var refreshCalls int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
atomic.AddInt32(&authCalls, 1)
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "initial-token"})
|
||||
case "/auth/refresh_token.json":
|
||||
atomic.AddInt32(&refreshCalls, 1)
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "refreshed-token"})
|
||||
case "/test":
|
||||
// Verify token is sent
|
||||
if r.Header.Get("X-Client-Token") == "" {
|
||||
t.Error("expected X-Client-Token header")
|
||||
}
|
||||
w.Write([]byte(`{"ok": true}`))
|
||||
default:
|
||||
t.Errorf("unexpected path: %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL))
|
||||
|
||||
// First call should authenticate
|
||||
err := client.ensureAuthenticated(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&authCalls) != 1 {
|
||||
t.Errorf("expected 1 auth call, got %d", authCalls)
|
||||
}
|
||||
|
||||
// Simulate token about to expire
|
||||
client.mu.Lock()
|
||||
client.tokenExp = time.Now().Add(30 * time.Minute) // Less than 1 hour
|
||||
client.mu.Unlock()
|
||||
|
||||
// This should trigger a refresh
|
||||
err = client.ensureAuthenticated(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&refreshCalls) != 1 {
|
||||
t.Errorf("expected 1 refresh call, got %d", refreshCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenRefresh_Manual(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "initial-token"})
|
||||
case "/auth/refresh_token.json":
|
||||
if err := r.ParseForm(); err != nil {
|
||||
t.Fatalf("failed to parse form: %v", err)
|
||||
}
|
||||
if r.Form.Get("old_token") != "initial-token" {
|
||||
t.Errorf("expected old_token initial-token, got %s", r.Form.Get("old_token"))
|
||||
}
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "refreshed-token"})
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL))
|
||||
|
||||
// First authenticate
|
||||
err := client.Authenticate(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if client.token != "initial-token" {
|
||||
t.Errorf("expected initial-token, got %s", client.token)
|
||||
}
|
||||
|
||||
// Manually refresh
|
||||
err = client.refreshToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if client.token != "refreshed-token" {
|
||||
t.Errorf("expected refreshed-token, got %s", client.token)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentUser(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "test-token"})
|
||||
case "/auth/curr_user.json":
|
||||
if r.Header.Get("X-Client-Token") != "test-token" {
|
||||
t.Errorf("expected X-Client-Token test-token, got %s", r.Header.Get("X-Client-Token"))
|
||||
}
|
||||
w.Write(loadFixture(t, "testdata/auth/current_user.json"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key", WithBaseURL(server.URL))
|
||||
user, err := client.CurrentUser(context.Background())
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if user.ID != 12345 {
|
||||
t.Errorf("expected ID 12345, got %d", user.ID)
|
||||
}
|
||||
if user.Username != "testuser" {
|
||||
t.Errorf("expected username testuser, got %s", user.Username)
|
||||
}
|
||||
if user.Email != "test@example.com" {
|
||||
t.Errorf("expected email test@example.com, got %s", user.Email)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryLogic_429(t *testing.T) {
|
||||
var attempts int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "test-token"})
|
||||
case "/test":
|
||||
count := atomic.AddInt32(&attempts, 1)
|
||||
if count < 3 {
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error": "rate limited"}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key",
|
||||
WithBaseURL(server.URL),
|
||||
WithRetryConfig(RetryConfig{
|
||||
MaxRetries: 5,
|
||||
BaseDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Jitter: false,
|
||||
}),
|
||||
)
|
||||
|
||||
var result map[string]bool
|
||||
err := client.doGet(context.Background(), "/test", &result)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||
}
|
||||
if !result["success"] {
|
||||
t.Error("expected success=true in response")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryLogic_5xx(t *testing.T) {
|
||||
var attempts int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "test-token"})
|
||||
case "/test":
|
||||
count := atomic.AddInt32(&attempts, 1)
|
||||
if count < 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(`{"error": "server error"}`))
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"success": true}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key",
|
||||
WithBaseURL(server.URL),
|
||||
WithRetryConfig(RetryConfig{
|
||||
MaxRetries: 3,
|
||||
BaseDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Jitter: false,
|
||||
}),
|
||||
)
|
||||
|
||||
var result map[string]bool
|
||||
err := client.doGet(context.Background(), "/test", &result)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if atomic.LoadInt32(&attempts) != 2 {
|
||||
t.Errorf("expected 2 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryLogic_ExhaustedRetries(t *testing.T) {
|
||||
var attempts int32
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "test-token"})
|
||||
case "/test":
|
||||
atomic.AddInt32(&attempts, 1)
|
||||
w.WriteHeader(http.StatusServiceUnavailable)
|
||||
w.Write([]byte(`{"error": "service unavailable"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key",
|
||||
WithBaseURL(server.URL),
|
||||
WithRetryConfig(RetryConfig{
|
||||
MaxRetries: 2,
|
||||
BaseDelay: 1 * time.Millisecond,
|
||||
MaxDelay: 10 * time.Millisecond,
|
||||
Jitter: false,
|
||||
}),
|
||||
)
|
||||
|
||||
var result map[string]bool
|
||||
err := client.doGet(context.Background(), "/test", &result)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error after exhausted retries")
|
||||
}
|
||||
// 1 initial + 2 retries = 3 total attempts
|
||||
if atomic.LoadInt32(&attempts) != 3 {
|
||||
t.Errorf("expected 3 attempts, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryLogic_ContextCancellation(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/auth/login.json":
|
||||
json.NewEncoder(w).Encode(map[string]string{"token": "test-token"})
|
||||
case "/test":
|
||||
w.WriteHeader(http.StatusTooManyRequests)
|
||||
w.Write([]byte(`{"error": "rate limited"}`))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient("user@example.com", "api-key",
|
||||
WithBaseURL(server.URL),
|
||||
WithRetryConfig(RetryConfig{
|
||||
MaxRetries: 10,
|
||||
BaseDelay: 100 * time.Millisecond,
|
||||
MaxDelay: 1 * time.Second,
|
||||
Jitter: false,
|
||||
}),
|
||||
)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
var result map[string]bool
|
||||
err := client.doGet(ctx, "/test", &result)
|
||||
|
||||
if err == nil {
|
||||
t.Fatal("expected error due to context cancellation")
|
||||
}
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Errorf("expected context.DeadlineExceeded, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculateRetryDelay(t *testing.T) {
|
||||
client := NewClient("user", "key",
|
||||
WithRetryConfig(RetryConfig{
|
||||
MaxRetries: 5,
|
||||
BaseDelay: 100 * time.Millisecond,
|
||||
MaxDelay: 1 * time.Second,
|
||||
Jitter: false,
|
||||
}),
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
attempt int
|
||||
expected time.Duration
|
||||
}{
|
||||
{1, 200 * time.Millisecond}, // 100ms * 2^1
|
||||
{2, 400 * time.Millisecond}, // 100ms * 2^2
|
||||
{3, 800 * time.Millisecond}, // 100ms * 2^3
|
||||
{4, 1 * time.Second}, // capped at MaxDelay
|
||||
{5, 1 * time.Second}, // capped at MaxDelay
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
delay := client.calculateRetryDelay(tc.attempt)
|
||||
if delay != tc.expected {
|
||||
t.Errorf("attempt %d: expected %v, got %v", tc.attempt, tc.expected, delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultRetryConfig(t *testing.T) {
|
||||
config := DefaultRetryConfig()
|
||||
|
||||
if config.MaxRetries != 3 {
|
||||
t.Errorf("expected MaxRetries 3, got %d", config.MaxRetries)
|
||||
}
|
||||
if config.BaseDelay != 1*time.Second {
|
||||
t.Errorf("expected BaseDelay 1s, got %v", config.BaseDelay)
|
||||
}
|
||||
if config.MaxDelay != 30*time.Second {
|
||||
t.Errorf("expected MaxDelay 30s, got %v", config.MaxDelay)
|
||||
}
|
||||
if !config.Jitter {
|
||||
t.Error("expected Jitter to be true")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue