Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ad80d22
Add fixture-completeness guard: manifest + coverage invariant
jeremy Jul 25, 2026
83ce34f
Source TS/Ruby/Python test helpers from shared fixtures
jeremy Jul 25, 2026
6addd73
Address review: pin get-test ids to the requested resource
jeremy Jul 25, 2026
72614b7
Harden fixture guard: schema type/nullability validation, CI, non-sel…
jeremy Jul 25, 2026
b8bc410
Fixture guard round 2: integral-int, null elements, robust emitters, …
jeremy Jul 25, 2026
844b66b
Address review: load_fixture reads UTF-8 (locale robustness)
jeremy Jul 25, 2026
4492836
Fixture guard: emitter discovery traverses composition + aliased items
jeremy Jul 25, 2026
1c3b523
Fixture guard round 4: $ref siblings, composition-aware validation, k…
jeremy Jul 25, 2026
cc89bd7
Fixture guard: validate anyOf/oneOf alternatives (at-least-one-branch)
jeremy Jul 25, 2026
23d5dad
Fixture guard: allOf nullability is conjunctive (null needs every bra…
jeremy Jul 25, 2026
35fc26c
Fixture guard: fold anyOf/oneOf into nullability (reject null-typed-b…
jeremy Jul 25, 2026
c261cb1
Fixture guard: intersect allOf type constraints; pin Vitest fs allow-…
jeremy Jul 25, 2026
170eb10
Harden run_checker_killable against the Timeout-after-reap race
jeremy Jul 25, 2026
67bd5ff
Fixture guard: conjoin allOf array items; accept scalar type:"null"
jeremy Jul 25, 2026
a5c6eff
Fixture guard: scalar type:"null" imposes a real null constraint; doc…
jeremy Jul 25, 2026
72d94ad
Fixture guard: preserve null-only constraint for type: ["null"] union
jeremy Jul 25, 2026
58a7f69
Merge remote-tracking branch 'origin/main' into track-b-fixture-cover…
jeremy Jul 25, 2026
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
27 changes: 27 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,33 @@ jobs:
- name: Verify embedded provenance is up to date
run: diff -q spec/api-provenance.json go/pkg/basecamp/api-provenance.json

fixture-coverage:
name: Fixture coverage
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false

- name: Set up Ruby
uses: ruby/setup-ruby@d45b1a4e94b71acab930e56e79c6aa188764e7f9 # v1.316.0
with:
ruby-version: "3.3"

# Run under LC_ALL=C so CI actually exercises the non-UTF-8-locale path
# (the reads are pinned to UTF-8; this proves it stays that way).
- name: Check fixture completeness + coverage invariant
run: ruby scripts/check-fixture-coverage.rb
env:
LC_ALL: C

- name: Fixture-guard self-test (negative + synthetic cases)
run: ruby scripts/test-check-fixture-coverage.rb
env:
LC_ALL: C

test-go:
name: Go Tests
runs-on: ubuntu-latest
Expand Down
15 changes: 14 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,9 +309,22 @@ Use `make sync-status` to see upstream diffs since last sync.

### Every Changed Field/Path Requires

1. **Existing tests updated** — every test stubbing a changed path must be updated
1. **Existing tests updated** — every test stubbing a changed path must be updated.
Inline stubs may omit unrelated response fields, but every response stub for a
changed path must include any newly required or behaviorally changed field.
2. **New field tests** — at least one test fixture should include new fields to verify they flow through

> **Automated backstop.** The shared JSON fixtures under `spec/fixtures/` are
> guarded by `make check-fixture-coverage` (`spec/fixtures/manifest.yaml`): every
> manifest'd fixture is validated against its schema for both required-field
> presence and type/nullability (a required-null-against-non-nullable or a
> wrong-typed value fails), every `covered_schemas` entry must keep a concrete
> representative, and every rich-text emitter schema must be covered or explicitly
> excluded (so the inventory can't silently shrink). A new required field on a
> covered schema is therefore forced into a fixture. This guard covers only the
> manifest'd shared fixtures — one-off inline stubs remain the reviewer's
> responsibility under the rule above.

### Pre-Merge Verification

Run `make go-check-drift`, `make kt-check-drift`, and `make py-check-drift` (all included in `make check`) and verify:
Expand Down
16 changes: 13 additions & 3 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -743,7 +743,7 @@ tools:
# Spec-shape lints
#------------------------------------------------------------------------------

.PHONY: check-bucket-flat-parity validate-api-gaps check-deprecation-parity kt-check-optional-arrays
.PHONY: check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays

# Verify every bucket-scoped GET list operation has a flat-path counterpart
# (or is justified in spec/bucket-scoped-allowlist.txt). Cross-project SDK
Expand All @@ -765,6 +765,16 @@ check-deprecation-parity: rb-build
validate-api-gaps:
@./scripts/validate-api-gaps.sh

# Fixture-completeness guard: every spec/fixtures/manifest.yaml target validates
# against its schema (required-field presence + type/nullability), every covered
# schema keeps a concrete active representative, and every rich-text emitter is
# accounted for — so a new required field on a covered schema is forced into a
# fixture. Reuses the conformance schema-walker. The self-test asserts the guard
# rejects each crafted failure mode (the live check only exercises the valid set).
check-fixture-coverage:
@./scripts/check-fixture-coverage.sh
@ruby ./scripts/test-check-fixture-coverage.rb

# D-invariant: every optional generated Kotlin array is `List<T>? = null`, every
# required array stays `List<T>`, and none default to the `= emptyList()`
# sentinel — pinning the optional-array presence contract against regression.
Expand Down Expand Up @@ -793,7 +803,7 @@ generate:
@echo "==> Generation complete"

# Run all checks (Smithy + Go + TypeScript + Ruby + Kotlin + Swift + Python + Behavior Model + Conformance + Provenance + Actions lint)
check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check go-check-drift go-check-wrapper-drift auth-routable-check kt-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity kt-check-optional-arrays
check: lint-actions sync-spec-version-check smithy-check behavior-model-check provenance-check sync-api-version-check go-check-drift go-check-wrapper-drift auth-routable-check kt-check-drift go-check ts-check rb-check kt-check swift-check py-check conformance check-bucket-flat-parity validate-api-gaps check-deprecation-parity check-fixture-coverage kt-check-optional-arrays
@echo "==> All checks passed"

# Clean all build artifacts
Expand Down Expand Up @@ -906,6 +916,6 @@ help:
@echo ""
@echo "Combined:"
@echo " generate Regenerate every machine-derived artifact (Smithy + per-language SDKs + provenance)"
@echo " check Run all checks (Smithy + behavior-model/drift + Go + TypeScript + Ruby + Swift + Kotlin + Python + Conformance + Provenance + API version sync + parity lint + api-gaps + Actions lint)"
@echo " check Run all checks (Smithy + behavior-model/drift + Go + TypeScript + Ruby + Swift + Kotlin + Python + Conformance + Provenance + API version sync + parity lint + api-gaps + fixture-coverage + kt-optional-arrays + Actions lint)"
@echo " clean Remove all build artifacts"
@echo " help Show this help"
4 changes: 3 additions & 1 deletion conformance/runner/ruby/schema-walker.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ class SchemaWalker
MAX_DEPTH = 12

def initialize(openapi_path)
@doc = JSON.parse(File.read(openapi_path))
# Read as UTF-8 regardless of process locale (LC_ALL=C would otherwise
# read as US-ASCII and choke on the spec's UTF-8 bytes).
@doc = JSON.parse(File.read(openapi_path, encoding: "UTF-8"))
end

# Returns the response schema for operation_id, or nil when none exists.
Expand Down
16 changes: 13 additions & 3 deletions python/tests/services/test_todos.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
from pathlib import Path

import httpx
import pytest
Expand All @@ -14,9 +15,19 @@

BASE = "https://3.basecampapi.com/12345"

_FIXTURES = Path(__file__).resolve().parents[3] / "spec" / "fixtures"


def load_fixture(rel: str) -> dict:
return json.loads((_FIXTURES / rel).read_text(encoding="utf-8"))


def _todo(todo_id: int = 42, **overrides) -> dict:
todo = {
# Source the full validated fixture for shape (every required Todo field is
# present), then keep the test-critical override values that the assertions
# verify flow through to the PUT body.
return {
**load_fixture("todos/get.json"),
"id": todo_id,
"content": "Buy milk",
"description": "<p>From the store</p>",
Expand All @@ -25,9 +36,8 @@ def _todo(todo_id: int = 42, **overrides) -> dict:
"assignees": [{"id": 100, "name": "Jane Doe"}],
"completion_subscribers": [{"id": 555, "name": "Sub Scriber"}],
"completed": False,
**overrides,
}
todo.update(overrides)
return todo


def _put_body(route) -> dict:
Expand Down
13 changes: 11 additions & 2 deletions python/tests/services/test_uploads.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,30 @@

from __future__ import annotations

import json
from pathlib import Path

import httpx
import pytest
import respx

from basecamp import AsyncClient, Client
from basecamp.errors import UsageError

_FIXTURES = Path(__file__).resolve().parents[3] / "spec" / "fixtures"


def load_fixture(rel: str) -> dict:
return json.loads((_FIXTURES / rel).read_text(encoding="utf-8"))


def _metadata(upload_id: int = 1069479400, *, download_url, filename="report.pdf") -> dict:
"""Minimal upload metadata payload; callers override download_url/filename."""
"""Upload metadata sourced from the validated fixture; callers override download_url/filename."""
return {
**load_fixture("uploads/get.json"),
"id": upload_id,
"filename": filename,
"download_url": download_url,
"description_attachments": [],
}


Expand Down
17 changes: 5 additions & 12 deletions ruby/test/basecamp/services/cards_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,9 @@ def setup
@account = create_account_client(account_id: "12345")
end

def sample_card(id: 1, title: "Task Card")
{
"id" => id,
"title" => title,
"content" => "<p>Card description</p>",
"description_attachments" => [],
"due_on" => "2024-12-31",
"completed" => false,
"assignees" => []
}
def sample_card(id: nil, title: nil)
fixture = load_fixture("cards/get.json")
fixture.merge "id" => id || fixture["id"], "title" => title || fixture["title"]
end

def test_list_cards
Expand All @@ -33,7 +26,7 @@ def test_list_cards
cards = @account.cards.list(column_id: 200).to_a

assert_equal 2, cards.length
assert_equal "Task Card", cards[0]["title"]
assert_equal "Implement user authentication", cards[0]["title"]
assert_equal "Another Card", cards[1]["title"]
end

Expand All @@ -44,7 +37,7 @@ def test_get_card
card = @account.cards.get(card_id: 200)

assert_equal 200, card["id"]
assert_equal "Task Card", card["title"]
assert_equal "Implement user authentication", card["title"]
end

def test_create_card
Expand Down
15 changes: 5 additions & 10 deletions ruby/test/basecamp/services/checkins_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,9 @@ def sample_question(id: 1, title: "What did you work on today?")
}
end

def sample_answer(id: 1, content: "<p>Making great progress!</p>")
{
"id" => id,
"content" => content,
"content_attachments" => [],
"creator" => { "id" => 1, "name" => "Test User" },
"created_at" => "2024-01-01T00:00:00Z"
}
def sample_answer(id: nil, content: nil)
fixture = load_fixture("checkins/answer.json")
fixture.merge "id" => id || fixture["id"], "content" => content || fixture["content"]
end

def test_get_questionnaire
Expand Down Expand Up @@ -107,7 +102,7 @@ def test_list_answers
answers = @account.checkins.list_answers(question_id: 200).to_a

assert_equal 2, answers.length
assert_equal "<p>Making great progress!</p>", answers[0]["content"]
assert_equal load_fixture("checkins/answer.json")["content"], answers[0]["content"]
end

def test_get_answer
Expand All @@ -117,7 +112,7 @@ def test_get_answer
answer = @account.checkins.get_answer(answer_id: 200)

assert_equal 200, answer["id"]
assert_equal "<p>Making great progress!</p>", answer["content"]
assert_equal load_fixture("checkins/answer.json")["content"], answer["content"]
end

def test_create_answer
Expand Down
18 changes: 6 additions & 12 deletions ruby/test/basecamp/services/client_approvals_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,9 @@ def setup
@account = create_account_client(account_id: "12345")
end

def sample_approval(id: 1, subject: "Design Review")
{
"id" => id,
"subject" => subject,
"approval_status" => "pending",
"content" => "<p>Please review the attached designs.</p>",
"content_attachments" => [],
"created_at" => "2024-01-01T00:00:00Z"
}
def sample_approval(id: nil, subject: nil)
fixture = load_fixture("client_approvals/get.json")
fixture.merge "id" => id || fixture["id"], "subject" => subject || fixture["subject"]
end

def test_list_approvals
Expand All @@ -32,7 +26,7 @@ def test_list_approvals
approvals = @account.client_approvals.list.to_a

assert_equal 2, approvals.length
assert_equal "Design Review", approvals[0]["subject"]
assert_equal "New logo for the website", approvals[0]["subject"]
assert_equal "Budget Approval", approvals[1]["subject"]
end

Expand All @@ -43,7 +37,7 @@ def test_get_approval
approval = @account.client_approvals.get(approval_id: 200)

assert_equal 200, approval["id"]
assert_equal "Design Review", approval["subject"]
assert_equal "pending", approval["approval_status"]
assert_equal "New logo for the website", approval["subject"]
assert_equal "approved", approval["approval_status"]
end
end
18 changes: 6 additions & 12 deletions ruby/test/basecamp/services/client_correspondences_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,15 +14,9 @@ def setup
@account = create_account_client(account_id: "12345")
end

def sample_correspondence(id: 1, subject: "Project Update")
{
"id" => id,
"subject" => subject,
"content" => "<p>Here is the latest update on the project.</p>",
"content_attachments" => [],
"replies_count" => 3,
"created_at" => "2024-01-01T00:00:00Z"
}
def sample_correspondence(id: nil, subject: nil)
fixture = load_fixture("client_correspondences/get.json")
fixture.merge "id" => id || fixture["id"], "subject" => subject || fixture["subject"]
end

def test_list_correspondences
Expand All @@ -32,7 +26,7 @@ def test_list_correspondences
correspondences = @account.client_correspondences.list.to_a

assert_equal 2, correspondences.length
assert_equal "Project Update", correspondences[0]["subject"]
assert_equal "Project kickoff!", correspondences[0]["subject"]
assert_equal "Invoice Query", correspondences[1]["subject"]
end

Expand All @@ -43,7 +37,7 @@ def test_get_correspondence
correspondence = @account.client_correspondences.get(correspondence_id: 200)

assert_equal 200, correspondence["id"]
assert_equal "Project Update", correspondence["subject"]
assert_equal 3, correspondence["replies_count"]
assert_equal "Project kickoff!", correspondence["subject"]
assert_equal 5, correspondence["replies_count"]
end
end
17 changes: 6 additions & 11 deletions ruby/test/basecamp/services/client_replies_service_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,9 @@ def setup
@account = create_account_client(account_id: "12345")
end

def sample_reply(id: 1, content: "<p>Thank you for the update!</p>")
{
"id" => id,
"content" => content,
"content_attachments" => [],
"creator" => { "id" => 1, "name" => "Client User" },
"created_at" => "2024-01-01T00:00:00Z"
}
def sample_reply(id: nil, content: nil)
fixture = load_fixture("client_replies/get.json")
fixture.merge "id" => id || fixture["id"], "content" => content || fixture["content"]
end

def test_list_replies
Expand All @@ -31,7 +26,7 @@ def test_list_replies
replies = @account.client_replies.list(recording_id: 200).to_a

assert_equal 2, replies.length
assert_equal "<p>Thank you for the update!</p>", replies[0]["content"]
assert_equal load_fixture("client_replies/get.json")["content"], replies[0]["content"]
assert_equal "<p>Looking forward to it!</p>", replies[1]["content"]
end

Expand All @@ -42,7 +37,7 @@ def test_get_reply
reply = @account.client_replies.get(recording_id: 200, reply_id: 300)

assert_equal 300, reply["id"]
assert_equal "<p>Thank you for the update!</p>", reply["content"]
assert_equal "Client User", reply["creator"]["name"]
assert_equal load_fixture("client_replies/get.json")["content"], reply["content"]
assert_equal "Annie Bryan", reply["creator"]["name"]
end
end
Loading
Loading