diff --git a/.github/workflows/dev.yml b/.github/workflows/dev.yml index 4443e69..e143b47 100644 --- a/.github/workflows/dev.yml +++ b/.github/workflows/dev.yml @@ -65,7 +65,15 @@ jobs: name: database.json github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ steps.get_id.outputs.previous_run_id }} - continue-on-error: true + continue-on-error: true + + - name: Download previous metrics artifact + uses: actions/download-artifact@v8 + with: + name: metrics.sqlite + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.get_id.outputs.previous_run_id }} + continue-on-error: true - name: Call RescueGroups API env: @@ -92,6 +100,13 @@ jobs: path: database.json retention-days: 1 archive: false + + - name: Upload metrics artifact + uses: actions/upload-artifact@v7 + with: + path: metrics.sqlite + retention-days: 1 + archive: false - name: Upload Log artifact if: '!cancelled()' #This ensures this step runs even if the previous steps failed only if manually cancelled it doesnt run diff --git a/.github/workflows/prod.yml b/.github/workflows/prod.yml index b4f0cd8..d1528df 100644 --- a/.github/workflows/prod.yml +++ b/.github/workflows/prod.yml @@ -52,7 +52,15 @@ jobs: name: database.json github-token: ${{ secrets.GITHUB_TOKEN }} run-id: ${{ steps.get_id.outputs.previous_run_id }} - continue-on-error: true + continue-on-error: true + + - name: Download previous metrics artifact + uses: actions/download-artifact@v8 + with: + name: metrics.sqlite + github-token: ${{ secrets.GITHUB_TOKEN }} + run-id: ${{ steps.get_id.outputs.previous_run_id }} + continue-on-error: true - name: Call RescueGroups API env: @@ -73,6 +81,13 @@ jobs: retention-days: 14 archive: false + - name: Upload metrics artifact + uses: actions/upload-artifact@v7 + with: + path: metrics.sqlite + retention-days: 14 + archive: false + - name: Upload API Log artifact if: '!cancelled()' #This ensures this step runs even if the previous steps failed only if manually cancelled it doesnt run uses: actions/upload-artifact@v7 diff --git a/abstractions.py b/abstractions.py index ea347c3..dd7c6c5 100644 --- a/abstractions.py +++ b/abstractions.py @@ -139,3 +139,35 @@ def format_post(self, pet: AdoptablePet) -> Post: pet.breed.lower().replace(" ", ""), ], ) + + +# ============================================================================= +# Engagement Metric Collector Interface +# ============================================================================= + + +@dataclass +class PostMetrics: + """Point-in-time engagement counts for a published post.""" + + collected_at: str + likes: int | None = None + reposts: int | None = None + comments: int | None = None + + +class MetricCollector(ABC): + """Interface for collecting engagement metrics from a social platform.""" + + @property + @abstractmethod + def platform_name(self) -> str: + """Return the platform name used in persisted post records.""" + ... + + @abstractmethod + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + """Return a metric snapshot, or None when the post cannot be read.""" + ... diff --git a/docs/specs/metric-collector.md b/docs/specs/metric-collector.md new file mode 100644 index 0000000..37d8a6b --- /dev/null +++ b/docs/specs/metric-collector.md @@ -0,0 +1,271 @@ +# MetricCollector — per-platform engagement snapshots + +## Summary + +Add an `AbstractMetricCollector` parallel to the existing `SocialPoster` ABC, with concrete implementations for Bluesky, Mastodon, and Instagram. Each run, after publishing, re-poll engagement counts (likes, reposts, comments) for posts up to 14 days old and append snapshots to the existing `database.json` artifact. `database.json` grows a new top-level `posts[]` table (one row per published post, joined to `posted_pets[]` by `pet_id`) with metric snapshots nested inside each post as a time-series list. No new persistence layer — same artifact, same workflow. + +## Problem Statement + +We post pets to three platforms but capture zero feedback on how those posts perform. We don't know which pets get traction, which platform performs best for which species, or whether a given post is dead in the water. Without that data, we can't prioritize, A/B, or build a dashboard later. + +## Goals + +- Persist per-platform engagement (likes, reposts/shares, comments) for every post we publish. +- Snapshot metrics on a recurring schedule (each scheduled run) so we have a time series, not just latest counts. +- Reuse the existing `database.json` artifact and GH Actions upload/download pattern — no new storage. +- Mirror the existing `SocialPoster` abstraction so adding a new platform is symmetric: one poster + one collector. + +## Non-Goals + +- **Dashboard / UI.** Out of scope for this MVP — planned as a follow-up (likely served from GH Pages in this repo, consuming the same artifact). +- **Slack digests / alerts on metric trends.** Future work. +- **Impressions / views / reach.** Not all platforms expose these (Bluesky doesn't, Mastodon doesn't, Insta requires extra Insights perms). Core engagement only. +- **Backfilling metrics for posts from before this feature ships.** Going forward only. + +## Background + +Current state (relevant pieces): + +- `abstractions.py` defines `SocialPoster` ABC, `Post`, `PostResult(success, post_id, post_url, error_message)`. Each concrete poster returns a `post_id` (and usually `post_url`) on success. +- `main.py::run()` iterates posters, calls `publish()`, but **does not persist the returned `post_id` anywhere.** That's a blocker for metric collection. +- `main.py::pick_pet()` writes one entry per posted pet to `database.json`: `{name, pet_id, posted_at}`. Entries older than 12 weeks are pruned on write. +- `database.json` is a GitHub Actions artifact: `prod.yml` downloads the previous run's artifact, runs `main.py`, then re-uploads (14-day retention in prod, 1 day in dev). +- Per-platform `post_id` shapes: + - **Bluesky**: `PostResult.post_id` = cid, `post_url` = `at://...` URI. The `at://` URI is what `app.bsky.feed.getPostThread` needs. + - **Mastodon**: `post_id` = status id (string), `post_url` = public URL. The status id is what `GET /api/v1/statuses/:id` needs. + - **Instagram**: `post_id` = media id, `post_url` = generic account URL (not per-post). Media id is what `GET /{media-id}` needs. + +## Proposed Solution + +### Overview + +Three layered changes: + +1. **Schema evolution** of `database.json` to add a new top-level `posts[]` table joined to `posted_pets[]` by `pet_id`, with metric snapshots nested inside each post entry. +2. **New abstractions** (`MetricCollector` ABC, `PostMetrics` dataclass) added to `abstractions.py`. +3. **New `metric_collectors/` package** mirroring `social_posters/`, with one collector per platform, wired into `main.py` to run after posting. + +### Detailed Design + +#### Schema: `database.json` evolution + +Add a new top-level `posts[]` array alongside the existing `posted_pets[]`. `posted_pets[]` is unchanged (pet metadata only). Each post entry carries its own `pet_id` (foreign key), `platform`, `post_id`, `post_url`, `posted_at`, and a nested `metrics[]` time-series: + +```json +{ + "posted_pets": [ + { + "name": "Fido", + "pet_id": "rg-12345", + "posted_at": "2026-05-26T12:00:00+00:00" + } + ], + "posts": [ + { + "pet_id": "rg-12345", + "platform": "Bluesky", + "post_id": "bafyrei...", + "post_url": "at://did:plc:.../app.bsky.feed.post/3k...", + "posted_at": "2026-05-26T12:00:00+00:00", + "metrics": [ + {"collected_at": "2026-05-26T12:00:10+00:00", "likes": 0, "reposts": 0, "comments": 0}, + {"collected_at": "2026-05-26T16:00:10+00:00", "likes": 5, "reposts": 1, "comments": 0} + ] + }, + { + "pet_id": "rg-12345", + "platform": "Mastodon", + "post_id": "...", + "post_url": "...", + "posted_at": "2026-05-26T12:00:00+00:00", + "metrics": [] + } + ] +} +``` + +**Why `posted_at` is duplicated on the post row**: the collector filters posts by age (14d window) every run. Storing `posted_at` on the post row lets the filter run as a direct scan of `posts[]` without joining back to `posted_pets[]`. The few-bytes redundancy is worth the simpler query and matches how the eventual dashboard will consume the data. + +**Backward compat**: existing `database.json` artifacts only have `posted_pets[]`. The new code: + +- Treats missing `posts` key as `[]` (no posts to poll, no-op for collector). +- Existing pets stay as-is — there are no historical `post_id`s to back-fill anyway. They age out naturally after 12 weeks. +- New pets get written to both `posted_pets[]` AND `posts[]` in the same transaction (see [Orchestration](#orchestration-mainpy-changes)). + +No migration script needed. + +**Failed publishes**: if a poster returned `success=False`, no row is appended to `posts[]` for that platform. The collector iterates only what's there. + +**Pruning** (both arrays use their own `posted_at` so prune is two independent filters, no join): + +- `posted_pets[]`: drop entries with `posted_at` older than 12 weeks (existing behavior). +- `posts[]`: drop entries with `posted_at` older than 12 weeks (new, mirrors pet prune so no orphans accumulate). + +Both prunes run on every `record_publish_results` write. The collector never prunes (it only appends). + +#### `abstractions.py` additions + +```python +@dataclass +class PostMetrics: + collected_at: str # ISO8601 UTC + likes: int | None = None + reposts: int | None = None # "reposts" on Bluesky, "reblogs" on Mastodon, "shares" doesn't apply on Insta -> None + comments: int | None = None # "replies" on Bluesky/Mastodon, "comments" on Insta + + +class MetricCollector(ABC): + @property + @abstractmethod + def platform_name(self) -> str: ... + + @abstractmethod + def fetch_metrics(self, post_id: str, post_url: str | None = None) -> PostMetrics | None: + """ + Return a PostMetrics snapshot for the given post. + Return None if the post is unreachable (deleted, 404, transient error). + Caller logs None results and continues — never raises into the caller. + """ + ... +``` + +Notes: + +- `platform_name` must match the corresponding poster's `platform_name` exactly — that's the join key in `database.json`. +- `post_url` is optional in the signature but required for Bluesky (it needs the `at://` URI). Mastodon and Insta can ignore it. +- Returning `None` (not raising) is the contract — a flaky network shouldn't crash a whole run. + +#### Concrete collectors + +One file each under `metric_collectors/`, matching the `social_posters/` layout: + +| File | Class | API call | Counts | +|---|---|---|---| +| `metric_collectors/bluesky.py` | `CollectorBluesky` | `GET /xrpc/app.bsky.feed.getPostThread?uri={post_url}` (public, no auth) | `thread.post.{likeCount, repostCount, replyCount}` | +| `metric_collectors/mastodon.py` | `CollectorMastodon` | `mastodon.status(id)` via the `Mastodon` SDK | `status.{favourites_count, reblogs_count, replies_count}` | +| `metric_collectors/instagram.py` | `CollectorInstagram` | `GET {GRAPH_API_BASE}/{media-id}?fields=like_count,comments_count&access_token=...` | `like_count`, `comments_count`. `reposts` → `None` (no native repost concept) | + +Each collector: + +- Has `platform_name` returning the same string as its paired poster. +- Reads credentials from the same env vars as its paired poster (Insta needs the access token; Bluesky/Mastodon metric endpoints are public-readable so no creds required). +- Returns `None` on any exception, logs to stdout with platform prefix. + +#### Orchestration: `main.py` changes + +**Refactor `pick_pet`**: split the responsibility so `pick_pet` chooses the pet without writing post info; a new `record_publish_results(pet, results)` writes the pet entry *including* `posts` after `run()` has results. + +Current `pick_pet` writes the entry on selection. Move the write to after publishing so we can include post_ids in the same write. Pruning of >12wk entries still happens at write time. + +New shape of `main()`: + +```python +def main(): + # ...existing arg parsing... + try: + sources = create_sources(...) + posters = create_posters(...) + collectors = create_collectors(...) # new + + pets = fetch_all_pets(sources) + pet = pick_pet(pets, database_path="database.json") # no longer writes + if pet: + results = publish(pet, posters) # existing per-poster loop + record_publish_results(pet, results, database_path="database.json") # writes pet + posts + + collect_metrics(collectors, database_path="database.json", window_days=14) # new + except Exception: + notify_slack_of_exception(traceback.format_exc()) + raise +``` + +`collect_metrics()` responsibilities: + +1. Read `database.json`. Treat missing `posts` key as `[]`. +2. Build a `{platform_name: collector}` lookup from the `collectors` list. +3. Compute `cutoff = now_utc - timedelta(days=window_days)`. +4. For each entry in `posts[]` where `posted_at >= cutoff`: + - Look up the collector by `entry["platform"]`. Skip if not registered. + - Call `collector.fetch_metrics(entry["post_id"], entry.get("post_url"))`. + - If non-None, append the snapshot (as a dict with `collected_at` set to now-UTC) to `entry["metrics"]`. +5. Write the file back. **Atomic write**: write to `database.json.tmp` then rename, so a mid-write crash doesn't corrupt the artifact. + +Note: `collect_metrics` only appends to `metrics[]` lists; it never adds or removes rows from `posts[]` or `posted_pets[]`. Pruning is `record_publish_results`'s job. + +`record_publish_results(pet, results, database_path)` responsibilities: + +1. Read `database.json`. Treat missing `posts` key as `[]`. +2. Append to `posted_pets[]`: `{name, pet_id, posted_at}` (same shape as today). +3. For each `result` in `results` where `result.success` is True: + - Append to `posts[]`: `{pet_id, platform, post_id, post_url, posted_at, metrics: []}`. + - `platform` is taken from the matching poster's `platform_name`. (`run()` will need to pass `(poster, result)` pairs, not just results.) +4. Prune both arrays: drop entries with `posted_at` older than 12 weeks. +5. Atomic write via `.tmp` rename. + +`create_collectors(debug=False)`: + +- Mirrors `create_posters`. If debug, returns `[]` (no-op) or a single `CollectorDebug` that just logs. (Lean toward `[]` for simplicity unless we want to exercise the orchestration in dev.) +- Otherwise returns `[CollectorBluesky(), CollectorMastodon(), CollectorInstagram()]`. + +#### File-locking / concurrency + +Not a concern — each GH Actions run is single-process and downloads its own copy of the artifact. No concurrent writers. + +#### GitHub workflow changes + +**None required.** The existing `prod.yml` and `dev.yml` already download `database.json` from the previous run and re-upload it. Metric snapshots ride along. + +Optional polish: bump prod artifact retention from 14 → 30 days so a few weeks of metric history survives even if a run fails. Defer unless we hit the limit in practice. + +#### Error handling + +- A collector exception inside `fetch_metrics` returns `None` and is logged. Never raises. +- `collect_metrics()` wraps the whole loop in try/except per pet/platform — one bad entry doesn't kill the rest. +- `collect_metrics()` itself does NOT raise into `main()`. Metric collection is best-effort; if the artifact write fails, log loudly but don't notify Slack (posting succeeded, metrics are just delayed). + +#### What about deleted/missing posts? + +If a post 404s (user deleted, account suspended), the collector returns `None`. The entry stays in `database.json` with whatever history it has; future runs will keep returning `None` until the pet ages out of the 12-week window. + +Optional: after N consecutive `None` results for the same `(pet_id, platform)`, mark the entry as `unreachable: true` and skip it. Defer — not needed for MVP. + +## Implementation Plan + +Each step independently verifiable. + +1. **Add `PostMetrics` dataclass + `MetricCollector` ABC** to `abstractions.py`. Verify: existing tests still pass; importable. +2. **Refactor `pick_pet`** so it picks but does not write. `pick_pet` returns the chosen `AdoptablePet`; no `database.json` writes happen inside it. Verify: existing tests for pick_pet are updated and still pass; dedup logic (skipping previously-posted `pet_id`s) is preserved by reading from `posted_pets[]` in the new pick-only flow. +3. **Add `record_publish_results(pet, results, database_path)`** that writes the pet entry to `posted_pets[]` and one row per successful publish to `posts[]` (with `metrics: []`). Prunes both arrays at 12 weeks. Update `main.py::run()` to pass `(poster, result)` pairs so platform names are available at write time. Verify: post a pet via dev workflow (`--debugposters`) and inspect `database.json` artifact — `posted_pets[]` has one new entry, `posts[]` has N new entries (one per successful publish). +4. **Create `metric_collectors/` package + 3 concrete collectors** (`bluesky.py`, `mastodon.py`, `instagram.py`). Each implements `fetch_metrics(post_id, post_url=None) -> PostMetrics | None`. Verify: unit tests with mocked HTTP responses for each platform. +5. **Add `collect_metrics()` orchestration** to `main.py` + `create_collectors()` factory. Wire after `run()`. Iterates `posts[]` within the 14-day window, appends snapshots to each post's `metrics[]`. Verify: end-to-end dev run shows `posts[i].metrics` growing by one entry per run. +6. **End-to-end verification** on the dev workflow: trigger `dev.yml`, confirm `database.json` artifact contains `posts[]` with one snapshot in `metrics`. Trigger again and confirm a *second* snapshot was appended to the existing post entries (not a new post row). + +## Testing Strategy + +Unit tests in `tests/`: + +- `tests/test_metric_collector_bluesky.py` — mock `requests.get` for `getPostThread`, assert counts mapped correctly, assert `None` returned on 404 / network error. +- `tests/test_metric_collector_mastodon.py` — mock the `Mastodon` SDK's `status()`, assert mapping + None on exception. +- `tests/test_metric_collector_instagram.py` — mock `requests.get` for the graph media endpoint, assert mapping, assert `reposts is None`. +- `tests/test_collect_metrics_orchestration.py` — fake `database.json` + fake collectors, assert: only `posts[]` entries within 14d window are polled; snapshot appended to the correct entry's `metrics[]`; entries with no matching registered collector are skipped; collector returning `None` doesn't append; missing top-level `posts` key is treated as empty. +- `tests/test_record_publish_results.py` — assert `posted_pets[]` gets one new entry; `posts[]` gets one row per successful publish (none for failed); `metrics: []` initialized empty; 12-week prune applied to both arrays independently. + +Edge cases covered: + +- Post entry with `posted_at` >14d ago → skipped by collector. +- All publishes for a pet failed → `posted_pets[]` gets the entry, `posts[]` gets nothing for that pet (pet still counts for dedup; no metrics ever collected). +- Collector raises → logged, returns None, no snapshot appended, loop continues. +- `database.json` missing or empty → both `pick_pet` and `collect_metrics` no-op gracefully. +- `posts[]` key missing on legacy artifact → treated as `[]`, new writes populate it. +- Mid-write crash → atomic rename via `.tmp` file means previous-known-good file stays intact. +- Pet pruned at 12wk → corresponding `posts[]` rows pruned in the same write (via independent `posted_at` filter, not a join — so even if `posted_at` somehow drifts between pet and post rows, no orphans accumulate). + +Manual / workflow verification: + +- Run `dev.yml` twice and inspect successive `database.json` artifacts for a growing `metrics` list. +- Eyeball one post's metric history vs the same post in the platform UI to spot-check accuracy. + +## Open Questions + +- **`reposts` for Instagram**: stored as `None` (no native concept). Confirm we want `None` rather than `0` — `None` correctly signals "not applicable" vs `0` which means "zero reposts". Going with `None`. +- **Dev workflow collector behavior**: should `create_collectors(debug=True)` return `[]` or a logging-only stub? Defaulting to `[]` for now; revisit if we want orchestration exercised in dev. diff --git a/main.py b/main.py index 21117cf..1d9cd02 100644 --- a/main.py +++ b/main.py @@ -1,22 +1,28 @@ import argparse +from dataclasses import asdict +from datetime import datetime, timedelta, timezone import json +import logging import os +from pathlib import Path +import pprint import random +import sqlite3 import sys import traceback -import logging -import pprint -from datetime import datetime, timedelta, timezone -from pathlib import Path import requests from adoption_sources import SourceManual, SourceRescueGroups +from metric_collectors.bluesky import CollectorBluesky +from metric_collectors.instagram import CollectorInstagram +from metric_collectors.mastodon import CollectorMastodon from social_posters.bluesky import PosterBluesky from social_posters.debug import PosterDebug from social_posters.instagram import PosterInstagram from social_posters.mastodon import PosterMastodon + file_handler = logging.FileHandler("cutepets.log") file_handler.setLevel(logging.DEBUG) console_handler = logging.StreamHandler(sys.stdout) @@ -24,27 +30,28 @@ logging.basicConfig( level=logging.DEBUG, - format='%(asctime)s [%(levelname)s] %(name)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S', + format="%(asctime)s [%(levelname)s] %(name)s - %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", handlers=[file_handler, console_handler], ) logger = logging.getLogger(__name__) + def main(): - - logger.info('Log started') + logger.info("Log started") parser = argparse.ArgumentParser() - parser.add_argument("--debugsources", action="store_true") # this defaults to False - parser.add_argument("--debugposters", action="store_true") # this defaults to False + parser.add_argument("--debugsources", action="store_true") + parser.add_argument("--debugposters", action="store_true") args = parser.parse_args() try: sources = create_sources(debug=args.debugsources) posters = create_posters(debug=args.debugposters) + collectors = create_collectors(debug=args.debugposters) - run(sources, posters) + run(sources, posters, collectors) except Exception: notify_slack_of_exception(traceback.format_exc()) raise @@ -54,31 +61,32 @@ def create_posters(debug=False): if debug: return [PosterDebug()] - posters = [] - posters.append(PosterMastodon()) - posters.append(PosterBluesky()) - posters.append(PosterInstagram()) - return posters + return [PosterMastodon(), PosterBluesky(), PosterInstagram()] +def create_collectors(debug=False): + if debug: + return [] + + return [CollectorBluesky(), CollectorMastodon(), CollectorInstagram()] def create_sources(debug=False): if debug: cat_fixture_path = Path(__file__).parent / "tests" / "fixtures" / "sample_cats.json" - with open(cat_fixture_path) as f: - cat_animals = json.load(f) + with cat_fixture_path.open() as fixture_file: + cat_animals = json.load(fixture_file) return [ SourceManual(species="dog"), SourceManual(species="cat", animals=cat_animals), ] - sources = [] - sources.append(SourceRescueGroups()) - return sources + return [SourceRescueGroups()] + +def run(sources, posters, collectors=None, database_path="database.json", metrics_db_path="metrics.sqlite"): + _init_metrics_db(metrics_db_path) -def run(sources, posters): pets = [] for source in sources: try: @@ -87,65 +95,233 @@ def run(sources, posters): raise SystemExit(str(exc)) from exc logger.info("Fetched %d records", len(pets)) - pet = pick_pet(pets) + pet = pick_pet(pets, database_path=database_path) + results = [] + publish_results = [] + if not pet: logger.error("No pets available to post.") - return [] else: - pet_format = pprint.pformat(pet) - logger.info("Picked pet %s", pet_format) + logger.info("Picked pet %s", pprint.pformat(pet)) - if not posters: - logger.error("No social media credentials set; skipping post.") - return [] + if not posters: + logger.error("No social media credentials set; skipping post.") + else: + for poster in posters: + post = poster.format_post(pet) + result = poster.publish(post) + results.append(result) + publish_results.append((poster, result)) + if not result.success: + logger.error( + "%s post failed: %s", + poster.platform_name, + result.error_message, + ) + else: + logger.info("%s post published.", poster.platform_name) + + record_publish_results(pet, publish_results, database_path=database_path, metrics_db_path=metrics_db_path) + + collect_metrics(collectors or [], database_path=database_path, metrics_db_path=metrics_db_path) + return results - results = [] - for poster in posters: - post = poster.format_post(pet) - result = poster.publish(post) - results.append(result) + +def pick_pet(pets, database_path="database.json"): + data = _read_database(database_path) + posted_pet_ids = { + posted_pet["pet_id"] for posted_pet in data.get("posted_pets", []) + } + eligible = [ + pet + for pet in pets + if pet.image_url + and pet.adoption_url + and pet.pet_id not in posted_pet_ids + ] + if not eligible: + raise ValueError("No eligible pet found") + + return random.choice(eligible) + + +def record_publish_results(pet, results, database_path="database.json", metrics_db_path="metrics.sqlite"): + data = _read_database(database_path) + posted_pets = data.setdefault("posted_pets", []) + posts = data.setdefault("posts", []) + posted_at = datetime.now(timezone.utc).isoformat() + + posted_pets.append( + {"name": pet.name, "pet_id": pet.pet_id, "posted_at": posted_at} + ) + new_posts = [] + for poster, result in results: if not result.success: - logger.error(f"{poster.platform_name} post failed: {result.error_message}") - else: - logger.info(f"{poster.platform_name} post published.") + continue + post_entry = { + "pet_id": pet.pet_id, + "platform": poster.platform_name, + "post_id": result.post_id, + "post_url": result.post_url, + "posted_at": posted_at, + "metrics": [], + } + posts.append(post_entry) + new_posts.append(post_entry) + + cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) + data["posted_pets"] = [ + item + for item in posted_pets + if datetime.fromisoformat(item["posted_at"]) >= cutoff + ] + data["posts"] = [ + item + for item in posts + if datetime.fromisoformat(item["posted_at"]) >= cutoff + ] + _write_database(database_path, data) + + if new_posts: + _upsert_posts_to_db(new_posts, metrics_db_path) + + +def collect_metrics(collectors, database_path="database.json", metrics_db_path="metrics.sqlite", window_days=14): + try: + data = _read_database(database_path) + posts = data.get("posts", []) + if not posts: + return + + _init_metrics_db(metrics_db_path) + _upsert_posts_to_db(posts, metrics_db_path) + + collectors_by_platform = { + collector.platform_name: collector for collector in collectors + } + cutoff = datetime.now(timezone.utc) - timedelta(days=window_days) + updated = False + + with sqlite3.connect(metrics_db_path) as conn: + for entry in posts: + try: + if datetime.fromisoformat(entry["posted_at"]) < cutoff: + continue + + collector = collectors_by_platform.get(entry.get("platform")) + if collector is None: + continue + + metrics = collector.fetch_metrics( + entry["post_id"], entry.get("post_url") + ) + if metrics is None: + continue + + snapshot = asdict(metrics) + collected_at = datetime.now(timezone.utc).isoformat() + snapshot["collected_at"] = collected_at + entry.setdefault("metrics", []).append(snapshot) + updated = True + conn.execute( + """ + INSERT INTO post_metrics (post_id, collected_at, likes, reposts, comments) + VALUES (?, ?, ?, ?, ?) + """, + ( + entry["post_id"], + collected_at, + snapshot.get("likes"), + snapshot.get("reposts"), + snapshot.get("comments"), + ), + ) + except Exception as exc: + platform = entry.get("platform", "unknown platform") + post_id = entry.get("post_id", "unknown post") + logger.error( + "%s metric collection failed for %s: %s", + platform, + post_id, + exc, + ) + + if updated: + _write_database(database_path, data) + except Exception as exc: + logger.error("Metric collection failed: %s", exc) + + +def _init_metrics_db(metrics_db_path): + with sqlite3.connect(metrics_db_path) as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + pet_id TEXT NOT NULL, + platform TEXT NOT NULL, + post_id TEXT NOT NULL UNIQUE, + post_url TEXT NOT NULL, + posted_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS post_metrics ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + post_id TEXT NOT NULL, + collected_at TEXT NOT NULL, + likes INTEGER, + reposts INTEGER, + comments INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (post_id) REFERENCES posts (post_id) + ); + """) + + +def _upsert_posts_to_db(posts, metrics_db_path): + with sqlite3.connect(metrics_db_path) as conn: + conn.executemany( + """ + INSERT INTO posts (pet_id, platform, post_id, post_url, posted_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(post_id) DO UPDATE SET + updated_at = datetime('now') + """, + [ + ( + post["pet_id"], + post["platform"], + post["post_id"], + post["post_url"], + post["posted_at"], + ) + for post in posts + ], + ) + + +def _read_database(database_path): + path = Path(database_path) + if not path.exists() or path.stat().st_size == 0: + return {} - return results + try: + with path.open() as database_file: + return json.load(database_file) + except (json.JSONDecodeError, ValueError) as exc: + logger.error("%s:%s", type(exc).__name__, exc) + traceback.print_exc() + return {} -def pick_pet(pets): - Path("database.json").touch(exist_ok=True) - # Open file - with open("database.json", "r+") as f: - # Load json - try: - data = json.load(f) - except (json.JSONDecodeError, ValueError) as e: - logger.error(f"{type(e).__name__}:{e}") - traceback.print_exc() - data = {} - - if "posted_pets" in data: - posted_pet_ids = {posted_pet["pet_id"] for posted_pet in data["posted_pets"]} - else: - posted_pet_ids = {} - data["posted_pets"] = [] - # Check pet has an image, adoption url, and has not been posted - eligible = [pet for pet in pets if pet.image_url and pet.adoption_url and pet.pet_id not in posted_pet_ids] - if not eligible: - raise ValueError("No elligible pet found") - - selected_pet = random.choice(eligible) - # Add pet ID to list of posted pets - data["posted_pets"].append({"name": selected_pet.name, "pet_id": selected_pet.pet_id, "posted_at": datetime.now(timezone.utc).isoformat()}) - # Remove old pets - cutoff = datetime.now(timezone.utc) - timedelta(weeks=12) - recent_pets = [item for item in data["posted_pets"] if datetime.fromisoformat(item['posted_at']) > cutoff] - data["posted_pets"] = recent_pets - # Export json - f.seek(0) - json.dump(data, f, indent=4) - f.truncate() - return selected_pet +def _write_database(database_path, data): + path = Path(database_path) + temporary_path = path.with_name(f"{path.name}.tmp") + with temporary_path.open("w") as database_file: + json.dump(data, database_file, indent=4) + temporary_path.replace(path) # Slack incoming-webhook messages have a ~40k-char limit; cap the traceback @@ -183,7 +359,7 @@ def notify_slack_of_exception(traceback_text): response = requests.post(webhook_url, json={"text": text}, timeout=10) response.raise_for_status() except Exception as slack_exc: - logger.error(f"Failed to post Slack alert: {slack_exc}") + logger.error("Failed to post Slack alert: %s", slack_exc) if __name__ == "__main__": diff --git a/metric_collectors/__init__.py b/metric_collectors/__init__.py new file mode 100644 index 0000000..a4458e8 --- /dev/null +++ b/metric_collectors/__init__.py @@ -0,0 +1,8 @@ +"""Engagement metric collectors for supported social platforms.""" + +from metric_collectors.bluesky import CollectorBluesky +from metric_collectors.instagram import CollectorInstagram +from metric_collectors.mastodon import CollectorMastodon + + +__all__ = ["CollectorBluesky", "CollectorInstagram", "CollectorMastodon"] diff --git a/metric_collectors/bluesky.py b/metric_collectors/bluesky.py new file mode 100644 index 0000000..703f305 --- /dev/null +++ b/metric_collectors/bluesky.py @@ -0,0 +1,41 @@ +"""Bluesky engagement metric collector.""" + +from datetime import datetime, timezone + +import requests + +from abstractions import MetricCollector, PostMetrics + + +POST_THREAD_URL = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread" + + +class CollectorBluesky(MetricCollector): + @property + def platform_name(self) -> str: + return "Bluesky" + + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + if not post_url: + print(f"Bluesky metric collection failed for {post_id}: post URL missing") + return None + + try: + response = requests.get( + POST_THREAD_URL, + params={"uri": post_url}, + timeout=20, + ) + response.raise_for_status() + post = response.json()["thread"]["post"] + return PostMetrics( + collected_at=datetime.now(timezone.utc).isoformat(), + likes=post.get("likeCount"), + reposts=post.get("repostCount"), + comments=post.get("replyCount"), + ) + except Exception as exc: + print(f"Bluesky metric collection failed for {post_id}: {exc}") + return None diff --git a/metric_collectors/instagram.py b/metric_collectors/instagram.py new file mode 100644 index 0000000..d97b22e --- /dev/null +++ b/metric_collectors/instagram.py @@ -0,0 +1,51 @@ +"""Instagram engagement metric collector.""" + +from datetime import datetime, timezone +import os + +import requests + +from abstractions import MetricCollector, PostMetrics +from social_posters.instagram import GRAPH_API_BASE + + +class CollectorInstagram(MetricCollector): + def __init__(self, access_token=None): + self.access_token = access_token or os.environ.get( + "INSTAGRAM_PAGE_ACCESS_TOKEN" + ) + + @property + def platform_name(self) -> str: + return "Instagram" + + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + if not self.access_token: + print( + f"Instagram metric collection failed for {post_id}: " + "access token missing" + ) + return None + + try: + response = requests.get( + f"{GRAPH_API_BASE}/{post_id}", + params={ + "fields": "like_count,comments_count", + "access_token": self.access_token, + }, + timeout=20, + ) + response.raise_for_status() + media = response.json() + return PostMetrics( + collected_at=datetime.now(timezone.utc).isoformat(), + likes=media.get("like_count"), + reposts=None, + comments=media.get("comments_count"), + ) + except Exception as exc: + print(f"Instagram metric collection failed for {post_id}: {exc}") + return None diff --git a/metric_collectors/mastodon.py b/metric_collectors/mastodon.py new file mode 100644 index 0000000..91138d8 --- /dev/null +++ b/metric_collectors/mastodon.py @@ -0,0 +1,35 @@ +"""Mastodon engagement metric collector.""" + +from datetime import datetime, timezone +import os + +from mastodon import Mastodon + +from abstractions import MetricCollector, PostMetrics + + +class CollectorMastodon(MetricCollector): + def __init__(self, client=None): + api_base_url = os.environ.get( + "MASTODON_API_BASE_URL", "https://mastodon.social" + ) + self._client = client or Mastodon(api_base_url=api_base_url) + + @property + def platform_name(self) -> str: + return "Mastodon" + + def fetch_metrics( + self, post_id: str, post_url: str | None = None + ) -> PostMetrics | None: + try: + status = self._client.status(post_id) + return PostMetrics( + collected_at=datetime.now(timezone.utc).isoformat(), + likes=status.get("favourites_count"), + reposts=status.get("reblogs_count"), + comments=status.get("replies_count"), + ) + except Exception as exc: + print(f"Mastodon metric collection failed for {post_id}: {exc}") + return None diff --git a/tests/test_collect_metrics_orchestration.py b/tests/test_collect_metrics_orchestration.py new file mode 100644 index 0000000..9ffdca5 --- /dev/null +++ b/tests/test_collect_metrics_orchestration.py @@ -0,0 +1,170 @@ +import json +from datetime import datetime, timedelta, timezone +import sqlite3 + +from abstractions import PostMetrics +from main import collect_metrics + + +class FakeCollector: + platform_name = "Bluesky" + + def __init__(self, responses): + self.responses = responses + self.calls = [] + + def fetch_metrics(self, post_id, post_url=None): + self.calls.append((post_id, post_url)) + response = self.responses[post_id] + if isinstance(response, Exception): + raise response + return response + + +def post(post_id, posted_at, platform="Bluesky", metrics=None): + return { + "pet_id": f"pet-{post_id}", + "platform": platform, + "post_id": post_id, + "post_url": f"at://{post_id}", + "posted_at": posted_at, + "metrics": list(metrics or []), + } + + +def snapshot(likes): + return PostMetrics( + collected_at="replaced-by-orchestrator", + likes=likes, + reposts=2, + comments=3, + ) + + +def _get_metrics(metrics_db_path, post_id): + with sqlite3.connect(metrics_db_path) as conn: + return conn.execute( + "SELECT likes, reposts, comments, collected_at FROM post_metrics WHERE post_id = ? ORDER BY id", + (post_id,), + ).fetchall() + + +def test_collects_only_recent_posts_with_registered_collectors(tmp_path): + database_path = tmp_path / "database.json" + metrics_db_path = tmp_path / "metrics.sqlite" + now = datetime.now(timezone.utc) + previous = { + "collected_at": (now - timedelta(hours=1)).isoformat(), + "likes": 1, + "reposts": 0, + "comments": 0, + } + database_path.write_text( + json.dumps( + { + "posted_pets": [], + "posts": [ + post("recent", (now - timedelta(days=1)).isoformat(), metrics=[previous]), + post("none", (now - timedelta(days=2)).isoformat()), + post("old", (now - timedelta(days=15)).isoformat()), + post( + "unknown", + (now - timedelta(days=1)).isoformat(), + platform="Unknown", + ), + ], + } + ) + ) + collector = FakeCollector({"recent": snapshot(9), "none": None}) + + collect_metrics([collector], database_path=database_path, metrics_db_path=metrics_db_path, window_days=14) + + data = json.loads(database_path.read_text()) + assert collector.calls == [("recent", "at://recent"), ("none", "at://none")] + assert data["posts"][0]["metrics"][0] == previous + assert data["posts"][0]["metrics"][1]["likes"] == 9 + assert data["posts"][0]["metrics"][1]["reposts"] == 2 + assert data["posts"][0]["metrics"][1]["comments"] == 3 + collected_at = datetime.fromisoformat( + data["posts"][0]["metrics"][1]["collected_at"] + ) + assert collected_at.tzinfo == timezone.utc + assert data["posts"][1]["metrics"] == [] + assert data["posts"][2]["metrics"] == [] + assert data["posts"][3]["metrics"] == [] + + recent_rows = _get_metrics(metrics_db_path, "recent") + assert len(recent_rows) == 1 + assert recent_rows[0][0] == 9 # likes + assert recent_rows[0][1] == 2 # reposts + assert recent_rows[0][2] == 3 # comments + assert _get_metrics(metrics_db_path, "none") == [] + assert _get_metrics(metrics_db_path, "old") == [] + assert _get_metrics(metrics_db_path, "unknown") == [] + + +def test_missing_database_is_a_noop(tmp_path): + database_path = tmp_path / "database.json" + metrics_db_path = tmp_path / "metrics.sqlite" + + collect_metrics([], database_path=database_path, metrics_db_path=metrics_db_path) + + assert not database_path.exists() + + +def test_missing_posts_key_is_a_noop(tmp_path): + database_path = tmp_path / "database.json" + metrics_db_path = tmp_path / "metrics.sqlite" + original = {"posted_pets": []} + database_path.write_text(json.dumps(original)) + + collect_metrics([], database_path=database_path, metrics_db_path=metrics_db_path) + + assert json.loads(database_path.read_text()) == original + + +def test_collector_error_does_not_stop_other_posts(tmp_path): + database_path = tmp_path / "database.json" + metrics_db_path = tmp_path / "metrics.sqlite" + recent = datetime.now(timezone.utc).isoformat() + database_path.write_text( + json.dumps({"posted_pets": [], "posts": [post("bad", recent), post("good", recent)]}) + ) + collector = FakeCollector( + {"bad": RuntimeError("unreachable"), "good": snapshot(4)} + ) + + collect_metrics([collector], database_path=database_path, metrics_db_path=metrics_db_path) + + data = json.loads(database_path.read_text()) + assert data["posts"][0]["metrics"] == [] + assert data["posts"][1]["metrics"][0]["likes"] == 4 + assert _get_metrics(metrics_db_path, "bad") == [] + assert _get_metrics(metrics_db_path, "good")[0][0] == 4 + + +def test_repeated_collection_appends_snapshots_without_adding_posts(tmp_path): + database_path = tmp_path / "database.json" + metrics_db_path = tmp_path / "metrics.sqlite" + recent = datetime.now(timezone.utc).isoformat() + database_path.write_text( + json.dumps({"posted_pets": [], "posts": [post("recent", recent)]}) + ) + collector = FakeCollector({"recent": snapshot(5)}) + + collect_metrics([collector], database_path=database_path, metrics_db_path=metrics_db_path) + collector.responses["recent"] = snapshot(7) + collect_metrics([collector], database_path=database_path, metrics_db_path=metrics_db_path) + + data = json.loads(database_path.read_text()) + assert len(data["posts"]) == 1 + assert [item["likes"] for item in data["posts"][0]["metrics"]] == [5, 7] + + rows = _get_metrics(metrics_db_path, "recent") + assert len(rows) == 2 + assert [r[0] for r in rows] == [5, 7] + + with sqlite3.connect(metrics_db_path) as conn: + post_count = conn.execute("SELECT COUNT(*) FROM posts").fetchone()[0] + assert post_count == 1 diff --git a/tests/test_main.py b/tests/test_main.py index 5ddae7f..0aaccf4 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,10 +1,13 @@ +import json +from pathlib import Path +import sqlite3 +from tempfile import TemporaryDirectory import unittest -import uuid -from abstractions import AdoptablePet, Post, PostResult +from abstractions import AdoptablePet, Post, PostMetrics, PostResult from adoption_sources import SourceManual from adoption_sources.rescue_groups import SourceRescueGroups -from main import create_posters, create_sources, run +from main import create_collectors, create_posters, create_sources, run class FakeSource: @@ -32,11 +35,31 @@ def format_post(self, pet): def publish(self, post): self.publish_called = True self.posts.append(post) - return PostResult(success=True) + return PostResult( + success=True, + post_id=f"post-{len(self.posts)}", + post_url=f"https://example.com/posts/{len(self.posts)}", + ) + + +class FakeCollector: + platform_name = "FakePoster" + + def __init__(self): + self.calls = [] + + def fetch_metrics(self, post_id, post_url=None): + self.calls.append((post_id, post_url)) + return PostMetrics( + collected_at="set-by-run", + likes=3, + reposts=1, + comments=2, + ) class RunFlowTests(unittest.TestCase): - def test_run_calls_source_and_posters(self): + def test_run_calls_source_posters_and_collectors(self): pet = AdoptablePet( name="Poppy", species="dog", @@ -44,13 +67,26 @@ def test_run_calls_source_and_posters(self): location="Boston, MA", image_url="https://example.com/poppy.jpg", adoption_url="https://example.com/adopt/poppy", - pet_id=f"test-poppy-{uuid.uuid4()}", + pet_id="pet-poppy", ) source = FakeSource([pet]) poster_one = FakePoster() poster_two = FakePoster() - - results = run([source], [poster_one, poster_two]) + collector = FakeCollector() + + with TemporaryDirectory() as temporary_directory: + database_path = Path(temporary_directory) / "database.json" + metrics_db_path = Path(temporary_directory) / "metrics.sqlite" + results = run( + [source], + [poster_one, poster_two], + collectors=[collector], + database_path=database_path, + metrics_db_path=metrics_db_path, + ) + data = json.loads(database_path.read_text()) + with sqlite3.connect(metrics_db_path) as conn: + rows = conn.execute("SELECT likes FROM post_metrics").fetchall() self.assertTrue(source.fetch_called) self.assertTrue(poster_one.format_called) @@ -58,6 +94,11 @@ def test_run_calls_source_and_posters(self): self.assertTrue(poster_two.format_called) self.assertTrue(poster_two.publish_called) self.assertEqual(len(results), 2) + self.assertEqual(len(data["posted_pets"]), 1) + self.assertEqual(len(data["posts"]), 2) + self.assertEqual(len(collector.calls), 2) + self.assertEqual(data["posts"][0]["metrics"][0]["likes"], 3) + self.assertEqual(rows[0][0], 3) def test_run_with_mixed_species_pool(self): dog = AdoptablePet( @@ -67,7 +108,7 @@ def test_run_with_mixed_species_pool(self): location="Boston, MA", image_url="https://example.com/rex.jpg", adoption_url="https://example.com/adopt/rex", - pet_id=f"test-dog-{uuid.uuid4()}", + pet_id="test-dog", ) cat = AdoptablePet( name="Luna", @@ -76,16 +117,26 @@ def test_run_with_mixed_species_pool(self): location="Boston, MA", image_url="https://example.com/luna.jpg", adoption_url="https://example.com/adopt/luna", - pet_id=f"test-cat-{uuid.uuid4()}", + pet_id="test-cat", ) source = FakeSource([dog, cat]) poster = FakePoster() - results = run([source], [poster]) + with TemporaryDirectory() as temporary_directory: + database_path = Path(temporary_directory) / "database.json" + results = run( + [source], + [poster], + collectors=[], + database_path=database_path, + ) + data = json.loads(database_path.read_text()) self.assertTrue(poster.format_called) self.assertTrue(poster.publish_called) self.assertEqual(len(results), 1) + self.assertEqual(len(data["posted_pets"]), 1) + self.assertIn(data["posted_pets"][0]["pet_id"], {"test-dog", "test-cat"}) class CreateSourcesTests(unittest.TestCase): @@ -113,6 +164,8 @@ def test_debug_returns_debug_poster(self): self.assertEqual(len(posters), 1) self.assertEqual(posters[0].platform_name, "Debug") + def test_debug_disables_metric_collectors(self): + self.assertEqual(create_collectors(debug=True), []) if __name__ == "__main__": diff --git a/tests/test_metric_collector_bluesky.py b/tests/test_metric_collector_bluesky.py new file mode 100644 index 0000000..2942f55 --- /dev/null +++ b/tests/test_metric_collector_bluesky.py @@ -0,0 +1,50 @@ +from unittest.mock import Mock, patch + +import requests + +from metric_collectors.bluesky import CollectorBluesky, POST_THREAD_URL + + +class TestCollectorBluesky: + @patch("metric_collectors.bluesky.requests.get") + def test_maps_post_thread_counts(self, mock_get): + response = Mock() + response.json.return_value = { + "thread": { + "post": {"likeCount": 8, "repostCount": 3, "replyCount": 2} + } + } + mock_get.return_value = response + + metrics = CollectorBluesky().fetch_metrics( + "cid-123", "at://did:plc:abc/app.bsky.feed.post/xyz" + ) + + assert metrics.likes == 8 + assert metrics.reposts == 3 + assert metrics.comments == 2 + mock_get.assert_called_once_with( + POST_THREAD_URL, + params={"uri": "at://did:plc:abc/app.bsky.feed.post/xyz"}, + timeout=20, + ) + response.raise_for_status.assert_called_once_with() + + @patch("metric_collectors.bluesky.requests.get") + def test_returns_none_on_http_error(self, mock_get): + response = Mock() + response.raise_for_status.side_effect = requests.HTTPError("not found") + mock_get.return_value = response + + metrics = CollectorBluesky().fetch_metrics( + "cid-123", "at://did:plc:abc/app.bsky.feed.post/missing" + ) + + assert metrics is None + + @patch("metric_collectors.bluesky.requests.get") + def test_returns_none_when_post_url_is_missing(self, mock_get): + metrics = CollectorBluesky().fetch_metrics("cid-123") + + assert metrics is None + mock_get.assert_not_called() diff --git a/tests/test_metric_collector_instagram.py b/tests/test_metric_collector_instagram.py new file mode 100644 index 0000000..d8d4e72 --- /dev/null +++ b/tests/test_metric_collector_instagram.py @@ -0,0 +1,48 @@ +from unittest.mock import Mock, patch + +import requests + +from metric_collectors.instagram import CollectorInstagram +from social_posters.instagram import GRAPH_API_BASE + + +class TestCollectorInstagram: + @patch("metric_collectors.instagram.requests.get") + def test_maps_media_counts_and_marks_reposts_not_applicable(self, mock_get): + response = Mock() + response.json.return_value = {"like_count": 13, "comments_count": 5} + mock_get.return_value = response + + metrics = CollectorInstagram(access_token="token").fetch_metrics("media-123") + + assert metrics.likes == 13 + assert metrics.reposts is None + assert metrics.comments == 5 + mock_get.assert_called_once_with( + f"{GRAPH_API_BASE}/media-123", + params={ + "fields": "like_count,comments_count", + "access_token": "token", + }, + timeout=20, + ) + response.raise_for_status.assert_called_once_with() + + @patch("metric_collectors.instagram.requests.get") + def test_returns_none_on_http_error(self, mock_get): + response = Mock() + response.raise_for_status.side_effect = requests.HTTPError("not found") + mock_get.return_value = response + + metrics = CollectorInstagram(access_token="token").fetch_metrics( + "media-123" + ) + + assert metrics is None + + @patch("metric_collectors.instagram.requests.get") + def test_returns_none_without_access_token(self, mock_get): + metrics = CollectorInstagram(access_token="").fetch_metrics("media-123") + + assert metrics is None + mock_get.assert_not_called() diff --git a/tests/test_metric_collector_mastodon.py b/tests/test_metric_collector_mastodon.py new file mode 100644 index 0000000..6cc8463 --- /dev/null +++ b/tests/test_metric_collector_mastodon.py @@ -0,0 +1,28 @@ +from unittest.mock import Mock + +from metric_collectors.mastodon import CollectorMastodon + + +class TestCollectorMastodon: + def test_maps_status_counts(self): + client = Mock() + client.status.return_value = { + "favourites_count": 11, + "reblogs_count": 4, + "replies_count": 6, + } + + metrics = CollectorMastodon(client=client).fetch_metrics("status-123") + + assert metrics.likes == 11 + assert metrics.reposts == 4 + assert metrics.comments == 6 + client.status.assert_called_once_with("status-123") + + def test_returns_none_on_sdk_error(self): + client = Mock() + client.status.side_effect = RuntimeError("mastodon unavailable") + + metrics = CollectorMastodon(client=client).fetch_metrics("status-123") + + assert metrics is None diff --git a/tests/test_record_publish_results.py b/tests/test_record_publish_results.py new file mode 100644 index 0000000..5295185 --- /dev/null +++ b/tests/test_record_publish_results.py @@ -0,0 +1,138 @@ +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace + +from abstractions import AdoptablePet, PostResult +from main import pick_pet, record_publish_results + + +def make_pet(pet_id="pet-123", name="Poppy"): + return AdoptablePet( + name=name, + species="dog", + breed="mutt", + location="Boston, MA", + image_url="https://example.com/poppy.jpg", + adoption_url="https://example.com/adopt/poppy", + pet_id=pet_id, + ) + + +def test_pick_pet_preserves_dedup_without_writing(tmp_path): + database_path = tmp_path / "database.json" + database_path.write_text( + json.dumps( + { + "posted_pets": [ + { + "name": "Already posted", + "pet_id": "old-pet", + "posted_at": datetime.now(timezone.utc).isoformat(), + } + ] + } + ) + ) + original_contents = database_path.read_text() + + selected = pick_pet( + [make_pet("old-pet", "Old"), make_pet("new-pet", "New")], + database_path=database_path, + ) + + assert selected.pet_id == "new-pet" + assert database_path.read_text() == original_contents + + +def test_pick_pet_does_not_create_a_missing_database(tmp_path): + database_path = tmp_path / "database.json" + + selected = pick_pet([make_pet()], database_path=database_path) + + assert selected.pet_id == "pet-123" + assert not database_path.exists() + + +def test_records_pet_and_one_post_per_successful_publish(tmp_path): + database_path = tmp_path / "database.json" + pet = make_pet() + bluesky = SimpleNamespace(platform_name="Bluesky") + mastodon = SimpleNamespace(platform_name="Mastodon") + + record_publish_results( + pet, + [ + ( + bluesky, + PostResult( + success=True, + post_id="cid-123", + post_url="at://did:plc:abc/app.bsky.feed.post/xyz", + ), + ), + ( + mastodon, + PostResult(success=False, error_message="publish failed"), + ), + ], + database_path=database_path, + ) + + data = json.loads(database_path.read_text()) + assert data["posted_pets"][0]["pet_id"] == "pet-123" + assert data["posts"] == [ + { + "pet_id": "pet-123", + "platform": "Bluesky", + "post_id": "cid-123", + "post_url": "at://did:plc:abc/app.bsky.feed.post/xyz", + "posted_at": data["posted_pets"][0]["posted_at"], + "metrics": [], + } + ] + assert not database_path.with_name("database.json.tmp").exists() + + +def test_records_pet_when_all_publishes_fail(tmp_path): + database_path = tmp_path / "database.json" + poster = SimpleNamespace(platform_name="Mastodon") + + record_publish_results( + make_pet(), + [(poster, PostResult(success=False, error_message="publish failed"))], + database_path=database_path, + ) + + data = json.loads(database_path.read_text()) + assert [pet["pet_id"] for pet in data["posted_pets"]] == ["pet-123"] + assert data["posts"] == [] + + +def test_prunes_old_pets_and_posts_independently(tmp_path): + database_path = tmp_path / "database.json" + old_timestamp = (datetime.now(timezone.utc) - timedelta(weeks=13)).isoformat() + database_path.write_text( + json.dumps( + { + "posted_pets": [ + {"name": "Old pet", "pet_id": "old-pet", "posted_at": old_timestamp} + ], + "posts": [ + { + "pet_id": "different-old-pet", + "platform": "Bluesky", + "post_id": "old-post", + "post_url": "at://old", + "posted_at": old_timestamp, + "metrics": [], + } + ], + } + ) + ) + + record_publish_results(make_pet(), [], database_path=database_path) + + data = json.loads(database_path.read_text()) + assert [pet["pet_id"] for pet in data["posted_pets"]] == ["pet-123"] + assert data["posts"] == []