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:
Oliver Jakoubek 2026-01-14 13:35:44 +01:00
commit 15cf18e2d8
4 changed files with 500 additions and 1 deletions

View file

@ -7,7 +7,7 @@
{"id":"checkvist-api-47y","title":"Quality \u0026 Documentation","description":"Phase 4: Complete unit tests, GoDoc examples, README, and CHANGELOG. Target \u003e80% test coverage. All public functions documented with examples.","status":"open","priority":0,"issue_type":"epic","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.655509387+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:36.655509387+01:00"}
{"id":"checkvist-api-5ab","title":"Implement Note operations","description":"Create notes.go with NoteService:\n- client.Notes(checklistID, taskID) returns NoteService\n- List(ctx) ([]Note, error) - GET /checklists/{id}/tasks/{task_id}/comments.json\n- Create(ctx, comment string) (*Note, error) - POST /checklists/{id}/tasks/{task_id}/comments.json\n- Update(ctx, noteID, comment string) (*Note, error) - PUT /checklists/{id}/tasks/{task_id}/comments/{note_id}.json\n- Delete(ctx, noteID) error - DELETE /checklists/{id}/tasks/{task_id}/comments/{note_id}.json\nContext support for all methods.","status":"open","priority":0,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:54.268124634+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:54.268124634+01:00","dependencies":[{"issue_id":"checkvist-api-5ab","depends_on_id":"checkvist-api-rl9","type":"blocks","created_at":"2026-01-14T12:32:55.843507717+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-5wr","title":"Initialize Go module and project structure","description":"Create go.mod with module path code.beautifulmachines.dev/jakoubek/checkvist-api. Set up flat package structure with placeholder files: client.go, checklists.go, tasks.go, notes.go, filter.go, models.go, errors.go, options.go. Create magefiles/ directory with separate go.mod for Mage build targets. Add LICENSE (MIT) file.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:06.285510329+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:43:45.392753825+01:00","closed_at":"2026-01-14T12:43:45.392753825+01:00","close_reason":"Closed"}
{"id":"checkvist-api-8bn","title":"Write unit tests for Client and Auth","description":"Create client_test.go with tests using httptest.Server:\n- TestNewClient_Defaults\n- TestNewClient_WithOptions\n- TestAuthenticate_Success\n- TestAuthenticate_InvalidCredentials\n- TestAuthenticate_2FA\n- TestTokenRefresh_Auto\n- TestTokenRefresh_Manual\n- TestCurrentUser\n- TestRetryLogic_429\n- TestRetryLogic_5xx\n- TestRetryLogic_NetworkError\nUse table-driven tests. Create testdata/auth/ fixtures.","status":"open","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.964610587+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:31:36.964610587+01:00","dependencies":[{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:33:12.783142853+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:33:13.232028837+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-8bn","title":"Write unit tests for Client and Auth","description":"Create client_test.go with tests using httptest.Server:\n- TestNewClient_Defaults\n- TestNewClient_WithOptions\n- TestAuthenticate_Success\n- TestAuthenticate_InvalidCredentials\n- TestAuthenticate_2FA\n- TestTokenRefresh_Auto\n- TestTokenRefresh_Manual\n- TestCurrentUser\n- TestRetryLogic_429\n- TestRetryLogic_5xx\n- TestRetryLogic_NetworkError\nUse table-driven tests. Create testdata/auth/ fixtures.","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:36.964610587+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:35:19.981723023+01:00","closed_at":"2026-01-14T13:35:19.981723023+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-lpn","type":"blocks","created_at":"2026-01-14T12:33:12.783142853+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8bn","depends_on_id":"checkvist-api-8u6","type":"blocks","created_at":"2026-01-14T12:33:13.232028837+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-8jh","title":"Implement repeating tasks configuration","description":"Add P2 (nice-to-have) repeat support to TaskBuilder:\n- WithRepeat(pattern string) *TaskBuilder\nSupport Checkvist smart syntax for repeats.\nDocument common patterns in GoDoc.","status":"open","priority":2,"issue_type":"feature","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:30:56.826106108+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T12:30:56.826106108+01:00","dependencies":[{"issue_id":"checkvist-api-8jh","depends_on_id":"checkvist-api-tjk","type":"blocks","created_at":"2026-01-14T12:33:03.159849575+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-8q3","title":"Set up Mage build targets","description":"Create magefiles/magefile.go with:\n- Test() - run go test -v ./...\n- Coverage() - run go test -coverprofile=coverage.out ./...\n- Lint() - run staticcheck ./...\n- Fmt() - run gofmt -w .\n- Check() - run all quality checks (fmt, vet, staticcheck, test)\nEnsure magefiles has its own go.mod importing magefile.org/mage","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:09.228450637+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:33:08.511791793+01:00","closed_at":"2026-01-14T13:33:08.511791793+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-8q3","depends_on_id":"checkvist-api-5wr","type":"blocks","created_at":"2026-01-14T12:32:48.556022687+01:00","created_by":"Oliver Jakoubek"}]}
{"id":"checkvist-api-8u6","title":"Implement HTTP request helper with retry logic","description":"Add internal HTTP helper to client.go:\n- doRequest(ctx, method, path, body) helper for all API calls\n- Automatic authentication check before requests\n- JSON marshaling/unmarshaling\n- Exponential backoff retry for:\n - HTTP 429 (Too Many Requests)\n - HTTP 5xx (Server Errors)\n - Network errors (timeout, connection reset)\n- Respect context cancellation\n- Optional debug logging of requests/responses via slog","status":"closed","priority":0,"issue_type":"task","owner":"mail@oliverjakoubek.de","created_at":"2026-01-14T12:31:08.780244392+01:00","created_by":"Oliver Jakoubek","updated_at":"2026-01-14T13:27:52.914675409+01:00","closed_at":"2026-01-14T13:27:52.914675409+01:00","close_reason":"Closed","dependencies":[{"issue_id":"checkvist-api-8u6","depends_on_id":"checkvist-api-ymg","type":"blocks","created_at":"2026-01-14T12:32:47.973194416+01:00","created_by":"Oliver Jakoubek"},{"issue_id":"checkvist-api-8u6","depends_on_id":"checkvist-api-mnh","type":"blocks","created_at":"2026-01-14T12:32:48.268500727+01:00","created_by":"Oliver Jakoubek"}]}

491
client_test.go Normal file
View 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")
}
}

5
testdata/auth/current_user.json vendored Normal file
View file

@ -0,0 +1,5 @@
{
"id": 12345,
"username": "testuser",
"email": "test@example.com"
}

3
testdata/auth/login_success.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"token": "test-token-abc123"
}