diff --git a/tests/fixtures/api_contracts/README.md b/tests/fixtures/api_contracts/README.md new file mode 100644 index 0000000..b15a4c6 --- /dev/null +++ b/tests/fixtures/api_contracts/README.md @@ -0,0 +1,60 @@ +# API contract test fixtures + +Recorded JSON payloads used by `tests/test_api_contracts.py` to verify that +GitHub and Slack API responses still deserialize through the Pydantic boundary +schemas in `github_activity_tracker.api_schemas` and `cppa_slack_tracker.api_schemas`. + +## Filename convention + +`__.json` + +The date suffix is the **recording date** (when the fixture was captured or last +refreshed), not the API event timestamp inside the JSON. + +## When to refresh + +- After GitHub or Slack API version or field changes that affect collectors +- After changing boundary parsers or Pydantic models in `api_schemas.py` +- When contract tests fail in CI with validation errors on these fixtures + +## How to refresh + +1. Capture a representative response from the live API (or from fetcher/sync debug output). +2. Redact secrets and sensitive data (see below). +3. Save under this directory with a **new** recording date in the filename. +4. Remove or keep older dated files; contract tests glob all matching prefixes. +5. Run: `uv run pytest tests/test_api_contracts.py -v` + +### Example: GitHub issue (nested bundle shape) + +```bash +# Replace TOKEN, owner, repo, and issue number. +curl -s -H "Authorization: Bearer $GITHUB_TOKEN" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/OWNER/REPO/issues/NUMBER" \ + > /tmp/issue.json +# Wrap as fetcher bundle if needed: {"issue_info": , "comments": [...]} +``` + +### Example: Slack channel (`conversations.list` member) + +Use a public channel object from Slack Web API responses; ensure `is_channel: true` +and `is_private: false` if testing channel ingestion paths. + +## Redaction rules + +- Do **not** commit API tokens, bot tokens, or webhook URLs. +- Mask or omit real user emails when possible (`@example.com` placeholders are fine). +- Trim large binary or irrelevant fields; keep fields the collector parsers use. + +## Fixture inventory + +| File | Parser | +|------|--------| +| `github_issue_bundle_*.json` | `parse_issue_bundle` | +| `github_pr_bundle_*.json` | `parse_pr_bundle` | +| `github_commit_*.json` | `parse_commit` | +| `slack_team_*.json` | `parse_team` | +| `slack_channel_*.json` | `parse_channel` | +| `slack_user_*.json` | `parse_user` | +| `slack_message_*.json` | `parse_message` | diff --git a/tests/fixtures/api_contracts/github_commit_2026-05-28.json b/tests/fixtures/api_contracts/github_commit_2026-05-28.json new file mode 100644 index 0000000..f56fbab --- /dev/null +++ b/tests/fixtures/api_contracts/github_commit_2026-05-28.json @@ -0,0 +1,46 @@ +{ + "sha": "a1b2c3d4e5f6789012345678901234567890abcd", + "author": { + "id": 583231, + "login": "octocat", + "name": "The Octocat", + "avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4" + }, + "committer": { + "id": 583231, + "login": "octocat" + }, + "commit": { + "message": "Contract test commit message\n\nMulti-line body.", + "author": { + "name": "The Octocat", + "email": "octocat@users.noreply.github.com", + "date": "2024-03-10T12:00:00Z" + }, + "committer": { + "name": "The Octocat", + "email": "octocat@users.noreply.github.com", + "date": "2024-03-10T12:00:00Z" + } + }, + "files": [ + { + "filename": "src/main.cpp", + "status": "modified", + "additions": 12, + "deletions": 3, + "patch": "@@ -1,3 +1,12 @@\n+// example" + }, + { + "filename": "README.md", + "status": "added", + "additions": 5, + "deletions": 0 + } + ], + "stats": { + "additions": 17, + "deletions": 3, + "total": 20 + } +} diff --git a/tests/fixtures/api_contracts/github_issue_bundle_2026-05-28.json b/tests/fixtures/api_contracts/github_issue_bundle_2026-05-28.json new file mode 100644 index 0000000..b6fbcbb --- /dev/null +++ b/tests/fixtures/api_contracts/github_issue_bundle_2026-05-28.json @@ -0,0 +1,43 @@ +{ + "issue_info": { + "number": 42, + "id": 2847291001, + "title": "Contract test issue", + "body": "Body from recorded GitHub-style payload.", + "state": "open", + "state_reason": "reopened", + "user": { + "id": 583231, + "login": "octocat", + "name": "The Octocat", + "avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4" + }, + "created_at": "2024-01-15T10:00:00Z", + "updated_at": "2024-01-20T14:30:00Z", + "closed_at": null, + "assignees": [ + { + "id": 991001, + "login": "assignee-one", + "name": "Assignee One" + } + ], + "labels": [ + {"name": "bug"}, + {"name": "help wanted"} + ] + }, + "comments": [ + { + "id": 9001001, + "body": "First comment on issue.", + "user": { + "id": 583231, + "login": "octocat", + "name": "The Octocat" + }, + "created_at": "2024-01-16T09:00:00Z", + "updated_at": "2024-01-16T09:00:00Z" + } + ] +} diff --git a/tests/fixtures/api_contracts/github_pr_bundle_2026-05-28.json b/tests/fixtures/api_contracts/github_pr_bundle_2026-05-28.json new file mode 100644 index 0000000..c5c8839 --- /dev/null +++ b/tests/fixtures/api_contracts/github_pr_bundle_2026-05-28.json @@ -0,0 +1,41 @@ +{ + "pr_info": { + "number": 17, + "id": 3847291002, + "title": "Contract test pull request", + "body": "PR body from recorded GitHub-style payload.", + "state": "open", + "user": { + "id": 583231, + "login": "octocat", + "name": "The Octocat", + "avatar_url": "https://avatars.githubusercontent.com/u/583231?v=4" + }, + "head": {"sha": "feature-branch-sha-abc123def456"}, + "base": {"sha": "main-branch-sha-789012345678"}, + "created_at": "2024-02-01T08:00:00Z", + "updated_at": "2024-02-05T16:45:00Z", + "merged_at": null, + "assignees": [], + "labels": [{"name": "enhancement"}] + }, + "comments": [ + { + "id": 9002001, + "body": "PR discussion comment.", + "user": {"id": 583231, "login": "octocat"}, + "created_at": "2024-02-02T11:00:00Z", + "updated_at": "2024-02-02T11:00:00Z" + } + ], + "reviews": [ + { + "id": 8003001, + "body": "Looks good.", + "user": {"id": 991002, "login": "reviewer-one"}, + "in_reply_to_id": null, + "created_at": "2024-02-03T10:00:00Z", + "updated_at": "2024-02-03T10:00:00Z" + } + ] +} diff --git a/tests/fixtures/api_contracts/slack_channel_2026-05-28.json b/tests/fixtures/api_contracts/slack_channel_2026-05-28.json new file mode 100644 index 0000000..f9b94a0 --- /dev/null +++ b/tests/fixtures/api_contracts/slack_channel_2026-05-28.json @@ -0,0 +1,23 @@ +{ + "id": "C0CONTRACT01", + "name": "general", + "is_channel": true, + "is_private": false, + "is_im": false, + "is_mpim": false, + "is_archived": false, + "is_general": true, + "creator": "U0CONTRACT01", + "created": 1609459200, + "purpose": { + "value": "Company-wide announcements and work-based matters", + "creator": "U0CONTRACT01", + "last_set": 1609459200 + }, + "topic": { + "value": "General discussion", + "creator": "U0CONTRACT01", + "last_set": 1609459200 + }, + "num_members": 42 +} diff --git a/tests/fixtures/api_contracts/slack_message_2026-05-28.json b/tests/fixtures/api_contracts/slack_message_2026-05-28.json new file mode 100644 index 0000000..936ce1d --- /dev/null +++ b/tests/fixtures/api_contracts/slack_message_2026-05-28.json @@ -0,0 +1,19 @@ +{ + "type": "message", + "subtype": null, + "user": "U0CONTRACT01", + "text": "Contract test message from recorded Slack-style payload.", + "ts": "1710000000.123456", + "thread_ts": null, + "edited": { + "user": "U0CONTRACT01", + "ts": "1710000100.654321" + }, + "reactions": [ + { + "name": "thumbsup", + "count": 2, + "users": ["U0CONTRACT01", "U0CONTRACT02"] + } + ] +} diff --git a/tests/fixtures/api_contracts/slack_team_2026-05-28.json b/tests/fixtures/api_contracts/slack_team_2026-05-28.json new file mode 100644 index 0000000..5c3bdbe --- /dev/null +++ b/tests/fixtures/api_contracts/slack_team_2026-05-28.json @@ -0,0 +1,11 @@ +{ + "id": "T0CONTRACT01", + "name": "Contract Test Workspace", + "domain": "contract-test", + "email_domain": "example.com", + "icon": { + "image_68": "https://example.com/team-icon-68.png" + }, + "enterprise_id": null, + "enterprise_name": null +} diff --git a/tests/fixtures/api_contracts/slack_user_2026-05-28.json b/tests/fixtures/api_contracts/slack_user_2026-05-28.json new file mode 100644 index 0000000..6e10669 --- /dev/null +++ b/tests/fixtures/api_contracts/slack_user_2026-05-28.json @@ -0,0 +1,17 @@ +{ + "id": "U0CONTRACT01", + "name": "contract.user", + "real_name": "Contract Test User", + "deleted": false, + "is_bot": false, + "is_app_user": false, + "updated": 1710000000, + "profile": { + "email": "contract.user@example.com", + "image_72": "https://example.com/avatar-72.png", + "display_name": "contractuser", + "real_name": "Contract Test User", + "title": "Engineer" + }, + "team_id": "T0CONTRACT01" +} diff --git a/tests/test_api_contracts.py b/tests/test_api_contracts.py new file mode 100644 index 0000000..2c056be --- /dev/null +++ b/tests/test_api_contracts.py @@ -0,0 +1,133 @@ +"""Contract tests: recorded API fixtures must parse through boundary schemas.""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest + +from cppa_slack_tracker.api_schemas import ( + SlackApiValidationError, + parse_channel, + parse_message, + parse_team, + parse_user, +) +from github_activity_tracker.api_schemas import ( + GitHubApiValidationError, + parse_commit, + parse_issue_bundle, + parse_pr_bundle, +) + +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "api_contracts" + + +def _load_fixture(path: Path) -> dict[str, Any]: + return json.loads(path.read_text(encoding="utf-8")) + + +def _fixture_paths(prefix: str) -> list[Path]: + paths = sorted(FIXTURES_DIR.glob(f"{prefix}_*.json")) + if not paths: + raise ValueError(f"no fixtures found for prefix '{prefix}'") + return paths + + +# --- GitHub --- + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("github_issue_bundle"), + ids=[p.name for p in _fixture_paths("github_issue_bundle")], +) +def test_github_issue_bundle_contract(fixture_path: Path) -> None: + bundle = parse_issue_bundle(_load_fixture(fixture_path), source=fixture_path.name) + assert bundle.issue.number >= 1 + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("github_pr_bundle"), + ids=[p.name for p in _fixture_paths("github_pr_bundle")], +) +def test_github_pr_bundle_contract(fixture_path: Path) -> None: + bundle = parse_pr_bundle(_load_fixture(fixture_path), source=fixture_path.name) + assert bundle.pr.number >= 1 + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("github_commit"), + ids=[p.name for p in _fixture_paths("github_commit")], +) +def test_github_commit_contract(fixture_path: Path) -> None: + commit = parse_commit(_load_fixture(fixture_path), source=fixture_path.name) + assert len(commit.sha) >= 1 + + +# --- Slack --- + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("slack_team"), + ids=[p.name for p in _fixture_paths("slack_team")], +) +def test_slack_team_contract(fixture_path: Path) -> None: + team = parse_team(_load_fixture(fixture_path), source=fixture_path.name) + assert team.team_id + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("slack_channel"), + ids=[p.name for p in _fixture_paths("slack_channel")], +) +def test_slack_channel_contract(fixture_path: Path) -> None: + channel = parse_channel(_load_fixture(fixture_path), source=fixture_path.name) + assert channel.id + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("slack_user"), + ids=[p.name for p in _fixture_paths("slack_user")], +) +def test_slack_user_contract(fixture_path: Path) -> None: + user = parse_user(_load_fixture(fixture_path), source=fixture_path.name) + assert user.id + + +@pytest.mark.parametrize( + "fixture_path", + _fixture_paths("slack_message"), + ids=[p.name for p in _fixture_paths("slack_message")], +) +def test_slack_message_contract(fixture_path: Path) -> None: + message = parse_message(_load_fixture(fixture_path), source=fixture_path.name) + assert message.ts is not None + + +# --- Negative sanity: contract tests detect broken required fields --- + + +def test_github_issue_bundle_contract_fails_without_number() -> None: + data = _load_fixture(_fixture_paths("github_issue_bundle")[0]) + if "issue_info" in data: + del data["issue_info"]["number"] + else: + del data["issue"]["number"] + with pytest.raises(GitHubApiValidationError): + parse_issue_bundle(data) + + +def test_slack_team_contract_fails_without_id() -> None: + data = _load_fixture(_fixture_paths("slack_team")[0]) + data.pop("id", None) + data.pop("team_id", None) + with pytest.raises(SlackApiValidationError): + parse_team(data) diff --git a/tests/test_idempotency_under_retry.py b/tests/test_idempotency_under_retry.py new file mode 100644 index 0000000..eb7b167 --- /dev/null +++ b/tests/test_idempotency_under_retry.py @@ -0,0 +1,76 @@ +"""Idempotency-under-retry tests for get_or_create_* service functions.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from django.db import transaction + +from cppa_slack_tracker.models import SlackChannel, SlackTeam +from cppa_slack_tracker.services import ( + get_or_create_slack_channel, + get_or_create_slack_team, +) +from github_activity_tracker.models import GitHubRepository +from github_activity_tracker.services import get_or_create_repository + +FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" / "api_contracts" + + +def _load_fixture(name: str) -> dict: + matches = sorted(FIXTURES_DIR.glob(name)) + assert matches, f"no fixture matching {name}" + return json.loads(matches[0].read_text(encoding="utf-8")) + + +@pytest.mark.django_db +def test_get_or_create_slack_team_idempotent_under_retry() -> None: + team_data = _load_fixture("slack_team_*.json") + with transaction.atomic(): + count_before = SlackTeam.objects.count() + team1, created1 = get_or_create_slack_team(team_data) + team2, created2 = get_or_create_slack_team(team_data) + assert SlackTeam.objects.count() == count_before + 1 + assert team1.pk == team2.pk + assert created1 is True + assert created2 is False + + +@pytest.mark.django_db +def test_get_or_create_slack_channel_idempotent_under_retry( + sample_slack_team, + sample_slack_user, +) -> None: + _ = sample_slack_user + channel_data = _load_fixture("slack_channel_*.json") + channel_data = dict(channel_data) + channel_data["creator"] = sample_slack_user.slack_user_id + with transaction.atomic(): + count_before = SlackChannel.objects.count() + ch1, created1 = get_or_create_slack_channel(channel_data, sample_slack_team) + ch2, created2 = get_or_create_slack_channel(channel_data, sample_slack_team) + assert ch1 is not None and ch2 is not None + assert SlackChannel.objects.count() == count_before + 1 + assert ch1.pk == ch2.pk + assert created1 is True + assert created2 is False + + +@pytest.mark.django_db +def test_get_or_create_repository_idempotent_under_retry(github_account) -> None: + repo_name = "contract-idempotency-repo" + kwargs = { + "stars": 10, + "forks": 2, + "description": "Idempotency contract test", + } + with transaction.atomic(): + count_before = GitHubRepository.objects.count() + repo1, created1 = get_or_create_repository(github_account, repo_name, **kwargs) + repo2, created2 = get_or_create_repository(github_account, repo_name, **kwargs) + assert GitHubRepository.objects.count() == count_before + 1 + assert repo1.pk == repo2.pk + assert created1 is True + assert created2 is False