diff --git a/DESIGN-oo-rewrite.md b/DESIGN-oo-rewrite.md new file mode 100644 index 0000000..82f6640 --- /dev/null +++ b/DESIGN-oo-rewrite.md @@ -0,0 +1,441 @@ +# Spond OO rewrite — design + +**Status:** implementation complete on branch `feat/oo-rewrite`; under review +**Last updated:** 2026-05-14 +**Scope:** First-class typed objects with ActiveRecord behaviour, replacing the dict-based return surface across `spond.spond.Spond` and `spond.club.SpondClub`. v2.0 also delivers the symmetric write surface (`Event.save()`/`delete()`), an exception hierarchy, natural-key equality, convenience properties, and async context-manager support. + +## Feedback welcome + +This document captured the design as it was brainstormed, and has been kept in sync with what shipped. The shape, deprecation path, and inventory in this file now reflect the implementation merged onto `feat/oo-rewrite`. The "Open questions" section near the end lists what was deliberately deferred to a follow-up release. + +- **For high-level concerns** (API shape, scope, deprecation path) — open an issue titled `OO rewrite: …` or comment on the tracking PR. +- **For deeper design questions** — see the "Open questions" section near the end. +- **For implementer-facing notes** (field-drift audit, subclass discipline, update-payload rules) — see "Implementation notes for maintainers" at the bottom. + +## Motivation + +The current SDK exposes one big `Spond` class with ~13 methods, all of them taking and returning `dict[str, Any]` (`JSONDict`). Callers must navigate raw dicts (`event["responses"]["acceptedIds"][0]`), there's no validation of API responses, and operations on a single event are scattered across `update_event(uid, ...)`, `change_response(uid, user, ...)`, `get_event_attendance_xlsx(uid)`. + +The OO rewrite addresses three things at once: + +1. **Self-contained objects.** Operations on an event live on the event: `event.update(...)`, `event.change_response(...)`, `event.attendance_xlsx()`. +2. **Typed navigation.** `group.members[i].guardians[j].first_name` works with autocomplete and type-checker support. +3. **Validation on construction.** Pydantic raises loudly if Spond's API drifts, instead of silently passing through unexpected shapes. + +## Decisions locked during brainstorming + +| Decision | Choice | Why | +|---|---|---| +| OO shape | **ActiveRecord** (instance owns operations) | Closest to "self-contained, well-behaved"; smaller refactor than Manager pattern; keeps `Spond.get_event(uid)` etc. on the client | +| Migration | **Side-by-side with deprecation** | Old method-on-Spond surface stays, emits `DeprecationWarning`, gets removed in a future major bump | +| Data-class tech | **Pydantic v2** | Runtime validation + clean snake_case ↔ camelCase aliasing; elliot-100's stale `oo-rewrite` branch already proved this works; ~5MB dep is fine alongside aiohttp | +| Pilot scope | **All types at once** | Half-OO state leaves inter-dependency gaps (e.g., `Group.members` returning Member objects requires Member to also be typed) | +| PR cadence | **Single draft PR** | One coherent change; most of the diff is new files | +| Person hierarchy | **Person base, Member/Guardian derived** | Members and Guardians have different behaviour: members are invited and respond to events; guardians manage a child member and may respond on their behalf | + +## Type inventory + +Every typed model extends `DictCompatModel`, which itself extends Pydantic's `BaseModel`. The dict-compatibility shim and the `LenientDate` annotated type live in `spond/_compat.py`; everything below inherits from it. + +``` +Person (base, DictCompatModel) + ├─ uid, first_name, last_name, email (optional), profile (optional), + │ phone_number (optional) + ├─ full_name (property) + │ + ├─ Member(Person) + │ ├─ email, date_of_birth (LenientDate — tolerates Spond's malformed + │ │ "2012-03-99"-style values), created_time, guardians, + │ │ role_uids, subgroup_uids, respondent, custom_fields (alias "fields") + │ └─ methods: send_message(text, group_uid) + │ + └─ Guardian(Person) + └─ methods: send_message(text, group_uid) + +Event(DictCompatModel) + ├─ uid, heading, start_time, end_time, type: str (compared against EventType), + │ owners, recipients, responses, comments, behalf_of_uids, ... + │ + ├─ ActiveRecord methods: + │ ├─ save(client=None) — POST `/sponds/` (create) when uid empty, + │ │ POST `/sponds/{uid}` (update) when uid set. + │ │ Mutates self in place with the persisted state. + │ ├─ delete() — DELETE `/sponds/{uid}`, prunes from cache. + │ ├─ update(_updates=None, /, **fields) — returns a new instance + │ │ with the updates applied. + │ ├─ change_response(member_uid, *, accepted, decline_message=None) + │ └─ attendance_xlsx() -> bytes + │ + ├─ Async helpers (resolve uid lists to typed Member/Guardian via the + │ client's group cache; lazy): + │ ├─ accepted_members() + │ ├─ declined_members() + │ ├─ unanswered_members() + │ ├─ waiting_list_members() + │ └─ unconfirmed_members() + │ + ├─ Synchronous convenience properties / methods (pure-Python, no HTTP): + │ ├─ is_past, is_upcoming, duration + │ ├─ has_responded(uid), response_for(uid) + │ └─ url (canonical Spond web URL) + │ + └─ Match(Event) — sports fixtures + ├─ match_info: MatchInfo | None — team/opponent names, scores, HOME/AWAY + └─ Spond.get_events() / get_event() return Match (not plain Event) + when the API record has matchEvent=True. Dispatch lives in + spond.spond._typed_event. + +Responses (sub-object of Event) + ├─ accepted_uids, declined_uids, unanswered_uids, waiting_list_uids, + │ unconfirmed_uids — all list[str] (raw UIDs) + ├─ decline_messages: dict + └─ (no methods; resolution to Member objects requires Group context — + see Open Questions) + +EventType (StrEnum, canonical reference) + └─ AVAILABILITY, EVENT, RECURRING + The `Event.type` field itself stays a `str` so Spond can introduce + new variants without crashing validation; EventType is a typed lookup + for callers writing comparisons. + +Group(DictCompatModel) + ├─ uid, name, members: list[Member], subgroups: list[Subgroup], + │ roles: list[Role], field_defs: list[FieldDef], plus the full set + │ of admin fields surfaced by the live API audit (created_time, + │ member_permissions, guardian_permissions, chat_age_limit, + │ share_contact_info, address_format, ...) + └─ Navigation helpers (synchronous, no HTTP): + ├─ find_member(*, email=None, name=None, uid=None) -> Member | None + ├─ member_by_uid(uid) / role_by_uid(uid) / subgroup_by_uid(uid) + │ — return the typed object by uid, or None + └─ members_by_subgroup(subgroup_or_uid) / + members_by_role(role_or_uid) + — return list[Member] filtered by membership/role + (accept either the typed object or its uid string). + (`from_api` wires `_client` through nested Members/Guardians) + +Subgroup, Role, FieldDef (DictCompatModel) + └─ uid, name (passive data, no methods). + FieldDef.uid pairs with Member.custom_fields keys so callers can + render human-readable label/value pairs for custom data. + +Profile(DictCompatModel) + └─ uid, first_name, last_name, plus the live-audited extras (passive) + +Post(DictCompatModel) + ├─ uid, title, body, timestamp, group_uid, owner_uid, + │ subgroup_uids, visibility, comments: list[Comment], reactions, … + └─ ActiveRecord methods: + ├─ save(client=None) — POST `/posts/` (create) when uid empty, + │ POST `/posts/{uid}` (update) when uid set. + │ Mutates self in place with the persisted state. + ├─ delete() — DELETE `/posts/{uid}`, prunes from cache. + └─ add_comment(text) — POST `/posts/{uid}/comments`, returns the + new `Comment` and appends to self.comments. + +Comment(DictCompatModel) — typed sub-object of Post and Event + ├─ uid, from_profile_uid, timestamp, text, reactions + └─ (no methods; comment edit/delete endpoints aren't exposed by the + consumer API. Comment lifecycle goes through the parent — + `post.add_comment(text)`.) + +Chat(DictCompatModel) + ├─ uid, name, type, participants, newest_timestamp, unread, muted, + │ community, message: Message | None + └─ methods: send(text) -> dict + (Routes through the chat-server host and chat-auth token; lazy + handshake on first call via the existing `Spond._login_chat`.) + +Message(DictCompatModel) — sub-object of Chat (most-recent message only) + ├─ chat_id, msg_num, type: str, timestamp, reactions, text, user + └─ type-specific optional payload fields: new_name (RENAME), images (IMAGES), + internal_promo (INTERNAL_PROMO), campaign (CAMPAIGN), spond (SPOND). + Anything Spond adds later passes through extra="allow". + +Comment — deferred to a follow-up (still applies) + └─ `Post.comments` exposes them as raw dicts (`list[dict[str, Any]]`). + +Transaction(DictCompatModel) + └─ uid, paid_at, payment_name, paid_by_name (passive, Spond Club only; + uses extra="allow" like every other top-level type for forward-compat). +``` + +Each typed model with operations carries a Pydantic `PrivateAttr` for the Spond/SpondClub client: + +```python +_client: Any = PrivateAttr(default=None) +``` + +Construction sites set this via a `from_api(data, client)` classmethod. `PrivateAttr` keeps it out of `model_dump()` and pdoc. Passive types (Subgroup, Role, Profile, Responses, MatchInfo) omit the client since they don't issue HTTP themselves. + +## Identity / equality + +`DictCompatModel.__eq__` and `__hash__` are driven by a `_natural_key()` hook each typed model overrides. The default uses `(entity_kind, uid)` where `entity_kind` walks the MRO to find the closest user-defined ancestor before `DictCompatModel` — so `Match("X") == Event("X")` (both resolve to kind `"Event"`), and same for `Member`/`Guardian` → `"Person"`. + +When `uid` is absent (a freshly-constructed instance not yet persisted), each model provides a fallback derived from user-visible fields: + +| Entity | Fallback key | +|-------------|-------------------------------------------| +| Event | heading + start_time | +| Group | name | +| Person/Member/Guardian | full_name + email | +| Profile | full_name | +| Post | title + timestamp | +| Chat | name + type | +| Transaction | paid_at + payment_name + paid_by_name | +| Subgroup | name | +| Role | name | +| Message | (chat_id, msg_num) — no uid at all | + +This makes typed instances usable as set members and dict keys following the ORM "same uid → same entity" convention. For callers who need the pre-OO field-by-field equality (e.g. "has the server-side state changed?"), `model_equals(other)` returns the Pydantic default. + +## Exception hierarchy + +``` +SpondError (base) +├── AuthenticationError (login failures) +├── SpondAPIError, ValueError (HTTP failures; carries status/body/url) +└── SpondNotFoundError, KeyError (lookup-by-id failures) + ├── EventNotFoundError + ├── GroupNotFoundError + ├── PersonNotFoundError + └── ChatNotFoundError +``` + +`SpondAPIError` multi-inherits from `ValueError`; `*NotFoundError` types multi-inherit from `KeyError`. Pre-OO `except KeyError:` and `except ValueError:` patterns keep working. + +`AuthenticationError` lives in `spond.exceptions` but is re-exported from `spond.__init__`, so `from spond import AuthenticationError` keeps working. + +## Async context manager + +`Spond` and `SpondClub` are async context managers via `_SpondBase.__aenter__` / `__aexit__`. The idiomatic shape: + +```python +async with Spond(username, password) as s: + events = await s.get_events() +# clientsession closed automatically on exit, even if the body raised +``` + +`__aexit__` wraps the close in `contextlib.suppress(RuntimeError)` so a caller that manually closed the session inside the `with` block doesn't trigger a second-close that masks the original control flow. + +## Backward compatibility + +### Dict-subscript shim + +`DictCompatModel` (in `spond/_compat.py`) gives every typed model dict-like behaviour: + +- `event["heading"]` works, emits `DeprecationWarning` +- `event["startTimestamp"]` works (alias-aware), emits `DeprecationWarning` +- `event.get("heading", default)` works, emits warning +- `"heading" in event` works +- `for key in event` iterates the API-field names (camelCase) actually populated on this instance +- `len(event)` returns the same count as the iterator +- `keys()` / `values()` / `items()` mirror dict semantics, scoped to populated fields + +Implementation: the base class reads `cls.model_fields` to discover both the Python attribute name and the alias, then routes subscript access through to attribute access. The "what's actually present" view is built from `model_fields_set ∪ __pydantic_extra__` so iteration and `len()` reflect only fields that were populated from the source data — fields sitting at their default values don't leak into the dict-compat surface. + +### Strict-equality test patterns + +A small number of existing tests compare returned objects to raw dicts with `==`: + +```python +assert g == {"id": "ID1", "name": "Event One"} +``` + +These need adapting to one of: + +```python +assert g.uid == "ID1" and g.heading == "Event One" +# or +assert g.model_dump(by_alias=True) == {"id": "ID1", "heading": "Event One", ...} +``` + +This is part of the PR (one test class affected, ~5 assertions). + +### Legacy write methods + +`Spond.update_event`, `Spond.change_response`, `Spond.get_event_attendance_xlsx` stay in v1.x — they emit `DeprecationWarning` pointing at the new method, then delegate internally. `Spond.send_message` is **not** deprecated: it remains the entrypoint for sending a one-off message to a user (the chat-thread send is exposed on `Chat.send(text)` for callers already holding a `Chat` object). + +```python +async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: + warnings.warn( + "Spond.update_event is deprecated; use Event.update() instead", + DeprecationWarning, + stacklevel=2, + ) + event = await self.get_event(uid) + return await event.update(**updates) +``` + +The three deprecated wrappers are removed in v3.0 (or a later v2.x), after callers have had a grace period to migrate. + +## Spond.get_* return-type changes + +Every `get_*` method now returns typed objects; names and signatures are unchanged: + +| Method | Before | After | +|---|---|---| +| `get_profile()` | `JSONDict` | `Profile` | +| `get_groups()` | `list[JSONDict] \| None` | `list[Group] \| None` | +| `get_group(uid)` | `JSONDict` | `Group` | +| `get_person(user)` | `JSONDict` | `Person` (concretely Member or Guardian) | +| `get_events(...)` | `list[JSONDict] \| None` | `list[Event] \| None` (`Match` for match events) | +| `get_event(uid)` | `JSONDict` | `Event` or `Match` | +| `get_posts(...)` | `list[JSONDict] \| None` | `list[Post] \| None` | +| `get_messages(...)` | `list[JSONDict] \| None` | `list[Chat] \| None` | +| `SpondClub.get_transactions(...)` | `list[JSONDict]` | `list[Transaction]` | + +Dict-style consumers still work through `DictCompatModel` (with warning). + +## Open questions / follow-up + +Items deferred from this PR, tracked here as roadmap candidates. + +1. **`Group.invite_member()` / `Group.save()`.** Group management (inviting members, editing group settings) is read-only. The invitation flow in particular goes through `/invites` rather than `/groups/{uid}/members` and needs careful probing. +2. **Guardian.managed_member back-link.** Not yet exposed. Guardians are currently constructed inside `Member.guardians`; a post-hoc parent reference can be added if a downstream caller asks for it. +3. **Full chat history.** `Chat.message` only carries the most-recent message; the chat API has additional endpoints for older messages that aren't modelled yet. +4. **Comment edit / delete.** Spond's app supports editing and deleting your own comments, but the corresponding endpoints aren't yet probed/modelled. `post.add_comment(text)` ships in v2.0; the lifecycle endpoints (`PUT /posts/{uid}/comments/{cuid}`, `DELETE /posts/{uid}/comments/{cuid}` or similar) need verification. + +All four remain answerable with live API probing against a real Spond account. + +## What shipped in v2.0 (previously open) + +These items were "open questions" in earlier revisions of this document and have since landed: + +- **`Event.accepted_members()` and siblings** — async helpers resolving each `responses.*_uids` list to typed `Member`/`Guardian` objects via the client's group cache (lazy fetch when empty). Five variants: `accepted_members`, `declined_members`, `unanswered_members`, `waiting_list_members`, `unconfirmed_members`. +- **`Event.save()` and `Event.delete()`** — symmetric ActiveRecord write surface. `save()` dispatches on `self.uid` (create vs update) and mutates self in place; `delete()` issues DELETE and prunes the cache. Endpoints verified live: POST `/sponds/` for create, DELETE `/sponds/{uid}` for delete. The `recipients` field (which is in `_EVENT_READ_ONLY_FIELDS` for the update path) is allowed through on create since Spond requires it. +- **Custom exception hierarchy** — `SpondError` base; `EventNotFoundError`/`GroupNotFoundError`/`PersonNotFoundError`/`ChatNotFoundError` (all also `KeyError`); `SpondAPIError` (also `ValueError`). Pre-OO `except KeyError:` / `except ValueError:` patterns preserved. +- **Natural-key equality** — `__eq__`/`__hash__` driven by `_natural_key()` on each typed model. uid-based when set, else user-visible-field fallback (heading+start_time for Event, etc.). `model_equals()` provides the old field-by-field shape for callers that need it. +- **Convenience properties on Event** — `is_past`, `is_upcoming`, `duration`, `has_responded(uid)`, `response_for(uid)`. +- **Async context manager on `Spond` / `SpondClub`** — `async with Spond(...) as s:` closes the aiohttp session automatically. +- **`Post.save()` / `Post.delete()` / `Post.add_comment()`** — symmetric write surface on Post, mirroring Event's. Endpoints verified live: POST `/posts/` for create, POST `/posts/{uid}` for update, DELETE `/posts/{uid}`, POST `/posts/{uid}/comments` for add_comment. `add_comment` returns a typed `Comment` and appends it to `post.comments` in place. +- **Typed `Comment` model** — `Post.comments` and `Event.comments` now contain `list[Comment]` instead of `list[dict]`. Same forward-compat (`extra="allow"`) and resilience-default discipline as the other types. + +## Files + +**New:** +- `spond/_compat.py` — `DictCompatModel`, `LenientDate`, natural-key equality machinery +- `spond/event.py` — `Event`, `Responses`, `EventType`, `_EVENT_READ_ONLY_FIELDS`, `save()`/`delete()` write surface, member-resolution helpers, convenience properties +- `spond/exceptions.py` — `SpondError` and the typed-exception hierarchy +- `spond/match.py` — `Match` (Event subclass), `MatchInfo` +- `spond/person.py` — `Person`, `Member`, `Guardian` +- `spond/group.py` — `Group` with `find_member()`, `member_by_uid()`, `role_by_uid()`, `subgroup_by_uid()`, `members_by_subgroup()`, `members_by_role()` +- `spond/subgroup.py` — `Subgroup` +- `spond/role.py` — `Role` +- `spond/field_def.py` — typed `FieldDef` (replaces the raw extras dict on `Group.field_defs`) +- `spond/profile.py` — `Profile` +- `spond/post.py` — `Post` with `save()`/`delete()`/`add_comment()` ActiveRecord surface +- `spond/comment.py` — typed `Comment` model (used by `Post.comments` and `Event.comments`) +- `spond/chat.py` — `Chat`, `Message` + +**Changed:** +- `spond/__init__.py` — re-exports the typed exception hierarchy alongside `AuthenticationError` +- `spond/base.py` — `_SpondBase.__aenter__` / `__aexit__` for async context manager support +- `spond/spond.py` — `get_*` methods return typed objects; legacy write methods get deprecation wrappers; `_typed_event` dispatches Event vs. Match; raise sites use typed exceptions +- `spond/club.py` — `Transaction` model added; `get_transactions` returns `list[Transaction]` +- `pyproject.toml` — `pydantic = ">=2.0"` added to runtime deps +- `README.md` — examples updated to OO style + async-with shape + +**Tests:** +The previous monolithic `tests/test_spond.py` has been split by domain. The current layout: +- `tests/conftest.py` — shared fixtures and constants +- `tests/test_auth.py` — login flow + `require_authentication` decorator metadata +- `tests/test_backward_compat.py` — regression guards for pre-OO patterns (dict access, `except KeyError:`/`ValueError:`, top-level `AuthenticationError` import, deprecated wrappers) +- `tests/test_club.py` — `Transaction` and `SpondClub.get_transactions()` +- `tests/test_comment.py` — typed `Comment` model, materialisation on Post/Event +- `tests/test_compat.py` — `DictCompatModel` shim + `LenientDate` +- `tests/test_context_manager.py` — `async with Spond(...)` shape +- `tests/test_event_convenience.py` — `is_past`/`is_upcoming`/`duration`/`response_for`/`has_responded` +- `tests/test_event_members.py` — `accepted_members()` and siblings +- `tests/test_event_save_delete.py` — ActiveRecord write surface (`save()` create/update, `delete()`) +- `tests/test_events.py` — `get_event` / `get_events` HTTP path, deprecated wrappers, OO `Event` methods, `Match` subclass +- `tests/test_exceptions.py` — exception hierarchy + raise-site coverage +- `tests/test_export.py` — deprecated `get_event_attendance_xlsx` wrapper +- `tests/test_groups.py` — `get_group` + Group → Member → Guardian navigation + `get_person` +- `tests/test_group_navigation_helpers.py` — `member_by_uid`, `role_by_uid`, `subgroup_by_uid`, `members_by_subgroup`, `members_by_role`, typed `FieldDef` +- `tests/test_identity.py` — natural-key equality / hashing across all models +- `tests/test_messaging.py` — `Spond.send_message` + `Chat`/`Message` +- `tests/test_post_save_delete.py` — ActiveRecord write surface on Post (`save()`, `delete()`, `add_comment()`) +- `tests/test_posts.py` — `get_posts` query construction, caching, error surfacing + +## Out of scope + +- Removing `self.events_update` (a pre-existing latent attribute that was already cleaned up before this PR). +- Adding new HTTP endpoints. This is a re-shaping of the existing surface only. +- Renaming any `Spond.get_*` method — name stability matters more than name perfection. + +## Test plan (what shipped) + +- All pre-existing tests pass (with strict-equality assertions adapted). +- ActiveRecord methods: HTTP-mocked tests asserting URL + payload + return value (`tests/test_events.py`, `tests/test_messaging.py`, `tests/test_export.py`). +- `DictCompatModel` shim: subscript works, warning fires, alias-mapped subscripts work, `__len__`/`__contains__`/`__iter__` agree (`tests/test_compat.py`). +- Inter-dep navigation: `group.members[0]` is a `Member`, `member.guardians[0]` is a `Guardian`, etc. (`tests/test_groups.py`). +- Subclass identity through update: `Match.update(...)` returns a `Match`, not a demoted `Event` (`tests/test_events.py::TestMatch::test_match_update_preserves_match_type`). +- Forward-compat: unmodelled fields survive a roundtrip via `extra="allow"` and are reachable through `__pydantic_extra__` (`tests/test_compat.py`). +- Manual smoke test of `examples/manual_test_functions.py` against the live API, plus the periodic field-drift audit (see implementation notes). + +## Versioning + +This work lands as **v2.0** — a major bump even though backward +compatibility is preserved through the deprecation cycle. The +rationale: + +- **New equality semantics.** Two `Event(uid="X")` instances now + compare equal regardless of field state — pre-v2.0 they did not. + The `model_equals()` escape hatch preserves the old shape for + callers who need it, but the default operator changed, and that's + a behavioural break. +- **Substantial new surface.** Typed write methods (`save()`/ + `delete()`), exception hierarchy, member-resolution helpers, + convenience properties, and async context-manager support are all + net-new — calling this a "minor" feels wrong. +- **Return-type changes.** Every `Spond.get_*` method now returns + typed objects instead of `dict[str, Any]` — softened by the + dict-compat shim, but technically a breaking type change for any + caller using static type checking. + +The plan: + +- **v2.0 (this release)** — ship everything described in this + document, with full backward compatibility: `DictCompatModel` shim + emits `DeprecationWarning` on dict-style access; `*NotFoundError` + multi-inherits from `KeyError`; `SpondAPIError` multi-inherits + from `ValueError`; deprecated wrappers (`Spond.update_event`/ + `change_response`/`get_event_attendance_xlsx`) still present and + delegating with `DeprecationWarning`. +- **v2.x (later)** — start removing the compat shims behind feature + flags or env vars, so callers can opt into the v3.0 surface early + and find any code that still relies on the dict path. +- **v3.0** — remove the deprecation cycle: drop the dict-compat + shim entirely, drop the multi-inheritance from `KeyError`/ + `ValueError` on the typed exceptions, remove the three deprecated + wrapper methods. + +Callers running v2.x with no deprecation warnings will have a +zero-change upgrade to v3.0. + +## Implementation notes for maintainers + +### Periodic API field-drift audit + +Spond keeps adding fields to its responses. The original SDK reverse-engineering captured what was visible at the time, but several models had accumulated gaps by the time the OO rewrite landed (Profile was missing 4 fields, Group was missing 17, Responses was missing 1). Two-thirds of those gaps were invisible to ordinary use because `extra="allow"` silently preserves unknown fields — they were stored on `__pydantic_extra__` but not surfaced in pdoc, attribute autocompletion, or static type-checking. + +A periodic audit closes the gap. The technique is mechanical: + +1. Authenticate a live `Spond` client against a real account. +2. For each modelled endpoint, fetch the raw response and compute `set(api_keys) - set(model_field_aliases)`. +3. Add any newly-discovered fields to the model with sentinel defaults (so the model stays resilient when the field eventually disappears too), with API aliases preserved, and a one-line docstring categorising each (user-facing vs internal). + +`Spond.get_*` methods naturally surface the data needed for the audit, and `.model_fields[name].alias or name` enumerates the model's expected key set. Re-run before each minor release, or whenever Spond ships a noticeable app update. + +### Subclass identity in `Event.update()` + +`Event.update()` builds the next instance via `type(self).from_api(new_data, self._client)` — **not** the literal `Event.from_api`. This preserves subclass identity for the `Match` subclass (and any future subclasses). Without `type(self)`, a `Match.update(...)` call returns a plain `Event`, the cache replacement loop writes the demoted instance, and subsequent `spond.get_event(uid)` silently serves the wrong type. Regression test: `TestMatch.test_match_update_preserves_match_type`. + +### Read-only field policy on Event + +`spond/event.py` defines `_EVENT_READ_ONLY_FIELDS` — a frozenset of Python field names that `Event.update()` strips from the POST payload before sending. The list mirrors the pre-OO `_EVENT_TEMPLATE` writable scope (with reasoning grouped by category: server-managed timestamps, derived flags, series wiring, nested sub-resources with their own endpoints). When adding new fields to `Event`, decide at declaration time whether they should be writable on update, and add them to the frozenset if not. `Match.match_info` deliberately does **not** appear in this set — score updates flow through `Event.update()`. + +### Update-payload discipline: `exclude_unset=True` + +`Event.update()` dumps with `exclude_unset=True` (in addition to `exclude_none=True` and the read-only frozenset). This is the critical guard against round-tripping defaulted state back to Spond as authoritative. Pydantic's `model_fields_set` tracks exactly which fields were populated during validation — so a field that defaulted to `[]` (e.g. `owners`, `attachments`) or `None` because Spond's GET response didn't include it gets correctly excluded from the subsequent POST. Without this, calling `event.update(heading="X")` on an event whose source response omitted `owners` would send `"owners": []`, and Spond could interpret an explicit empty list as "clear all owners." Caller-supplied updates are overlaid on top of the dump, so explicit modifications always reach Spond regardless of source-data presence. diff --git a/README.md b/README.md index d6273f4..70d5453 100644 --- a/README.md +++ b/README.md @@ -7,30 +7,177 @@ Simple, unofficial library with some example scripts to access data from the [Sp `pip install spond` +### ⚠️ Upgrading to v2.0 — read this first + +v2.0 is the OO-rewrite release. The `get_*` methods now return typed +Pydantic models (`Event`, `Group`, `Member`, `Post`, `Chat`, …) instead +of raw `dict`s. **Existing code that uses dict-style access keeps +working** through a `DeprecationWarning` shim — but a few things did +change. Before upgrading from 1.x: + +- **Equality semantics changed.** `Event(uid="X") == Event(uid="X")` + now compares natural keys (uid-based when present) rather than every + field. Two instances with the same uid but different field state are + now considered equal. Callers depending on the old "are these + field-identical?" behaviour can use the new `obj.model_equals(other)` + escape hatch. +- **Return types of every `Spond.get_*` method changed** from + `JSONDict` / `list[JSONDict]` to typed objects. Static type checkers + flag this; the runtime dict shim covers most code at runtime. +- **HTTP error class changed** from bare `ValueError` to `SpondAPIError` + — which still inherits from `ValueError`, so `except ValueError:` is + unaffected. Same for the `*NotFoundError` family (still `KeyError`). +- **Some deprecated wrappers will be removed in v3.x.** + `Spond.update_event()`, `Spond.change_response()`, and + `Spond.get_event_attendance_xlsx()` emit `DeprecationWarning` in v2.x; + use `Event.update()`, `Event.change_response()`, and + `Event.attendance_xlsx()` instead. + +**Pin to `< 2.0.0` if you aren't ready to upgrade yet:** + +```shell +pip install "spond<2.0.0" +``` + +Or in `pyproject.toml`: +```toml +[tool.poetry.dependencies] +spond = "<2.0.0" +``` + +Or `requirements.txt`: +``` +spond<2.0.0 +``` + +**Audit your code before upgrading** by running with deprecation +warnings promoted to errors — every dict-style access site lights up +so you can migrate it: + +```shell +python -W error::DeprecationWarning your_script.py +``` + +The full migration story (semantics, write surface, exception +hierarchy, async context manager, etc.) is in +[`DESIGN-oo-rewrite.md`](DESIGN-oo-rewrite.md). + ## Usage You need a username and password from Spond ### Example code -``` +```python import asyncio from spond import spond username = 'my@mail.invalid' password = 'Pa55worD' -group_id = 'C9DC791FFE63D7914D6952BE10D97B46' # fake +group_id = 'C9DC791FFE63D7914D6952BE10D97B46' # fake async def main(): - s = spond.Spond(username=username, password=password) - group = await s.get_group(group_id) - print(group['name']) - await s.clientsession.close() + async with spond.Spond(username=username, password=password) as s: + group = await s.get_group(group_id) + print(group.name) + for member in group.members: + print(f" {member.full_name}") + for guardian in member.guardians: + print(f" guardian: {guardian.full_name}") asyncio.run(main()) +``` + +> **Typed objects from v2.0 onwards.** `get_groups()`, `get_event()`, +> `get_posts()`, etc. now return typed `Group` / `Event` / `Post` objects +> with attribute access and per-instance methods. Existing dict-style +> access (`group["name"]`) still works with a `DeprecationWarning` +> through the v2.x line; the shim is removed in v3.0. See +> [`DESIGN-oo-rewrite.md`](DESIGN-oo-rewrite.md) for the full design and +> migration story. + +### Working with the typed objects + +```python +async with spond.Spond(username, password) as s: + # Read: typed instances with attribute access + event = await s.get_event(uid) + print(event.heading, event.start_time, event.duration) + + # Convenience properties — synchronous, no HTTP + if event.is_upcoming and not event.has_responded(my_uid): + print("you haven't responded yet") + + # Resolve response uids to typed Member/Guardian objects + for member in await event.accepted_members(): + print(f" ✓ {member.full_name}") + + # Update via kwargs (returns a new instance) + new_event = await event.update(heading="Renamed") + + # ActiveRecord-style write surface — same shape for Event and Post + # (requires: from spond.event import Event; from spond.post import Post) + new_event = Event(heading="My new event", + start_time=start, end_time=end, type="EVENT", + owners=[{"id": my_pid, "response": "accepted"}], + recipients={"group": {"id": group_id}}) + await new_event.save(client=s) # POST → uid populated; cache updated + assert new_event.uid + + new_event.description = "Some details" + await new_event.save() # mutate-in-place, then re-save + + await new_event.delete() # DELETE → pruned from cache + + # Posts work the same way, with `add_comment` as a bonus: + post = Post(uid="", type="PLAIN", group_uid=group_id, + title="Hello", body="Welcome.") + await post.save(client=s) + comment = await post.add_comment("First!") + assert comment.uid and comment.text == "First!" + await post.delete() +``` +### Identity / equality + +Typed instances use natural-key equality so they behave correctly in +sets and as dict keys: + +```python +a = await s.get_event(uid) +b = await s.get_event(uid) +assert a == b # same uid → equal, even if state differs +assert {a, b} == {a} # dedups via __hash__ + +# Match is a subclass of Event; same uid → same entity +assert isinstance(match, Event) +assert match == event_with_same_uid ``` +For callers who need the old field-by-field comparison (e.g. "has the +server state changed?"), use `model_equals(other)`. + +### Exception hierarchy + +```python +from spond import ( + SpondError, # base — catch this for any SDK error + AuthenticationError, # login failures + EventNotFoundError, # also a KeyError, for backward compat + GroupNotFoundError, # also a KeyError + PersonNotFoundError, # also a KeyError + SpondAPIError, # HTTP failures; also a ValueError +) + +try: + event = await s.get_event(uid) +except EventNotFoundError: + ... +``` + +Pre-OO `except KeyError:` / `except ValueError:` patterns continue to +work — the typed exceptions multi-inherit from the stdlib classes. + ## Key methods ### get_groups() diff --git a/examples/attendance.py b/examples/attendance.py index 08340cd..3054282 100644 --- a/examples/attendance.py +++ b/examples/attendance.py @@ -1,3 +1,10 @@ +"""Per-event attendance CSVs for organisers. + +Uses the v2.x typed-object surface throughout — attribute access, +typed `event.responses`, the `Event.response_for(uid)` convenience +property, and the `_resolve_uids_to_persons()`-based member helpers. +""" + import argparse import asyncio import csv @@ -35,91 +42,55 @@ async def main() -> None: - session = spond.Spond(username=username, password=password) - events = await session.get_events(min_start=args.f, max_start=args.t) - EXPORT_DIRPATH.mkdir(exist_ok=True) - - for e in events: - base_filename = _sanitise_filename(f"{e['startTimestamp']}-{e['heading']}") - filepath = EXPORT_DIRPATH / f"{base_filename}.csv" - with filepath.open("w", newline="") as csvfile: - spamwriter = csv.writer( - csvfile, delimiter=",", quotechar='"', quoting=csv.QUOTE_MINIMAL - ) - - spamwriter.writerow( - ["Start", "End", "Description", "Name", "Answer", "Organizer"] - ) - for o in e["owners"]: - name = await _derive_member_name(session, o["id"]) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - o["response"], - "X", - ] + async with spond.Spond(username=username, password=password) as session: + events = await session.get_events(min_start=args.f, max_start=args.t) + EXPORT_DIRPATH.mkdir(exist_ok=True) + + for e in events: + base_filename = _sanitise_filename(f"{e.start_time}-{e.heading}") + filepath = EXPORT_DIRPATH / f"{base_filename}.csv" + with filepath.open("w", newline="") as csvfile: + writer = csv.writer( + csvfile, + delimiter=",", + quotechar='"', + quoting=csv.QUOTE_MINIMAL, ) - if args.a is True: - for r in e["responses"]["acceptedIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "accepted", - ] - ) - for r in e["responses"]["declinedIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "declined", - ] - ) - for r in e["responses"]["unansweredIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "unanswered", - ] - ) - for r in e["responses"]["unconfirmedIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( - [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], - name, - "unconfirmed", - ] - ) - for r in e["responses"]["waitinglistIds"]: - name = await _derive_member_name(session, r) - spamwriter.writerow( + writer.writerow( + ["Start", "End", "Description", "Name", "Answer", "Organizer"] + ) + + # Organisers first (event.owners is list[dict] — + # individual owner shape isn't a typed model in v2.x). + for o in e.owners: + name = await _derive_member_name(session, o.get("id", "")) + writer.writerow( [ - e["startTimestamp"], - e["endTimestamp"], - e["heading"], + e.start_time, + e.end_time, + e.heading, name, - "waitinglist", + o.get("response", ""), + "X", ] ) - await session.clientsession.close() + if args.a: + # Each response bucket gets its own pass — using the + # typed `event.responses` instead of dict subscripts. + buckets = ( + ("accepted", e.responses.accepted_uids), + ("declined", e.responses.declined_uids), + ("unanswered", e.responses.unanswered_uids), + ("unconfirmed", e.responses.unconfirmed_uids), + ("waitinglist", e.responses.waiting_list_uids), + ) + for status, uids in buckets: + for uid in uids: + name = await _derive_member_name(session, uid) + writer.writerow( + [e.start_time, e.end_time, e.heading, name, status] + ) async def _derive_member_name(spond_session, member_id: str) -> str: @@ -128,7 +99,7 @@ async def _derive_member_name(spond_session, member_id: str) -> str: person = await spond_session.get_person(member_id) except KeyError: return member_id - return f"{person['firstName']} {person['lastName']}" + return person.full_name def _sanitise_filename(input_str: str) -> str: diff --git a/examples/groups.py b/examples/groups.py index 909273c..3611ed4 100644 --- a/examples/groups.py +++ b/examples/groups.py @@ -1,5 +1,11 @@ +"""Dump each group's full JSON representation to a per-group file. + +Uses the v2.x typed-object surface. `group.model_dump_json(by_alias=True)` +serialises a typed `Group` instance back to JSON in Spond's wire shape — +the equivalent of the raw dict the pre-OO API returned. +""" + import asyncio -import json from pathlib import Path from config import password, username @@ -10,23 +16,19 @@ async def main() -> None: - s = spond.Spond(username=username, password=password) - groups = await s.get_groups() + async with spond.Spond(username=username, password=password) as s: + groups = await s.get_groups() or [] + EXPORT_DIRPATH.mkdir(exist_ok=True) + keepcharacters = (" ", ".", "_") for group in groups: - name = group["name"] - data = json.dumps(group, indent=4, sort_keys=True) - keepcharacters = (" ", ".", "_") base_filename = "".join( - c for c in name if c.isalnum() or c in keepcharacters + c for c in group.name if c.isalnum() or c in keepcharacters ).rstrip() json_filepath = EXPORT_DIRPATH / f"{base_filename}.json" print(json_filepath) - with json_filepath.open("w") as out_file: - out_file.write(data) - - await s.clientsession.close() + json_filepath.write_text(group.model_dump_json(by_alias=True, indent=4)) loop = asyncio.new_event_loop() diff --git a/examples/ical.py b/examples/ical.py index 7c85f48..23ab2e4 100644 --- a/examples/ical.py +++ b/examples/ical.py @@ -1,4 +1,9 @@ #!/usr/bin/env python3 +"""Generate an .ics calendar file from your Spond events. + +Demonstrates the v2.x typed-object surface — attribute access throughout, +and `async with Spond(...)` for automatic session cleanup. +""" import asyncio from pathlib import Path @@ -12,37 +17,39 @@ async def main() -> None: - s = spond.Spond(username=username, password=password) - c = Calendar() - c.method = "PUBLISH" - events = await s.get_events() + async with spond.Spond(username=username, password=password) as s: + events = await s.get_events() + EXPORT_DIRPATH.mkdir(exist_ok=True) ics_filepath = EXPORT_DIRPATH / "spond.ics" + c = Calendar() + c.method = "PUBLISH" + for event in events: e = Event() - e.uid = event["id"] - e.name = event["heading"] - # Match events expose two start times: `startTimestamp` is the - # kickoff, while `meetupTimestamp` is when participants are expected + e.uid = event.uid + e.name = event.heading + # Match events expose two start times: `start_time` is the + # kickoff, while `meetup_time` is when participants are expected # to arrive (Norwegian: "oppmøtetid"). Training events only have - # `startTimestamp`. We prefer the meet-up time so calendar + # `start_time`. We prefer the meet-up time so calendar # subscribers see when to show up, and fall back to kickoff. - e.begin = event.get("meetupTimestamp", event["startTimestamp"]) - e.end = event["endTimestamp"] - e.sequence = event["updated"] - e.description = event.get("description") - if "cancelled" in event and event["cancelled"]: + e.begin = event.meetup_time or event.start_time + e.end = event.end_time + e.sequence = event.updated + e.description = event.description + if event.cancelled: e.status = "Cancelled" - if "location" in event: - e.location = f"{event['location'].get('feature')}, {event['location'].get('address')}" + if event.location: + e.location = ( + f"{event.location.get('feature')}, {event.location.get('address')}" + ) c.events.add(e) with ics_filepath.open("w") as out_file: out_file.writelines(c) - await s.clientsession.close() - loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) diff --git a/examples/manual_test_functions.py b/examples/manual_test_functions.py index 962c7de..3d0885c 100644 --- a/examples/manual_test_functions.py +++ b/examples/manual_test_functions.py @@ -1,18 +1,25 @@ """Use Spond 'get' functions to summarise available data. -Intended as a simple end-to-end test for assurance when making changes, where there are -gaps in test suite coverage. +Intended as a simple end-to-end test for assurance when making changes, +where there are gaps in test suite coverage. -Doesn't yet use `get_person(user)` or any `send_`, `update_` methods. +Uses the v2.x typed-object surface throughout. Doesn't yet exercise +`get_person(user)` or any `send_` / save / delete methods — those +have dedicated test coverage in the suite. """ import asyncio import tempfile from config import club_id, password, username -from spond import club, spond, JSONDict -DUMMY_ID = "DUMMY_ID" +from spond import club, spond +from spond.chat import Chat +from spond.club import Transaction +from spond.event import Event +from spond.group import Group +from spond.post import Post +from spond.profile import Profile MAX_EVENTS = 10 MAX_CHATS = 10 @@ -21,114 +28,108 @@ async def main() -> None: - - # SPOND - s = spond.Spond(username=username, password=password) - - # Profile - print("\nGetting profile...") - profile = await s.get_profile() - print(_profile_summary(profile)) - - # Groups - print("\nGetting all groups...") - groups = await s.get_groups() - print(f"{len(groups)} groups:") - for i, group in enumerate(groups): - print(f"[{i}] {_group_summary(group)}") - - # Events - print(f"\nGetting up to {MAX_EVENTS} events...") - events = await s.get_events(max_events=MAX_EVENTS) - print(f"{len(events)} events:") - for i, event in enumerate(events): - print(f"[{i}] {_event_summary(event)}") - - # Chats (messages) - print(f"\nGetting up to {MAX_CHATS} chats...") - chats = await s.get_messages(max_chats=MAX_CHATS) - print(f"{len(chats)} chats:") - for i, chat in enumerate(chats): - print(f"[{i}] {_chat_summary(chat)}") - - # Posts - print(f"\nGetting up to {MAX_POSTS} posts...") - posts = await s.get_posts(max_posts=MAX_POSTS) - print(f"{len(posts)} posts:") - for i, post in enumerate(posts): - print(f"[{i}] {_post_summary(post)}") - - # Attendance export - print("\nGetting attendance report for the first event...") - e = events[0] - data = await s.get_event_attendance_xlsx(e["id"]) - with tempfile.NamedTemporaryFile( - mode="wb", - suffix=".xlsx", - delete=False, - ) as temp_file: - temp_file.write(data) - print(f"Check out {temp_file.name}") - - await s.clientsession.close() - - # SPOND CLUB - sc = club.SpondClub(username=username, password=password) - print(f"\nGetting up to {MAX_TRANSACTIONS} transactions...") - - # Transactions - transactions = await sc.get_transactions( - club_id=club_id, - max_items=MAX_TRANSACTIONS, - ) - print(f"{len(transactions)} transactions:") - for i, t in enumerate(transactions): - print(f"[{i}] {_transaction_summary(t)}") - - await sc.clientsession.close() - - -def _profile_summary(profile: JSONDict) -> str: + # ---------------------------------------------------------------- + # Consumer Spond API + # ---------------------------------------------------------------- + async with spond.Spond(username=username, password=password) as s: + # Profile + print("\nGetting profile...") + profile = await s.get_profile() + print(_profile_summary(profile)) + + # Groups + print("\nGetting all groups...") + groups = await s.get_groups() or [] + print(f"{len(groups)} groups:") + for i, group in enumerate(groups): + print(f"[{i}] {_group_summary(group)}") + + # Events + print(f"\nGetting up to {MAX_EVENTS} events...") + events = await s.get_events(max_events=MAX_EVENTS) or [] + print(f"{len(events)} events:") + for i, event in enumerate(events): + print(f"[{i}] {_event_summary(event)}") + + # Chats (messages) + print(f"\nGetting up to {MAX_CHATS} chats...") + chats = await s.get_messages(max_chats=MAX_CHATS) or [] + print(f"{len(chats)} chats:") + for i, chat in enumerate(chats): + print(f"[{i}] {_chat_summary(chat)}") + + # Posts + print(f"\nGetting up to {MAX_POSTS} posts...") + posts = await s.get_posts(max_posts=MAX_POSTS) or [] + print(f"{len(posts)} posts:") + for i, post in enumerate(posts): + print(f"[{i}] {_post_summary(post)}") + + # Attendance export — exercise the v2.x ActiveRecord method + # `event.attendance_xlsx()` (the deprecated `Spond.get_event_ + # attendance_xlsx()` wrapper still works but emits a + # DeprecationWarning). + if events: + print("\nGetting attendance report for the first event...") + data = await events[0].attendance_xlsx() + with tempfile.NamedTemporaryFile( + mode="wb", + suffix=".xlsx", + delete=False, + ) as temp_file: + temp_file.write(data) + print(f"Check out {temp_file.name}") + + # ---------------------------------------------------------------- + # Spond Club finance API + # ---------------------------------------------------------------- + async with club.SpondClub(username=username, password=password) as sc: + print(f"\nGetting up to {MAX_TRANSACTIONS} transactions...") + transactions = await sc.get_transactions( + club_id=club_id, + max_items=MAX_TRANSACTIONS, + ) + print(f"{len(transactions)} transactions:") + for i, t in enumerate(transactions): + print(f"[{i}] {_transaction_summary(t)}") + + +def _profile_summary(profile: Profile) -> str: return ( - f"id='{profile['id']}', " - f"firstName='{profile['firstName']}, " - f"lastName='{profile['lastName']}'" + f"uid={profile.uid!r}, " + f"first_name={profile.first_name!r}, " + f"last_name={profile.last_name!r}" ) -def _group_summary(group: JSONDict) -> str: - return f"id='{group['id']}', name='{group['name']}'" +def _group_summary(group: Group) -> str: + return f"uid={group.uid!r}, name={group.name!r}, members={len(group.members)}" -def _event_summary(event: JSONDict) -> str: +def _event_summary(event: Event) -> str: return ( - f"id='{event['id']}', " - f"heading='{event['heading']}', " - f"startTimestamp='{event['startTimestamp']}'" + f"uid={event.uid!r}, heading={event.heading!r}, start_time={event.start_time}" ) -def _chat_summary(chat: JSONDict) -> str: - msg_text = chat["message"].get("text", "") +def _chat_summary(chat: Chat) -> str: + msg_text = chat.message.text if chat.message and chat.message.text else "" + msg_ts = chat.message.timestamp if chat.message else None return ( - f"id='{chat['id']}', " - f"timestamp='{chat['message']['timestamp']}', " - f"text={_abbreviate(msg_text, length=64)}" + f"uid={chat.uid!r}, timestamp={msg_ts}, text={_abbreviate(msg_text, length=64)}" ) -def _post_summary(post: JSONDict) -> str: - return ( - f"id='{post['id']}', timestamp='{post['timestamp']}', title='{post['title']}'" - ) +def _post_summary(post: Post) -> str: + return f"uid={post.uid!r}, timestamp={post.timestamp}, title={post.title!r}" -def _transaction_summary(transaction: JSONDict) -> str: +def _transaction_summary(transaction: Transaction) -> str: return ( - f"id='{transaction['id']}', " - f"timestamp='{transaction['paidAt']}', " - f"payment_name='{transaction['paymentName']}', " - f"name={transaction['paidByName']}" + f"uid={transaction.uid!r}, " + f"paid_at={transaction.paid_at}, " + f"payment_name={transaction.payment_name!r}, " + f"paid_by={transaction.paid_by_name!r}" ) @@ -137,7 +138,6 @@ def _abbreviate(text: str, length: int) -> str: escaped_text = repr(text) if len(text) > length: return f"{escaped_text[:length]}[…]" - return escaped_text diff --git a/examples/transactions.py b/examples/transactions.py index 1f47c34..dc9b69a 100644 --- a/examples/transactions.py +++ b/examples/transactions.py @@ -1,3 +1,9 @@ +"""Spond Club transactions CSV export. + +Uses the v2.x typed `Transaction` instances; `model_dump(by_alias=True)` +gives the same row shape the pre-OO dict export produced. +""" + import argparse import asyncio import csv @@ -10,7 +16,9 @@ EXPORT_DIRPATH = Path("./exports") parser = argparse.ArgumentParser( - description="Creates an transactions.csv to keep track of payments accessible on Spond Club" + description=( + "Creates a transactions.csv to keep track of payments accessible on Spond Club" + ) ) parser.add_argument( "-m", @@ -20,30 +28,38 @@ dest="max", default=1000, ) - args = parser.parse_args() async def main() -> None: - s = SpondClub(username=username, password=password) - transactions = await s.get_transactions(club_id=club_id, max_items=args.max) + async with SpondClub(username=username, password=password) as s: + transactions = await s.get_transactions(club_id=club_id, max_items=args.max) + if not transactions: print("No transactions found.") - await s.clientsession.close() return EXPORT_DIRPATH.mkdir(exist_ok=True) csv_filepath = EXPORT_DIRPATH / "transactions.csv" - header = transactions[0].keys() + + # Each Transaction is a Pydantic model — dump with `by_alias=True` + # to get camelCase column names matching Spond's wire shape (the + # same shape the pre-OO dict export used). `model_dump(mode="json")` + # converts datetime fields to ISO strings so csv.DictWriter can + # write them without further conversion. + rows = [ + t.model_dump(by_alias=True, mode="json", exclude_none=True) + for t in transactions + ] + header = sorted({k for row in rows for k in row}) with csv_filepath.open("w", newline="") as file: writer = csv.DictWriter(file, fieldnames=header) writer.writeheader() - for t in transactions: - writer.writerow(t) + for row in rows: + writer.writerow(row) print(f"Collected {len(transactions)} transactions. Written to {csv_filepath}") - await s.clientsession.close() loop = asyncio.new_event_loop() diff --git a/pyproject.toml b/pyproject.toml index f625499..ca50922 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ repository = 'https://github.com/Olen/Spond' [tool.poetry.dependencies] python = ">=3.11" aiohttp = ">=3.8.5" +pydantic = ">=2.0" [tool.poetry.group.dev.dependencies] # Constraint on `python` is required: pdoc's transitive `markdown2` declares diff --git a/spond/__init__.py b/spond/__init__.py index 70bd3ef..048b73d 100644 --- a/spond/__init__.py +++ b/spond/__init__.py @@ -6,27 +6,33 @@ from typing import Any, TypeAlias +# Re-export the public exception surface from the dedicated module so +# `from spond import AuthenticationError` keeps working for pre-OO callers, +# and `from spond import SpondError, EventNotFoundError, ...` works for +# new callers using the typed-exception hierarchy. +from .exceptions import ( + AuthenticationError, + ChatNotFoundError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, + SpondError, + SpondNotFoundError, +) + JSONDict: TypeAlias = dict[str, Any] """Simple alias for type hinting `dict`s that can be passed to/from JSON-handling functions.""" -class AuthenticationError(Exception): - """Raised when login to the Spond API fails. - - Typical causes: - - - Incorrect username/password. - - 2FA enabled on the account (the library does not currently support - Spond's TOTP flow). - - The account has hit Spond's login rate limit (`outOfLoginAttempts`). - - The Spond login API has changed shape and the response no longer - contains an `accessToken`. - - The exception message includes any of the response's whitelisted - diagnostic fields (`error`, `errorKey`, `errorCode`, `message`) so - most error cases are self-explanatory. Other response fields — such - as 2FA challenge tokens and (masked) `phoneNumber` — are intentionally - dropped from the message to avoid leaking them into application logs. - """ - - pass +__all__ = [ + "AuthenticationError", + "ChatNotFoundError", + "EventNotFoundError", + "GroupNotFoundError", + "JSONDict", + "PersonNotFoundError", + "SpondAPIError", + "SpondError", + "SpondNotFoundError", +] diff --git a/spond/_compat.py b/spond/_compat.py new file mode 100644 index 0000000..a0a993e --- /dev/null +++ b/spond/_compat.py @@ -0,0 +1,333 @@ +"""Internal helpers for backward-compatible dict-style access on typed models. + +The pre-OO public API returned raw `dict[str, Any]` from every `get_*` method. +The OO rewrite returns Pydantic models instead. To avoid breaking existing +callers that subscript the result (`event["heading"]`, `event.get("id")`, +`"heading" in event`, …), every typed model inherits from `DictCompatModel`, +which adds dict-style read access. + +A `DeprecationWarning` is emitted from `__getitem__` and `get()` so callers +can find their dict-style sites and migrate to attribute access. The other +dict-compat surface (`__iter__`, `keys`, `values`, `items`, `__len__`, +`__contains__`) does not warn — it's noisier and provides less signal. + +Mutation via subscript is intentionally not supported: the typed models are +read-only at the dict-compat layer, and writes go through the ActiveRecord +methods on each type (`event.update(...)`, `event.change_response(...)`, …). +""" + +from __future__ import annotations + +import warnings +from collections.abc import Iterator +from datetime import date +from typing import Annotated, Any + +from pydantic import BaseModel, BeforeValidator + + +def _parse_date_lenient(value: Any) -> date | None: + """Parse a date string tolerantly — return `None` for unparseable input. + + Spond's API occasionally returns malformed `dateOfBirth` values (e.g. + `'2012-03-99'` with an impossible day). Strict ISO-8601 parsing would + raise; we want callers to keep working with `None` for that field. + """ + if value is None or isinstance(value, date): + return value + if not isinstance(value, str): + return None + try: + return date.fromisoformat(value) + except ValueError: + return None + + +LenientDate = Annotated[date | None, BeforeValidator(_parse_date_lenient)] +"""Type alias for `dateOfBirth`-shaped fields that may contain malformed data. + +Use this in place of `date | None` for any field where Spond's API has been +observed to return values that fail strict ISO-8601 parsing. Unparseable +values become `None` rather than raising `ValidationError`. +""" + + +class DictCompatModel(BaseModel): + """Pydantic base class with dict-style read access for backward compatibility. + + Subscript access (`obj["key"]`) maps either the API-side camelCase alias or + the Python-side snake_case attribute name to the underlying attribute, + emitting a `DeprecationWarning` so callers are nudged toward attribute + access. Other dict-style operations work without warning. + + Subclasses should inherit from this instead of directly from + `pydantic.BaseModel`. + """ + + def _resolve_dict_key(self, key: str) -> str | None: + """Return the Python attribute name for a declared field matching `key`. + + Matches either the field's API alias or its Python name. Resolution + is bounded to `self.__class__.model_fields` — never reaches parent + attributes that aren't declared as fields. Does **not** resolve + keys against `__pydantic_extra__`; callers wanting that should + check `_pydantic_extras()` separately. + """ + for field_name, field_info in self.__class__.model_fields.items(): + if field_info.alias == key or field_name == key: + return field_name + return None + + def _pydantic_extras(self) -> dict[str, Any]: + """Unknown fields preserved by `model_config = extra="allow"`. + + Empty dict for models with `extra="ignore"` (the older config), since + Pydantic discards unknown fields there. + """ + return getattr(self, "__pydantic_extra__", None) or {} + + def _present_api_keys(self) -> list[str]: + """API-shaped key names actually present in the source data. + + Returns declared fields that were set during validation (using + their alias if defined, else Python name) plus any extras + preserved via `extra="allow"`. Mirrors pre-OO dict semantics where + iterating a parsed JSON response yielded only the keys the API + actually sent. + """ + keys: list[str] = [] + present_declared = set(self.model_fields_set) + for field_name, field_info in self.__class__.model_fields.items(): + if field_name in present_declared: + keys.append(field_info.alias or field_name) + keys.extend(self._pydantic_extras().keys()) + return keys + + def __getitem__(self, key: str) -> Any: + field_name = self._resolve_dict_key(key) + if field_name is not None and field_name in self.model_fields_set: + warnings.warn( + f"{self.__class__.__name__}[{key!r}] uses deprecated dict-style " + f"access; use attribute access (`.{field_name}`) instead", + DeprecationWarning, + stacklevel=2, + ) + return getattr(self, field_name) + extras = self._pydantic_extras() + if key in extras: + warnings.warn( + f"{self.__class__.__name__}[{key!r}] accesses an unmodelled " + f"field preserved via extra='allow'; dict-style access is " + f"deprecated — use `obj.{key}` instead", + DeprecationWarning, + stacklevel=2, + ) + return extras[key] + raise KeyError(key) + + def get(self, key: str, default: Any = None) -> Any: + """Dict-style `.get(key, default)` with deprecation warning.""" + try: + return self[key] + except KeyError: + return default + + def __contains__(self, key: object) -> bool: + if not isinstance(key, str): + return False + field_name = self._resolve_dict_key(key) + if field_name is not None and field_name in self.model_fields_set: + return True + return key in self._pydantic_extras() + + def __iter__(self) -> Iterator[str]: # type: ignore[override] + """Yield API-shaped keys for fields actually present in the source data. + + Overrides `pydantic.BaseModel.__iter__` (which yields `(name, value)` + tuples) so `for k in obj` matches dict semantics. Only yields keys + for fields populated during validation plus any extras preserved + via `extra="allow"` — not defaulted fields. + """ + yield from self._present_api_keys() + + def __len__(self) -> int: + return len(self._present_api_keys()) + + def keys(self) -> list[str]: + """Dict-style `.keys()` — API-shaped names of fields present in the source.""" + return list(self._present_api_keys()) + + def values(self) -> list[Any]: + """Dict-style `.values()` — values for fields present in the source.""" + present_declared = set(self.model_fields_set) + result = [ + getattr(self, name) + for name in self.__class__.model_fields + if name in present_declared + ] + result.extend(self._pydantic_extras().values()) + return result + + def items(self) -> list[tuple[str, Any]]: + """Dict-style `.items()` — (api-key, value) pairs for fields present.""" + present_declared = set(self.model_fields_set) + result: list[tuple[str, Any]] = [] + for field_name, field_info in self.__class__.model_fields.items(): + if field_name in present_declared: + key = field_info.alias or field_name + result.append((key, getattr(self, field_name))) + for extra_key, extra_value in self._pydantic_extras().items(): + result.append((extra_key, extra_value)) + return result + + # ----------------------------------------------------------------- + # Identity / equality / hashing + # + # Pydantic's default `__eq__` compares every field; two `Event` + # instances with the same `uid` but slightly different (e.g. + # `updated`) state are considered different. That's the wrong + # semantics for entity types served by a remote API — most callers + # want "same uid → same entity", and want to use Event instances as + # set members or dict keys. + # + # `_natural_key()` is the override hook. Subclasses return a tuple + # of (entity_kind, *identifying_fields). The default uses + # `(class-tree-root.__name__, uid)` when uid is set so `Match("X")` + # and `Event("X")` are equal (Match's MRO walks back to Event). + # When uid is absent (a freshly-constructed instance not yet + # persisted), subclasses provide a fallback natural key derived + # from user-visible fields (e.g. heading + start_time for Event). + # Returning `None` falls back to Pydantic's full-field equality. + # ----------------------------------------------------------------- + + def _natural_key(self) -> tuple | None: + """Return a tuple uniquely identifying this entity, or `None` to + fall back to Pydantic's full-field equality. + + Default implementation: if the instance has a non-empty `uid`, + the key is `(top-level entity class name, uid)`. This makes + `Match(uid="X") == Event(uid="X")` evaluate True — a sensible + outcome since they refer to the same Spond record. + + Subclasses override to provide a natural key for instances + without a uid yet (e.g. an `Event` about to be created): + + ```python + def _natural_key(self) -> tuple | None: + if self.uid: + return ("Event", self.uid) + if self.heading or self.start_time: + return ("Event", None, self.heading, self.start_time) + return None + ``` + """ + uid = getattr(self, "uid", None) + if uid: + return (_entity_kind_of(type(self)), uid) + return None + + def __eq__(self, other: object) -> bool: + """Two-tier equality: + + - **Entity types** (have a natural key — uid, or a user-visible + fallback like heading+start_time): equal iff their natural + keys match. Hashable via the same key. + - **Value types / sub-objects** (no natural key — e.g. + `Responses`, `MatchInfo`): equal iff every declared field + plus extras match. Unhashable (like `dict`/`list`), so they + can't live in sets or dict keys — see `__hash__`. + + Mixed comparisons (entity vs value) return False; cross-class + comparisons return False. + """ + if not isinstance(other, DictCompatModel): + return NotImplemented + a = self._natural_key() + b = other._natural_key() + if a is not None and b is not None: + # Natural-key path — types may differ legitimately (Match + # and Event share entity kind `"Event"`; Member and + # Guardian share `"Person"`). The kind tag is the FIRST + # element of the natural-key tuple, so equality across + # those pairs is preserved while two genuinely different + # entity kinds with the same uid (a hypothetical + # `Event("X")` and `Group("X")`) still compare unequal. + return a == b + if a is None and b is None: + # Value-type comparison: full-field equality, but only + # between same-class instances. Required so parent + # equality (an Event comparing its `responses` sub-object) + # gets the right "are these the same state?" answer. + if type(self) is not type(other): + return False + return BaseModel.__eq__(self, other) + # One side has a natural key and the other doesn't — they're + # different kinds of thing. + return False + + def __hash__(self) -> int: + """Hash via the natural key when set. + + Value-type sub-objects (no natural key — `Responses`, + `MatchInfo`, partially-constructed entities with all + identifying fields empty) raise `TypeError` — same convention + as the Python stdlib (`dict`, `list`, `set` are all unhashable + for the same reason: their content is mutable and they may + carry unhashable values internally). The `__eq__` path above + uses full-field comparison for these, so making them hashable + would require hashing every declared field, and the contained + lists/dicts aren't hashable themselves. + """ + key = self._natural_key() + if key is None: + raise TypeError(f"unhashable type: {type(self).__name__!r}") + return hash(key) + + def model_equals(self, other: object) -> bool: + """Pre-OO escape hatch: full-field equality, ignoring natural keys. + + The default `==` operator on typed models compares natural keys + (uid when set, or user-visible fields), so two `Event` instances + with the same uid but different field state compare equal. That's + usually the right semantics for entity types, but a pre-OO caller + that depended on Pydantic's field-by-field equality (e.g. to + detect "has this event been mutated server-side?") can use this + method instead: + + ```python + if not new_event.model_equals(old_event): + # state actually changed, not just the same record + ... + ``` + + Returns True iff `other` is the same class and every declared + field (plus `__pydantic_extra__`) matches. + """ + if type(self) is not type(other): + return False + return BaseModel.__eq__(self, other) + + +def _entity_kind_of(cls: type) -> str: + """Return the name of the top-most user-defined ancestor in `cls`'s + MRO before `DictCompatModel` — that's the "entity kind." + + For `Match` (which inherits from `Event`), this returns `"Event"` + so `Match` and `Event` instances with the same uid compare equal. + For `Member`/`Guardian` (both inheriting from `Person`), this + returns `"Person"` for the same reason. + """ + # Filter MRO to user-defined spond.* classes, dropping the shared + # DictCompatModel / BaseModel / object roots. The result is ordered + # from most-derived (cls itself) to least-derived — the LAST entry + # is the entity kind (e.g. for Match: [Match, Event] → "Event"). + user_classes = [ + c + for c in cls.__mro__ + if c not in (DictCompatModel, BaseModel, object) + and c.__module__.startswith("spond") + ] + if not user_classes: + return cls.__name__ + # The last one in MRO before DictCompatModel is the root entity kind. + return user_classes[-1].__name__ diff --git a/spond/_event_template.py b/spond/_event_template.py deleted file mode 100644 index 39a03df..0000000 --- a/spond/_event_template.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Module contains template event data, to be used as a base when updating events.""" - -from spond import JSONDict - -_EVENT_TEMPLATE: JSONDict = { - "heading": None, - "description": None, - "spondType": "EVENT", - "startTimestamp": None, - "endTimestamp": None, - "commentsDisabled": False, - "maxAccepted": 0, - "rsvpDate": None, - "location": { - "id": None, - "feature": None, - "address": None, - "latitude": None, - "longitude": None, - }, - "owners": [{"id": None}], - "visibility": "INVITEES", - "participantsHidden": False, - "autoReminderType": "DISABLED", - "autoAccept": False, - "payment": {}, - "attachments": [], - "id": None, - "tasks": { - "openTasks": [], - "assignedTasks": [ - { - "name": None, - "description": "", - "type": "ASSIGNED", - "id": None, - "adultsOnly": True, - "assignments": {"memberIds": [], "profiles": [], "remove": []}, - } - ], - }, -} diff --git a/spond/base.py b/spond/base.py index 16bc1e3..db3dc89 100644 --- a/spond/base.py +++ b/spond/base.py @@ -8,9 +8,12 @@ Not intended to be instantiated directly — use a subclass. """ +from __future__ import annotations + import functools from abc import ABC from collections.abc import Callable +from typing import Self import aiohttp @@ -48,9 +51,50 @@ def __init__(self, username: str, password: str, api_url: str) -> None: self.username = username self.password = password self.api_url = api_url - self.clientsession = aiohttp.ClientSession(cookie_jar=aiohttp.CookieJar()) + # Use ThreadedResolver explicitly instead of aiohttp's c-ares default: + # c-ares allocates a kernel resource (an "AresChannel") per resolver + # instance, and the OS has a hard limit on those. A long-running + # process or a wide test matrix that constructs many short-lived + # `Spond` instances would otherwise hit + # `pycares.AresError: Failed to initialize c-ares channel`. + # ThreadedResolver uses the stdlib synchronous resolver in a thread + # — slightly higher per-lookup overhead, no channel limit. + self.clientsession = aiohttp.ClientSession( + cookie_jar=aiohttp.CookieJar(), + connector=aiohttp.TCPConnector(resolver=aiohttp.ThreadedResolver()), + ) self.token = None + async def __aenter__(self) -> Self: + """Async context-manager entry — returns self. + + Enables the idiomatic `async with Spond(...) as s:` shape so the + underlying aiohttp session is closed cleanly on exit, even if the + body raises: + + ```python + async with Spond(username, password) as s: + events = await s.get_events() + # session closed automatically here + ``` + + Replaces the older `await s.clientsession.close()` cleanup that + every example used to require. + """ + return self + + async def __aexit__(self, exc_type, exc, tb) -> None: + """Async context-manager exit — close the aiohttp client session. + + Checks `clientsession.closed` first so a caller who manually + closed the session inside the `with` block doesn't trigger a + second close. Any genuine `RuntimeError` from `close()` (resource + leak, connector failure, etc.) is allowed to propagate rather + than being silently swallowed — that surface signals a real bug. + """ + if not self.clientsession.closed: + await self.clientsession.close() + @property def auth_headers(self) -> dict: """Headers required for authenticated requests: JSON content-type plus @@ -68,16 +112,31 @@ def require_authentication(func: Callable): @functools.wraps(func) async def wrapper(self, *args, **kwargs): - if not self.token: - try: - await self.login() - except AuthenticationError as e: - await self.clientsession.close() - raise e + await self._ensure_authenticated() return await func(self, *args, **kwargs) return wrapper + async def _ensure_authenticated(self) -> None: + """Trigger `login()` if not yet authenticated. + + Internal helper shared between the `@require_authentication` + decorator (which wraps `Spond.*` methods) and the per-instance + ActiveRecord methods on typed models (`event.save()`, + `post.save()`, etc.) — those route HTTP through `self._client` + but aren't themselves decorated, so they need to trigger the + lazy login themselves. + + On `AuthenticationError`, closes the underlying aiohttp session + before re-raising — same shape as the wrapper. + """ + if not self.token: + try: + await self.login() + except AuthenticationError: + await self.clientsession.close() + raise + async def login(self) -> None: """Authenticate against the Spond API and store the access token on `self.token`. Called automatically by the `require_authentication` diff --git a/spond/chat.py b/spond/chat.py new file mode 100644 index 0000000..418328e --- /dev/null +++ b/spond/chat.py @@ -0,0 +1,179 @@ +"""Typed `Chat` and `Message` models for the in-app chat API. + +Spond's chat lives on a separate host (`self._chat_url`) with a separate +auth token (`self._auth`), set up lazily by `Spond._login_chat`. Each +chat thread carries a single embedded `message` representing the most +recent post in the thread; full message history isn't exposed by the +core API surface. + +The `message.type` field discriminates between several payload shapes: + +| message.type | Sender | Extra payload field | +|-------------------|--------------------|----------------------------| +| `TEXT` | regular user | `text` | +| `IMAGES` | regular user | `images: list` | +| `RENAME` | regular user | `new_name` | +| `SPOND` | regular user | `spond` (event share) | +| `INTERNAL_PROMO` | none (system) | `internal_promo` | +| `CAMPAIGN` | none (system) | `campaign` | + +Common fields (`chat_id`, `msg_num`, `timestamp`, `type`, `reactions`) +are modelled directly; the type-specific extras are declared as +optional fields. Anything Spond adds later passes through `extra="allow"`. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel + +if TYPE_CHECKING: + from .spond import Spond + + +class Message(DictCompatModel): + """A single chat message — almost always the most recent in a `Chat`. + + Only the common header fields (`chat_id`, `msg_num`, `timestamp`, + `type`, `reactions`) are guaranteed across all message variants. + Type-specific extras (`text`, `images`, `new_name`, etc.) are + optional; consumers should branch on `type` before reading them. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + chat_id: str | None = Field(default=None, alias="chatId") + msg_num: int | None = Field(default=None, alias="msgNum") + type: str = "" + """One of `TEXT`, `IMAGES`, `RENAME`, `SPOND`, `INTERNAL_PROMO`, + `CAMPAIGN` (and likely more — Spond extends this set over time; + unknown values pass through unchanged).""" + timestamp: datetime | None = None + reactions: dict[str, Any] = Field(default_factory=dict) + """Emoji-reactions map. Empty for messages without reactions.""" + + # Often present — most user-sent message types carry these + text: str | None = None + user: str | None = None + """Sender's profile UID. Absent on system messages (INTERNAL_PROMO, + CAMPAIGN, etc.).""" + + # Type-specific payloads — declared so they get docs and IDE hints, + # but each is `None` unless the message is of the matching `type`. + new_name: str | None = Field(default=None, alias="newName") + """Set when `type=="RENAME"` — the chat's new title.""" + images: list[Any] = Field(default_factory=list) + """Set when `type=="IMAGES"` — attached image objects (raw).""" + internal_promo: dict[str, Any] | None = Field(default=None, alias="internalPromo") + """Set when `type=="INTERNAL_PROMO"` — Spond's own promotional content.""" + campaign: dict[str, Any] | None = None + """Set when `type=="CAMPAIGN"` — community campaign payload.""" + spond: dict[str, Any] | None = None + """Set when `type=="SPOND"` — embedded event share.""" + + def _natural_key(self) -> tuple | None: + """Messages have no `uid` — identity is `(chat_id, msg_num)`, the + composite key Spond uses to address an individual message.""" + if self.chat_id is not None and self.msg_num is not None: + return ("Message", self.chat_id, self.msg_num) + return None + + +class Chat(DictCompatModel): + """A chat thread — group, direct message, system channel, or campaign. + + Construct via `Spond.get_messages()` — both wire `_client` for you. + Direct instantiation works but `chat.send(...)` will refuse to run + without a client attached. + + Example + ------- + ```python + chats = await spond.get_messages() + for chat in chats: + if chat.unread and chat.message and chat.message.type == "TEXT": + print(f"unread in {chat.name!r}: {chat.message.text!r}") + await chat.send("ack") + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + arbitrary_types_allowed=True, + ) + + uid: str = Field(alias="id") + name: str = "" + type: str = "" + """One of `GROUP`, `DM`, `INTERNAL_PROMO`, `CAMPAIGN` (and similar — + unknown values pass through unchanged).""" + participants: list[str] = Field(default_factory=list) + """Profile UIDs of members participating in this thread.""" + newest_timestamp: datetime | None = Field(default=None, alias="newestTimestamp") + """Timestamp of the most recent message in the thread.""" + unread: bool = False + muted: bool = False + community: dict[str, Any] | None = None + """Community-channel metadata (`{type: "NONE"}` for non-community + chats). Unmodelled — varies by chat kind.""" + message: Message | None = None + """The most recent message in this thread — the only one the chat + list endpoint includes. Full history isn't exposed by this SDK.""" + + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + return f"Chat(uid={self.uid!r}, name={self.name!r}, type={self.type!r})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (name, type) for unsaved threads.""" + if self.uid: + return ("Chat", self.uid) + if self.name or self.type: + return ("Chat", None, self.name, self.type) + return None + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Chat: + """Construct a `Chat` from raw API data and bind the client. + + Used internally by `Spond.get_messages()`. Sets `_client` so the + `send()` method can issue HTTP calls. + """ + instance = cls.model_validate(data) + instance._client = client + return instance + + async def send(self, text: str) -> dict[str, Any]: + """Post a TEXT message to this chat thread. + + Performs the lazy chat-server handshake on first call (same + machinery `Spond.send_message` uses for the `chat_id` path). + + Parameters + ---------- + text : str + Message body. Spond sends it as `type=TEXT`. + + Returns + ------- + dict + The Spond chat API's response for the send operation. + """ + if self._client is None: + raise RuntimeError( + "Chat has no client attached; instantiate via Spond.get_messages()." + ) + if self._client._auth is None: + await self._client._login_chat() + payload = {"chatId": self.uid, "text": text, "type": "TEXT"} + url = f"{self._client._chat_url}/messages" + async with self._client.clientsession.post( + url, json=payload, headers={"auth": self._client._auth} + ) as r: + return await r.json() diff --git a/spond/club.py b/spond/club.py index b5c2121..6b2f0ce 100644 --- a/spond/club.py +++ b/spond/club.py @@ -8,12 +8,56 @@ class for this API and `spond.spond.Spond` for everything else. from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from datetime import datetime +from typing import ClassVar +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel from .base import _SpondBase -if TYPE_CHECKING: - from . import JSONDict + +class Transaction(DictCompatModel): + """A Spond Club payment/transaction record. + + Returned as elements of `SpondClub.get_transactions()`. Conservatively + modelled around the four fields the Spond Club UI typically shows for + every transaction: `id`, `paidAt`, `paymentName`, `paidByName`. All + other fields Spond emits are preserved via `extra="allow"` and remain + accessible both as attributes (`transaction.someExtra`) and through the + dict-compat shim (`transaction["someExtra"]`) until they're explicitly + modelled. Only `uid` is strictly required so an API drift that drops + one of the other fields doesn't crash the entire `get_transactions()` + call. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + paid_at: datetime | None = Field(default=None, alias="paidAt") + payment_name: str = Field(default="", alias="paymentName") + paid_by_name: str = Field(default="", alias="paidByName") + + def __str__(self) -> str: + return ( + f"Transaction(uid={self.uid!r}, payment={self.payment_name!r}, " + f"paid_by={self.paid_by_name!r})" + ) + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (paid_at, payment_name, paid_by_name) + as the natural composite key Spond's UI exposes.""" + if self.uid: + return ("Transaction", self.uid) + if self.paid_at or self.payment_name or self.paid_by_name: + return ( + "Transaction", + None, + self.paid_at, + self.payment_name, + self.paid_by_name, + ) + return None class SpondClub(_SpondBase): @@ -32,11 +76,11 @@ class SpondClub(_SpondBase): from spond import club async def main(): - sc = club.SpondClub(username="me@example.invalid", password="secret") - txs = await sc.get_transactions(club_id="ABCD1234...", max_items=50) - for t in txs: - print(t["paidAt"], t["paymentName"], t["paidByName"]) - await sc.clientsession.close() + async with club.SpondClub(username="me@example.invalid", + password="secret") as sc: + txs = await sc.get_transactions(club_id="ABCD1234...", max_items=50) + for t in txs: + print(t.paid_at, t.payment_name, t.paid_by_name) asyncio.run(main()) ``` @@ -57,12 +101,12 @@ def __init__(self, username: str, password: str) -> None: Spond account password. """ super().__init__(username, password, self._API_BASE_URL) - self.transactions: list[JSONDict] | None = None + self.transactions: list[Transaction] | None = None @_SpondBase.require_authentication async def get_transactions( self, club_id: str, skip: int | None = None, max_items: int = 100 - ) -> list[JSONDict]: + ) -> list[Transaction]: """Retrieve transactions/payments for a Spond Club. Spond's transactions endpoint returns at most 25 records per request, @@ -98,9 +142,10 @@ async def get_transactions( Returns ------- - list[JSONDict] + list[Transaction] All transactions accumulated so far (across recursive page - fetches). Empty list if the club has no transactions. + fetches), as typed `Transaction` instances. Empty list if the + club has no transactions. """ if self.transactions is None: self.transactions = [] @@ -111,15 +156,19 @@ async def get_transactions( async with self.clientsession.get(url, headers=headers, params=params) as r: if r.status == 200: - t = await r.json() - if len(t) == 0: + raw = await r.json() + if len(raw) == 0: return self.transactions - self.transactions.extend(t) + # Validate the whole page first, then extend, so a mid-page + # validation failure can't leave `self.transactions` + # partially populated. + page = [Transaction.model_validate(t) for t in raw] + self.transactions.extend(page) if len(self.transactions) < max_items: return await self.get_transactions( club_id=club_id, - skip=len(t) if skip is None else skip + len(t), + skip=len(raw) if skip is None else skip + len(raw), max_items=max_items, ) diff --git a/spond/comment.py b/spond/comment.py new file mode 100644 index 0000000..13270ce --- /dev/null +++ b/spond/comment.py @@ -0,0 +1,74 @@ +"""Typed `Comment` model — replaces the raw `list[dict]` previously +exposed by `Post.comments` and `Event.comments`. + +Comments are nested children of Posts and Events. They're typed for +attribute access and forward-compat (`extra="allow"`), but carry no +ActiveRecord operations of their own — the write surface lives on +the parent (`post.add_comment(text)`). Spond doesn't expose +comment-edit or comment-delete endpoints in the consumer API. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Comment(DictCompatModel): + """A single comment attached to a `Post` or `Event`. + + Spond emits comments with this stable shape: + + ```json + { + "id": "", + "fromProfileId": "", + "timestamp": "", + "text": "", + "reactions": {} + } + ``` + + All fields except `uid` are optional in the SDK so a future API + drift (e.g. system-generated comments without a `fromProfileId`) + doesn't crash the whole `get_posts()` payload. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + from_profile_uid: str | None = Field(default=None, alias="fromProfileId") + """Profile UID of the comment author. Absent on system-generated + comments (if Spond ever emits any).""" + timestamp: datetime | None = None + text: str = "" + reactions: dict[str, Any] = Field(default_factory=dict) + """Emoji-reactions map keyed by reaction kind. Empty for unreacted + comments. Unmodelled — values vary by Spond release.""" + + def __str__(self) -> str: + ts = self.timestamp.isoformat() if self.timestamp else "?" + snippet = self.text[:40] + ("…" if len(self.text) > 40 else "") + return ( + f"Comment(uid={self.uid!r}, from={self.from_profile_uid!r}, " + f"ts={ts}, text={snippet!r})" + ) + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (from_profile_uid, timestamp, text) + for the rare case of a freshly-constructed comment with no uid yet.""" + if self.uid: + return ("Comment", self.uid) + if self.from_profile_uid or self.timestamp or self.text: + return ( + "Comment", + None, + self.from_profile_uid, + self.timestamp, + self.text, + ) + return None diff --git a/spond/event.py b/spond/event.py new file mode 100644 index 0000000..97bad40 --- /dev/null +++ b/spond/event.py @@ -0,0 +1,765 @@ +"""Typed `Event` model with ActiveRecord-style behaviour. + +`Event` instances are returned from `spond.spond.Spond.get_event()` and +`Spond.get_events()`. Each instance carries a reference back to the Spond +client (`_client`) so its methods can issue HTTP calls without the caller +having to thread the client through. + +The class inherits from `DictCompatModel`, so existing dict-style consumers +(`event["heading"]`, `event["startTimestamp"]`, `event.get("id")`) keep +working with a `DeprecationWarning`. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from enum import StrEnum +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr, ValidationError +from pydantic_core import to_jsonable_python + +from ._compat import DictCompatModel +from .comment import Comment +from .exceptions import SpondAPIError + +if TYPE_CHECKING: + from .spond import Spond + + +class EventType(StrEnum): + """Known canonical values for `Event.type`. + + `Event.type` is typed as `str` rather than `EventType` because Spond may + introduce new event kinds at any time — constraining the field to this + enum would crash validation whenever an unknown value appears. Use these + constants when comparing (`event.type == EventType.RECURRING`) but expect + `event.type` itself to be a string. + """ + + EVENT = "EVENT" + """A one-off event.""" + RECURRING = "RECURRING" + """An occurrence of a recurring event.""" + AVAILABILITY = "AVAILABILITY" + """An availability request (no fixed time).""" + + +class Responses(DictCompatModel): + """The attendance-response lists attached to an `Event`. + + Each list holds raw member UIDs (`str`), not `Member` objects — resolving + them to members requires a `Group` context which an Event doesn't carry + standalone. To get `Member` objects, walk the parent group's `members` + list and filter, or call `await event.accepted_members(spond)` (planned + follow-up). + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + accepted_uids: list[str] = Field(default_factory=list, alias="acceptedIds") + """UIDs of members who accepted the invitation.""" + declined_uids: list[str] = Field(default_factory=list, alias="declinedIds") + """UIDs of members who declined.""" + unanswered_uids: list[str] = Field(default_factory=list, alias="unansweredIds") + """UIDs of members who have not yet responded.""" + waiting_list_uids: list[str] = Field(default_factory=list, alias="waitinglistIds") + """UIDs of members on the waiting list (event is full).""" + unconfirmed_uids: list[str] = Field(default_factory=list, alias="unconfirmedIds") + """UIDs of members whose response needs confirmation.""" + decline_messages: dict[str, dict[str, Any]] = Field( + default_factory=dict, alias="declineMessages" + ) + """Per-member decline-reason map. Keys are member UIDs (subset of + `declined_uids`); each value is a `{profileId, message}` dict + describing who entered the message and what they said.""" + + +# Python field names that `Event.update()` strips from the POST payload. +# The set mirrors the pre-OO `_EVENT_TEMPLATE`'s implicit policy: only +# the curated set of writable fields the template included goes back on +# update. Anything else is read-only, server-managed, derived, or has its +# own dedicated endpoint — sending those back risks Spond treating stale +# local state as authoritative (the most concerning case being +# `responses`, which would clobber concurrent attendance changes). +_EVENT_READ_ONLY_FIELDS = frozenset( + { + # Server-managed identifiers / timestamps + "creator_uid", + "created_time", + "updated", + # Derived / boolean state flags + "expired", + "registered", + "hidden", + "cancelled", + "match_event", + "modified_from_series", + # Series wiring (set when the event is part of a recurrence) + "series_uid", + "series_ordinal", + # Nested objects with their own update paths + "responses", + "recipients", + "comments", + # Behalf-of list: not in the pre-OO writable template + "behalf_of_uids", + } +) + + +class Event(DictCompatModel): + """A Spond event with attached operations. + + Construct via `Spond.get_event(uid)` or as elements of + `Spond.get_events()` — both wire `_client` for you. Don't instantiate + directly unless you also set `_client` (the ActiveRecord methods need + it for HTTP). + + Example + ------- + ```python + event = await spond.get_event(uid) + print(event.heading, event.start_time) + for member_uid in event.responses.accepted_uids: + print(member_uid) + + await event.update(description="Updated description") + await event.change_response(member_uid, accepted=True) + xlsx = await event.attendance_xlsx() + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + arbitrary_types_allowed=True, + # `validate_assignment=True` makes direct attribute assignment + # (e.g. `event.heading = "Renamed"`) run through Pydantic's + # validation pipeline AND update `__pydantic_fields_set__`. + # Without this, mutate-then-save() would silently drop changes + # to fields that weren't in the source payload, since the + # `exclude_unset=True` dump filter wouldn't include them. + validate_assignment=True, + ) + + # Core fields. Only `uid` is truly required for the SDK to be useful + # at all (every method addresses an event by id); the others have + # defaults so the SDK doesn't hard-fail if Spond ever drops a field + # from its response shape. Defaults are deliberately sentinel-ish so + # callers can distinguish "field absent" from "field genuinely empty". + uid: str = Field(alias="id") + heading: str = "" + start_time: datetime | None = Field(default=None, alias="startTimestamp") + end_time: datetime | None = Field(default=None, alias="endTimestamp") + meetup_time: datetime | None = Field(default=None, alias="meetupTimestamp") + """When participants are expected to arrive (Norwegian: \"oppmøtetid\"). + Present on match events and some training events. Falls back to + `start_time` for events that only have a kickoff.""" + created_time: datetime | None = Field(default=None, alias="createdTime") + type: str = "" + """Spond's event-kind string. Common values are listed on `EventType`, + but unknown values pass through unchanged so the SDK doesn't crash if + Spond adds new variants.""" + responses: Responses = Field(default_factory=Responses) + + # Owner / creator metadata + creator_uid: str | None = Field(default=None, alias="creatorId") + owners: list[dict[str, Any]] = Field(default_factory=list) + """Raw owner objects (typed `Owner` class is a possible future refinement).""" + + # Commonly present but treated as optional + description: str | None = None + visibility: str = "INVITEES" + expired: bool = False + hidden: bool = False + cancelled: bool = False + auto_accept: bool = Field(default=False, alias="autoAccept") + auto_reminder_type: str = Field(default="DISABLED", alias="autoReminderType") + participants_hidden: bool = Field(default=False, alias="participantsHidden") + registered: bool = False + comments_disabled: bool = Field(default=False, alias="commentsDisabled") + match_event: bool = Field(default=False, alias="matchEvent") + modified_from_series: bool = Field(default=False, alias="modifiedFromSeries") + + # Series fields (only for recurring events) + series_uid: str | None = Field(default=None, alias="seriesId") + series_ordinal: int | None = Field(default=None, alias="seriesOrdinal") + + # Update timestamp (Spond uses milliseconds since epoch here) + updated: int | None = None + + # Behalf-of (members someone else can respond for) + behalf_of_uids: list[str] = Field(default_factory=list, alias="behalfOfIds") + + # Nested data we keep as raw dicts for now (modelling these is follow-up work) + location: dict[str, Any] | None = None + """Location dict with `address`, `latitude`, `longitude`, etc. Unmodelled for now.""" + recipients: dict[str, Any] | None = None + """Recipients dict with `group`, `profiles`, `guardians`. Unmodelled for now.""" + tasks: dict[str, Any] | None = None + """Tasks dict with `openTasks`, `assignedTasks`. Unmodelled for now.""" + attachments: list[Any] = Field(default_factory=list) + """Attachment objects. Unmodelled for now.""" + comments: list[Comment] = Field(default_factory=list) + """Typed `Comment` instances. Only populated when fetched with + `?includeComments=true` (which `Spond.get_event(uid)` sets by default + for the single-event endpoint).""" + + # Non-serialised reference back to the Spond client for HTTP calls. + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + start = self.start_time.isoformat() if self.start_time else "?" + return f"Event(uid={self.uid!r}, heading={self.heading!r}, start_time={start})" + + def _natural_key(self) -> tuple | None: + """Entity-identity tuple. uid-based when set; otherwise the + `(heading, start_time)` pair lets a freshly-constructed event + compare equal to itself across copies (useful when staging an + event before `Spond.create_event()` is called). + """ + if self.uid: + return ("Event", self.uid) + if self.heading or self.start_time: + return ("Event", None, self.heading, self.start_time) + return None + + @property + def url(self) -> str: + """Web URL of the event (for opening in a browser).""" + return f"https://spond.com/client/sponds/{self.uid}/" + + @property + def is_past(self) -> bool: + """True when the event has finished (or started, if no `end_time`). + + An event with no `start_time` is never "past" (the API hasn't + committed it to a calendar slot yet) — returns False. + """ + # Prefer end_time; fall back to start_time when end isn't set. + reference = self.end_time or self.start_time + if reference is None: + return False + return reference < datetime.now(UTC) + + @property + def is_upcoming(self) -> bool: + """True when the event hasn't started yet. Opposite face of + `is_past`, but **not** strictly its negation — an event with no + `start_time` returns False for both.""" + if self.start_time is None: + return False + return self.start_time > datetime.now(UTC) + + @property + def duration(self) -> timedelta | None: + """`end_time - start_time` when both are present; otherwise `None`. + + Returns a `datetime.timedelta`. Useful for calendar sync, slot + comparison, and reporting. + """ + if self.start_time is None or self.end_time is None: + return None + return self.end_time - self.start_time + + def response_for(self, member_uid: str) -> str | None: + """Return the response status of `member_uid` on this event. + + Returns one of `"accepted"`, `"declined"`, `"unanswered"`, + `"waiting_list"`, `"unconfirmed"` — or `None` if the uid doesn't + appear in any of the response lists (i.e. not invited). + + Synchronous; reads from the already-populated `responses` field. + Doesn't issue HTTP. Pair with `await event.accepted_members()` + and siblings to resolve uids → typed members. + """ + if not self.responses: + return None + for status, uids in ( + ("accepted", self.responses.accepted_uids), + ("declined", self.responses.declined_uids), + ("unanswered", self.responses.unanswered_uids), + ("waiting_list", self.responses.waiting_list_uids), + ("unconfirmed", self.responses.unconfirmed_uids), + ): + if member_uid in uids: + return status + return None + + def has_responded(self, member_uid: str) -> bool: + """True when `member_uid` has given any concrete response + (`accepted`, `declined`, `waiting_list`, or `unconfirmed`) — i.e. + their uid is in any list other than `unanswered_uids`.""" + status = self.response_for(member_uid) + return status is not None and status != "unanswered" + + async def _resolve_uids_to_persons(self, uids: list[str]) -> list[Any]: + """Resolve member UIDs to typed `Member`/`Guardian` objects. + + Walks the client's `groups` cache (fetching via `get_groups()` if + empty) and returns one typed `Person` per uid. UIDs that don't + match any current group member are silently skipped — Spond + sometimes retains response records for members who've since left. + + Used by `accepted_members()` and siblings. Requires `_client`. + """ + if self._client is None: + raise RuntimeError( + "Event has no client attached; member-resolution helpers " + "require an instance constructed via Spond.get_event() or " + "Spond.get_events()." + ) + if not self._client.groups: + await self._client.get_groups() + if not self._client.groups: + return [] + # Build a single uid → person lookup across all groups, so the + # per-uid scan is O(uids) not O(uids × groups × members). + index: dict[str, Any] = {} + for group in self._client.groups: + for member in group.members: + index.setdefault(member.uid, member) + for guardian in member.guardians: + index.setdefault(guardian.uid, guardian) + return [index[uid] for uid in uids if uid in index] + + async def accepted_members(self) -> list[Any]: + """Resolve `responses.accepted_uids` to typed `Member`/`Guardian` + objects via the client's group cache. Lazy — fetches groups if the + cache is empty. + + Returns + ------- + list[Member | Guardian] + One typed `Person` per uid that still resolves to a current + group member. UIDs that no longer correspond to a member of + any group are silently omitted from the result. + + Raises + ------ + RuntimeError + The Event was constructed without a client (e.g. via + `Event.model_validate(raw)` directly rather than + `Spond.get_event()`). Helpers need a client to fetch groups. + """ + return await self._resolve_uids_to_persons(self.responses.accepted_uids) + + async def declined_members(self) -> list[Any]: + """Resolve `responses.declined_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.declined_uids) + + async def unanswered_members(self) -> list[Any]: + """Resolve `responses.unanswered_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.unanswered_uids) + + async def waiting_list_members(self) -> list[Any]: + """Resolve `responses.waiting_list_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.waiting_list_uids) + + async def unconfirmed_members(self) -> list[Any]: + """Resolve `responses.unconfirmed_uids` to typed `Member`/`Guardian` + objects. See `accepted_members` for semantics.""" + return await self._resolve_uids_to_persons(self.responses.unconfirmed_uids) + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Event: + """Construct an `Event` from a raw API response and bind the client. + + Used internally by `Spond.get_event()` and `Spond.get_events()`. + Sets `_client` on the instance so the ActiveRecord methods can + issue HTTP calls. `client` is `Optional` only so test fixtures + can build typed instances without a live Spond — production + callers always pass a real client, and any ActiveRecord method + called on a `_client is None` instance raises `RuntimeError` + (or `AttributeError` in the dereference path). + """ + instance = cls.model_validate(data) + instance._client = client + return instance + + async def update( + self, _updates: dict[str, Any] | None = None, /, **fields: Any + ) -> Event: + """POST changes to this event back to Spond and return the updated event. + + Accepts either Python-style attribute names (`description="..."`) or + API-style aliases (`startTimestamp="..."`) — resolution is bounded + to `Event.model_fields`, so keys that don't match a declared field + in either form pass through to Spond verbatim under their original + name. Spond is the ultimate arbiter of what the event API accepts, + not this SDK. + + The POST payload is built from this Event's current state via + `model_dump(by_alias=True, mode="json")`, then overlaid with the + caller-supplied updates. `mode="json"` converts datetimes to ISO + strings so aiohttp's `json.dumps` can serialise the payload. + + `_EVENT_READ_ONLY_FIELDS` (server-managed timestamps, derived flags, + nested sub-resources like `responses` and `comments`) are stripped + from the *dumped current state* only. Caller-supplied kwargs are + **not** gated — a caller who explicitly passes `responses={...}` or + `creatorId="X"` will see those keys reach Spond, and Spond decides + whether to accept them. The filter exists to prevent the SDK from + silently round-tripping stale local state, not to police explicit + caller intent. + + Parameters + ---------- + _updates : dict, positional-only, optional + Dict of updates to apply. Useful when keys clash with Python + reserved kwarg names like `self` or `cls` (which `**fields` + can't carry), or when callers already have a dict in hand. + **fields + Field updates to send. Use the Python attribute name + (`description`, `start_time`, …) or the API alias + (`startTimestamp`, …) — either resolves correctly. Unknown keys + pass through to the API verbatim. Merged on top of `_updates` + if both are supplied. + + Returns + ------- + Event + A new `Event` reflecting the persisted state. The original + instance is **not** mutated. + """ + # Translate caller-supplied keys to API names. Unknown keys pass + # through as-is so Spond-side changes don't get blocked client-side. + combined: dict[str, Any] = {**(_updates or {}), **fields} + api_updates: dict[str, Any] = {} + for key, value in combined.items(): + py_name = self._resolve_dict_key(key) + if py_name is None: + api_updates[key] = value + else: + field_info = self.__class__.model_fields[py_name] + api_updates[field_info.alias or py_name] = value + + # Dump the current state, then strip three classes of field: + # * read-only fields (creator, timestamps, server-managed flags, + # `responses`) — sending these back risks Spond treating stale + # local state as authoritative. + # * fields that weren't in the source API data (`exclude_unset`) + # — Pydantic tracks `model_fields_set` exactly so we know + # which fields came from Spond vs are class-level defaults. + # This is the critical guard: without it, defaulted empty + # collections (`owners=[]`, `attachments=[]`) and other + # sentinel-defaulted fields would round-trip back to Spond + # and could be interpreted as "clear this". + # * `None`-valued fields (`exclude_none`) — belt-and-suspenders + # for the same risk on dict-typed nested fields. + # The caller's `api_updates` are overlaid afterwards, so explicit + # updates always reach Spond regardless of source-data presence. + payload = self.model_dump( + by_alias=True, + mode="json", + exclude=_EVENT_READ_ONLY_FIELDS, + exclude_unset=True, + exclude_none=True, + ) + # `model_dump(mode="json")` converts native Python types (datetime, + # UUID, Decimal, sets, …) to JSON-safe equivalents, but the + # caller's `api_updates` haven't been through that pass. Run them + # through the same encoder so callers can pass typed values + # naturally — `event.update(start_time=datetime.now())` is the + # obvious shape, given the field is itself a `datetime`. + payload.update({k: to_jsonable_python(v) for k, v in api_updates.items()}) + + url = f"{self._client.api_url}sponds/{self.uid}" + async with self._client.clientsession.post( + url, json=payload, headers=self._client.auth_headers + ) as r: + new_data = await r.json() + + # Spond usually returns the updated event on POST, but if the + # response is partial (status-only, an error wrapper, etc.) the + # construction below would crash with ValidationError. Fall back to + # a fresh fetch in that case. **Order matters**: invalidate the + # cache entry first so the fallback `get_events()` actually + # re-fetches from the API rather than returning the stale `self` + # that's still in the cache. + # + # `type(self)` (not literal `Event`) preserves subclass identity — + # a `Match` updated via Event.update stays a `Match` so subsequent + # `spond.get_event(uid)` doesn't silently demote to plain Event + # (which would lose `match_info`). + try: + new_event = type(self).from_api(new_data, self._client) + except ValidationError: + # Drop the whole events cache so `get_event()` re-fetches via + # `get_events()` instead of resolving from the stale cache. + # That re-fetch routes through `_typed_event` and picks the + # right subclass automatically. + self._client.events = None + new_event = await self._client.get_event(self.uid) + + # Keep the client's events cache coherent — replace the matching + # entry in-place so subsequent `spond.get_event(uid)` calls don't + # serve the stale pre-update instance. Index-based replacement + # preserves the cache's list identity (callers holding a + # reference to `spond.events` keep their list). + if self._client.events is not None: + for i, cached in enumerate(self._client.events): + if cached.uid == self.uid: + self._client.events[i] = new_event + break + + return new_event + + async def change_response( + self, + member_uid: str, + *, + accepted: bool, + decline_message: str | None = None, + ) -> dict[str, Any]: + """Set a member's response on this event. + + Parameters + ---------- + member_uid : str + UID of the member whose response to set. This is the **member's** + id (`group["members"][i]["id"]`), not a profile id and not the + authenticated user's id. + accepted : bool + True to accept, False to decline. + decline_message : str, optional + Reason for declining. When `accepted=False`, the message is + forwarded to Spond if provided. When `accepted=True`, the + message is **not** auto-cleared — any prior decline message + stays on the response server-side unless you explicitly pass + `decline_message=""` to clear it, or follow up with a separate + edit through Spond's UI. + + Returns + ------- + dict + The event's `responses` object as returned by the API, with the + updated id lists (`acceptedIds`, `declinedIds`, …). + """ + payload: dict[str, Any] = {"accepted": str(accepted).lower()} + if decline_message is not None: + # Forward unconditionally if explicitly provided — lets callers + # pass `decline_message=""` to clear a prior message when + # flipping accepted=True. + payload["declineMessage"] = decline_message + + url = f"{self._client.api_url}sponds/{self.uid}/responses/{member_uid}" + async with self._client.clientsession.put( + url, headers=self._client.auth_headers, json=payload + ) as r: + return await r.json() + + async def attendance_xlsx(self) -> bytes: + """Download Spond's attendance-history XLSX for this event. + + Thin wrapper around Spond's web-UI "Export attendance history" + feature — the columns and format are determined by Spond, not by + this library, and notably the export does not include member ids. + For a customisable CSV alternative, see `examples/attendance.py`. + + Returns + ------- + bytes + Raw XLSX bytes, typically written directly to disk: + + ```python + import pathlib + + data = await event.attendance_xlsx() + pathlib.Path(f"{event.uid}.xlsx").write_bytes(data) + ``` + """ + url = f"{self._client.api_url}sponds/{self.uid}/export" + async with self._client.clientsession.get( + url, headers=self._client.auth_headers + ) as r: + return await r.read() + + async def save(self, client: Spond | None = None) -> Event: + """Persist this event to Spond — universal create-or-update. + + - When `self.uid` is empty (a freshly-constructed instance): + POSTs to `/sponds/` to create. Spond returns the new event + with `uid` populated; the result is **applied to self in + place** so the same instance can be used for subsequent + calls. + - When `self.uid` is set: POSTs to `/sponds/{uid}` (the same + path `update()` uses) to persist whatever local state the + caller has mutated. + + On first save of an unbound instance, pass `client=spond` to + bind a Spond client. Subsequent saves use the bound client. + + Example + ------- + ```python + # Create + event = Event(heading="Match vs Rivals", + start_time=..., end_time=..., + recipients={"group": {"id": "GROUPUID"}}, + owners=[{"id": my_profile_uid, "response": "accepted"}]) + await event.save(client=spond) + assert event.uid # populated by Spond + + # Mutate + save + event.heading = "Renamed" + await event.save() + ``` + + Returns `self` (mutated in place) for chaining. Compare with + `update(**fields)` which returns a *new* instance — both shapes + are supported; pick whichever matches your code style. + + Raises + ------ + RuntimeError + No client is bound and `client` was not supplied. + SpondAPIError + Spond rejected the create or update. + """ + if client is not None: + self._client = client + if self._client is None: + raise RuntimeError( + "Event has no client bound. Pass `client=spond` to " + "`event.save(client=...)` on first save." + ) + await self._client._ensure_authenticated() + + if self.uid: + # Update path — round-trip through `update()` so all the + # payload-discipline machinery (exclude_unset, read-only + # filter, JSON-encoding of caller values) applies. Note + # that `update()` *also* writes its returned instance into + # `self._client.events`; we overwrite that slot with self + # below to preserve the ActiveRecord identity guarantee + # (`event is spond.events[i]` after `save()`). + refreshed = await self.update() + is_create = False + else: + # Create path — POST to /sponds/ (collection endpoint). + # We do NOT apply `_EVENT_READ_ONLY_FIELDS` here: that + # filter exists to prevent stale-state round-tripping on + # update, but on create the caller's explicit state is + # all we have to work with — `recipients` in particular + # is required by Spond's create endpoint. + # Server-managed fields (creator_uid, created_time, + # updated, expired, registered, …) are still excluded + # because they default to None and exclude_none=True + # drops them, OR they're in model_fields_set as None and + # exclude_none=True drops them. + payload = self.model_dump( + by_alias=True, + mode="json", + exclude_unset=True, + exclude_none=True, + ) + # Drop the empty `id` if it slipped through — Spond mints + # a fresh uid on create. + payload.pop("id", None) + url = f"{self._client.api_url}sponds/" + async with self._client.clientsession.post( + url, json=payload, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + new_data = await r.json() + refreshed = type(self).from_api(new_data, self._client) + is_create = True + + # Apply the refreshed state to self IN PLACE — this is the + # ActiveRecord contract: after `save()`, `self` is the + # authoritative live record. + # + # `object.__setattr__` is used deliberately to bypass any + # `validate_assignment=True` or custom `__setattr__` a future + # subclass might add — the values in `refreshed` have already + # passed full Pydantic validation via `from_api`, so re-running + # validation per-field here would be redundant work AND would + # incorrectly re-trigger any validators with side effects (e.g. + # mutation timestamps). + # + # `comments` and `responses` are skipped on the UPDATE path: + # both have dedicated endpoints (post-style comments, and + # `change_response()` for attendance) and Spond's POST + # /sponds/{uid} response doesn't always include them. + # Overwriting from the response would silently wipe local + # state that's been updated through those side-channel + # endpoints. On the CREATE path the response IS the canonical + # fresh state, so we let the overwrite proceed. + skip_on_update = set() if is_create else {"comments", "responses"} + for field_name in type(self).model_fields: + if field_name in skip_on_update: + continue + object.__setattr__(self, field_name, getattr(refreshed, field_name)) + # Replace extras wholesale rather than merging — `model_fields_set` + # below is replaced wholesale too, and merging extras would leave + # stale entries on self that were present before the refresh but + # absent from the response. Dict-compat iteration (`list(event)`) + # would then report keys that aren't really in the model anymore. + if self.__pydantic_extra__ is not None: + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(refreshed._pydantic_extras()) + # Sync `model_fields_set` so subsequent `exclude_unset=True` + # dumps reflect what Spond actually emitted (not our pre-save + # snapshot). + self.__pydantic_fields_set__ = set(refreshed.__pydantic_fields_set__) + + # Cache management — match Post.save()'s identity guarantee: + # `event is spond.events[i]` after a successful save(). On + # create, prepend self. On update, replace whatever the + # delegated update() call wrote (which was the now-discardable + # `refreshed` instance, NOT self). + if is_create: + if self._client.events is None: + self._client.events = [self] + else: + self._client.events.insert(0, self) + else: + if self._client.events is not None: + for i, cached in enumerate(self._client.events): + if cached.uid == self.uid: + self._client.events[i] = self + break + + return self + + async def delete(self) -> None: + """Delete this event from Spond. + + Issues `DELETE /sponds/{uid}` and removes the event from the + client's `events` cache. After this call, `self.uid` is left + in place (so callers can still reference what was deleted), + but any subsequent `save()` would attempt to update a no-longer- + existing event and fail. + + Raises + ------ + RuntimeError + The event has no client bound or no `uid` (i.e. it was + never persisted to begin with). + SpondAPIError + Spond rejected the delete. + """ + if self._client is None: + raise RuntimeError("Event has no client bound; cannot delete.") + if not self.uid: + raise RuntimeError( + "Cannot delete an unsaved Event (no uid). Call save() first " + "or construct the instance via Spond.get_event()." + ) + await self._client._ensure_authenticated() + url = f"{self._client.api_url}sponds/{self.uid}" + async with self._client.clientsession.delete( + url, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + # Remove from cache so subsequent get_event(uid) raises rather + # than serving a stale entry. + if self._client.events is not None: + self._client.events = [e for e in self._client.events if e.uid != self.uid] diff --git a/spond/exceptions.py b/spond/exceptions.py new file mode 100644 index 0000000..0afc2eb --- /dev/null +++ b/spond/exceptions.py @@ -0,0 +1,113 @@ +"""Exception hierarchy for the Spond SDK. + +All SDK-raised exceptions descend from `SpondError`, so callers can +`except SpondError:` to catch anything the SDK might raise without +having to enumerate the specific subclasses. + +Lookup failures (`EventNotFoundError`, `GroupNotFoundError`, +`PersonNotFoundError`, `ChatNotFoundError`) additionally inherit from +the stdlib `KeyError` so pre-OO callers that wrote `except KeyError:` +keep working through the v1.x deprecation cycle without modification. + +`AuthenticationError` is re-exported from `spond.__init__` for +backward compatibility — older code imports it as `from spond import +AuthenticationError`. +""" + +from __future__ import annotations + + +class SpondError(Exception): + """Base class for every exception raised by this SDK. + + Catch this to handle any SDK-originated failure without naming the + specific subclass. + """ + + +class AuthenticationError(SpondError): + """Raised when login to the Spond API fails. + + Typical causes: + + - Incorrect username/password. + - 2FA enabled on the account (the library does not currently support + Spond's TOTP flow). + - The account has hit Spond's login rate limit (`outOfLoginAttempts`). + - The Spond login API has changed shape and the response no longer + contains an `accessToken`. + + The exception message includes any of the response's whitelisted + diagnostic fields (`error`, `errorKey`, `errorCode`, `message`) so + most error cases are self-explanatory. Other response fields — such + as 2FA challenge tokens and (masked) `phoneNumber` — are intentionally + dropped from the message to avoid leaking them into application logs. + """ + + +class SpondAPIError(SpondError, ValueError): + """Raised when the Spond API returns a non-success HTTP status. + + Carries the HTTP status code and the response body (truncated) so + callers can branch on either. + + Multi-inherits from `ValueError` so pre-OO callers that wrote + `except ValueError:` against the previous `raise ValueError(...)` + on HTTP failure keep working through the v1.x deprecation cycle. + New callers should use `except SpondAPIError:` and read + `.status` / `.body` / `.url` directly. + """ + + def __init__(self, status: int, body: str = "", url: str | None = None) -> None: + self.status = status + self.body = body + self.url = url + # Truncate body in the message to keep log noise bounded + # (the full body is still available on `self.body`). + trimmed = body[:500] + ("…" if len(body) > 500 else "") + # Preserve the pre-OO `ValueError` message shape so callers + # matching on substring (e.g. `match="401"`) still work. + if body: + msg = f"Request failed with status {status}: {trimmed}" + else: + msg = f"Spond API returned HTTP {status}" + # URL is always appended when present — it's the most useful + # diagnostic field after the status code and is omitting it + # silently on the body-present path made the body+url case + # less debuggable than the body-only case. + if url: + msg += f" for {url}" + super().__init__(msg) + + +class SpondNotFoundError(SpondError, KeyError): + """Base for "lookup-by-id failed" errors. + + Multi-inherits from `KeyError` so existing `except KeyError:` callers + written against the pre-OO dict-shaped API keep working unchanged. + Catch `SpondNotFoundError` for the typed form, or `KeyError` for the + permissive form. + """ + + +class EventNotFoundError(SpondNotFoundError): + """Raised by `Spond.get_event(uid)` when no event with the given uid + exists in the cache (and a refresh didn't surface one either).""" + + +class GroupNotFoundError(SpondNotFoundError): + """Raised by `Spond.get_group(uid)` when no group with the given uid + exists in the cache.""" + + +class PersonNotFoundError(SpondNotFoundError): + """Raised by `Spond.get_person(identifier)` when no member or guardian + matches the identifier across any of the authenticated user's groups.""" + + +class ChatNotFoundError(SpondNotFoundError): + """Raised by chat-list lookups when no chat with the given uid exists. + + Reserved for the future `Spond.get_chat(uid)` shape; the current + `get_messages()` returns a list and doesn't look up by uid. + """ diff --git a/spond/field_def.py b/spond/field_def.py new file mode 100644 index 0000000..4bdc7c1 --- /dev/null +++ b/spond/field_def.py @@ -0,0 +1,47 @@ +"""Typed `FieldDef` model — definitions for the custom fields a Group exposes +on its members. + +Spond's `Group.fieldDefs` is a list of definitions describing the +custom-data slots each member can fill (e.g. `"shirt size"`, +`"emergency contact"`). The per-member values live on +`Member.custom_fields` as a `dict` keyed by the field-def uid; this +class gives those keys human-readable context: + +```python +for fd in group.field_defs: + value = member.custom_fields.get(fd.uid) + print(f"{fd.name}: {value}") +``` + +The model is intentionally minimal — Spond's API may carry additional +fields per definition (type, required-flag, ordering hints), but those +shapes vary by Spond release and aren't part of the SDK contract. +`extra="allow"` preserves them on the instance for callers who need +to reach in directly. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class FieldDef(DictCompatModel): + """A custom-field definition attached to a `Group`.""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + name: str = "" + """Human-readable label for the field, as shown in the Spond UI.""" + + def __str__(self) -> str: + return f"FieldDef(uid={self.uid!r}, name={self.name!r})" + + def _natural_key(self) -> tuple | None: + if self.uid: + return ("FieldDef", self.uid) + if self.name: + return ("FieldDef", None, self.name) + return None diff --git a/spond/group.py b/spond/group.py new file mode 100644 index 0000000..b0c5140 --- /dev/null +++ b/spond/group.py @@ -0,0 +1,252 @@ +"""Typed `Group` model with `find_member()` helper. + +A `Group` is a Spond group the authenticated user belongs to. Each group +carries lists of `Member`s, `Subgroup`s, and `Role`s — all materialised as +their respective typed objects when the Group is constructed via +`Group.from_api()`. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel +from .field_def import FieldDef +from .person import Member +from .role import Role +from .subgroup import Subgroup + +if TYPE_CHECKING: + from .spond import Spond + + +class Group(DictCompatModel): + """A Spond group, with its members, subgroups, and roles as typed lists. + + Construct via `Spond.get_group(uid)` or as elements of + `Spond.get_groups()`. Direct instantiation works but won't attach a + `_client` reference (which Member.send_message and other behaviour + methods on contained objects need). + + Example + ------- + ```python + group = await spond.get_group(uid) + print(group.name) + for member in group.members: + print(f" {member.full_name}") + for guardian in member.guardians: + print(f" guardian: {guardian.full_name}") + + coach = group.find_member(name="Ola Thoresen") + if coach is not None: + await coach.send_message("Practice cancelled", group_uid=group.uid) + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + arbitrary_types_allowed=True, + ) + + uid: str = Field(alias="id") + name: str = "" + activity: str | None = None + """The group's sport/activity tag, e.g. `"football"`.""" + + members: list[Member] = Field(default_factory=list) + """Members of the group. Each member's nested `guardians` are typed too.""" + + subgroups: list[Subgroup] = Field(default_factory=list, alias="subGroups") + roles: list[Role] = Field(default_factory=list) + + contact_person: dict[str, Any] | None = Field(default=None, alias="contactPerson") + """Profile reference dict for the group's primary contact. Unmodelled.""" + + age_group: str | None = Field(default=None, alias="ageGroup") + organization_type: str | None = Field(default=None, alias="organizationType") + event_visibility: str | None = Field(default=None, alias="eventVisibility") + country_code: str | None = Field(default=None, alias="countryCode") + type: int | None = None + + # Fields observed in the live API but absent from the original Spond + # SDK's reverse-engineered shape. Most are admin-relevant metadata + # (permissions, contact policy, address layout); all are optional so a + # future field-drop doesn't crash get_groups(). + created_time: datetime | None = Field(default=None, alias="createdTime") + member_permissions: list[str] = Field( + default_factory=list, alias="memberPermissions" + ) + """Permission strings granted to regular members (e.g. `["posts"]`).""" + guardian_permissions: list[str] = Field( + default_factory=list, alias="guardianPermissions" + ) + """Permission strings granted to guardians (e.g. `["posts"]`).""" + membership_requests: list[dict[str, Any]] = Field( + default_factory=list, alias="membershipRequests" + ) + """Pending join requests. Each entry is unmodelled.""" + chat_age_limit: int | None = Field(default=None, alias="chatAgeLimit") + """Minimum age allowed in group chats.""" + share_contact_info: bool = Field(default=False, alias="shareContactInfo") + """Whether member contact info is visible to other members.""" + contact_info_hidden: bool = Field(default=False, alias="contactInfoHidden") + admins_can_add_members: bool = Field(default=False, alias="adminsCanAddMembers") + address_format: list[str] = Field(default_factory=list, alias="addressFormat") + """Field-order hint for displaying member addresses, e.g. + `["street", "zip", "city"]`.""" + allow_sms_nag: bool = Field(default=False, alias="allowSmsNag") + bonus_enabled: bool = Field(default=False, alias="bonusEnabled") + invited_to_app_time: datetime | None = Field(default=None, alias="invitedToAppTime") + + # Less user-facing — admin/finance internals. Kept as raw containers + # because their nested shapes vary by Spond Club configuration and + # we don't want to over-promise a structure. + field_defs: list[FieldDef] = Field(default_factory=list, alias="fieldDefs") + """Custom-field definitions configured on the group. Pair with + `Member.custom_fields` (keyed by `FieldDef.uid`) to render + human-readable label/value pairs in callers' UIs.""" + default_fields: dict[str, Any] = Field(default_factory=dict, alias="defaultFields") + """Default per-member field metadata. Unmodelled.""" + payout_accounts: list[Any] = Field(default_factory=list, alias="payoutAccounts") + """Spond Club payout accounts attached to the group.""" + allow_private_payout_accounts: bool = Field( + default=False, alias="allowPrivatePayoutAccounts" + ) + experiments: dict[str, Any] = Field(default_factory=dict) + """A/B experiment flags Spond has enabled for this group. Internal.""" + + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + return ( + f"Group(uid={self.uid!r}, name={self.name!r}, members={len(self.members)})" + ) + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise the group `name` distinguishes + unsaved groups.""" + if self.uid: + return ("Group", self.uid) + if self.name: + return ("Group", None, self.name) + return None + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond) -> Group: + """Construct a `Group` from raw API data and wire `_client` through. + + Sets `_client` on the group and on every nested member and guardian, + so per-instance methods like `member.send_message(...)` can issue + HTTP calls without further plumbing. `client` is required — passing + a no-client Group around would crash any subsequent behaviour call + with a confusing late-stage error. + """ + instance = cls.model_validate(data) + instance._client = client + for member in instance.members: + member._client = client + for guardian in member.guardians: + guardian._client = client + return instance + + def find_member( + self, + *, + uid: str | None = None, + email: str | None = None, + name: str | None = None, + ) -> Member | None: + """Find a single member by uid, email, or full name. + + Exactly one of `uid`, `email`, `name` must be provided. Returns the + first match in `self.members`, or `None` if no member matches. + + For full-name matching, `name` is compared against + `member.full_name` (first + space + last). + + Parameters + ---------- + uid : str, optional + Match against `member.uid`. + email : str, optional + Match against `member.email` (exact). + name : str, optional + Match against `member.full_name` (exact). Note: `full_name` + joins only non-empty parts, so a member whose record carries + only `firstName` (no last name) has `full_name == "Alice"`, + not `"Alice "`. Pass the trimmed form. + + Returns + ------- + Member or None + + Raises + ------ + ValueError + Zero or more than one search criterion was supplied. + """ + criteria = {"uid": uid, "email": email, "name": name} + supplied = [k for k, v in criteria.items() if v is not None] + if len(supplied) != 1: + raise ValueError( + f"find_member requires exactly one of uid/email/name; got {supplied}" + ) + + for member in self.members: + if uid is not None and member.uid == uid: + return member + if email is not None and member.email == email: + return member + if name is not None and member.full_name == name: + return member + return None + + def member_by_uid(self, uid: str) -> Member | None: + """Find a member by `uid`. Returns `None` if not found. + + Shorthand for `find_member(uid=uid)` — matches the + `role_by_uid` / `subgroup_by_uid` sibling shape so callers can + write `group.member_by_uid(...)` / `group.role_by_uid(...)` / + `group.subgroup_by_uid(...)` uniformly without remembering which + ones live on `find_member` vs as standalone helpers. + """ + return self.find_member(uid=uid) + + def role_by_uid(self, uid: str) -> Role | None: + """Find a role by `uid`. Returns `None` if not found.""" + return next((r for r in self.roles if r.uid == uid), None) + + def subgroup_by_uid(self, uid: str) -> Subgroup | None: + """Find a subgroup by `uid`. Returns `None` if not found.""" + return next((sg for sg in self.subgroups if sg.uid == uid), None) + + def members_by_subgroup(self, subgroup: Subgroup | str) -> list[Member]: + """Return members belonging to the given subgroup. + + Accepts either a `Subgroup` instance or its `uid` string — + callers walking `group.subgroups` already have the object, + callers holding only a uid (e.g. from `member.subgroup_uids`) + avoid an extra lookup. + + Returns an empty list if no members reference the subgroup + (including the case where the subgroup doesn't exist at all + — there's no need to distinguish "subgroup empty" from + "subgroup not in this group" for the navigation use case). + """ + target_uid = subgroup if isinstance(subgroup, str) else subgroup.uid + return [m for m in self.members if target_uid in m.subgroup_uids] + + def members_by_role(self, role: Role | str) -> list[Member]: + """Return members holding the given role. + + Accepts either a `Role` instance or its `uid` string. Returns + an empty list if no members reference the role. See + `members_by_subgroup` for the same conventions. + """ + target_uid = role if isinstance(role, str) else role.uid + return [m for m in self.members if target_uid in m.role_uids] diff --git a/spond/match.py b/spond/match.py new file mode 100644 index 0000000..0f6e158 --- /dev/null +++ b/spond/match.py @@ -0,0 +1,84 @@ +"""Typed `Match` model — a sports-fixture `Event` subclass. + +A "match" in Spond is an Event with `matchEvent=True` plus a `matchInfo` +sub-object carrying opponent name, team name, scores, and home/away status. +`Spond.get_events()` and `Spond.get_event()` automatically return `Match` +instances (instead of plain `Event`) when the underlying API record has +`matchEvent=True`, so callers can `isinstance(event, Match)` to discriminate. + +The `matchInfo` shape was verified against real Spond fixtures during +implementation. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel +from .event import Event + + +class MatchInfo(DictCompatModel): + """Score and opponent metadata attached to a `Match`. + + All fields are optional with sensible defaults so a fixture without + scores yet (the typical pre-match state) doesn't crash construction. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + type: str | None = None + """`"HOME"` or `"AWAY"` — whether the team plays on its own ground.""" + + team_name: str | None = Field(default=None, alias="teamName") + """Name of the authenticated user's team for this fixture.""" + team_score: int | None = Field(default=None, alias="teamScore") + """Goals/points scored by the user's team. `None` until scores entered.""" + + opponent_name: str | None = Field(default=None, alias="opponentName") + """Name of the opposing team.""" + opponent_score: int | None = Field(default=None, alias="opponentScore") + """Goals/points scored by the opposing team. `None` until scores entered.""" + + scores_set: bool = Field(default=False, alias="scoresSet") + """True once any score is recorded (either team's) for this fixture.""" + scores_set_ever: bool = Field(default=False, alias="scoresSetEver") + """Server-tracked: True if scores have ever been set, even if later cleared.""" + scores_final: bool = Field(default=False, alias="scoresFinal") + """True once the result has been marked final (no further edits expected).""" + scores_public: bool = Field(default=False, alias="scoresPublic") + """True if the scores are visible to non-admin members of the group.""" + + +class Match(Event): + """A Spond Event that represents a sports fixture. + + Carries everything an `Event` does, plus `match_info` with the opponent + and score metadata. Construct via `Spond.get_events()` / + `Spond.get_event()` — they pick `Match` or `Event` automatically based + on the underlying `matchEvent` flag. + + ```python + events = await spond.get_events() + for e in events: + if isinstance(e, Match) and e.match_info: + print(f"{e.match_info.team_name} {e.match_info.team_score} - " + f"{e.match_info.opponent_score} {e.match_info.opponent_name}") + ``` + + Updates to score fields go through the same `Event.update()` machinery: + + ```python + match = await spond.get_event(uid) # returns Match when matchEvent is true + await match.update(matchInfo={"teamScore": 3, "opponentScore": 1, + "scoresFinal": True}) + ``` + + The `matchInfo` dict is forwarded to Spond verbatim; the score-related + booleans `scoresSetEver` etc. are server-tracked so callers shouldn't + set them directly. + """ + + match_info: MatchInfo | None = Field(default=None, alias="matchInfo") + """Per-fixture opponent/score metadata. Always present in the API for a + real match (`match_event=True`), but defaulted to `None` for resilience.""" diff --git a/spond/person.py b/spond/person.py new file mode 100644 index 0000000..2276f46 --- /dev/null +++ b/spond/person.py @@ -0,0 +1,215 @@ +"""Typed `Person` base class with `Member` and `Guardian` subclasses. + +The Spond API uses two flavours of person attached to a `Group`: + +- **Member** — a person invited to events, who can respond, hold roles, + and (for child members) have one or more guardians. +- **Guardian** — a person attached to a Member to receive notifications + and respond on the member's behalf. Has fewer fields than Member + (no email, no roles, no nested guardians, no `respondent` flag). + +Both share a common shape (uid, name, profile, phone) which we model as +the abstract base `Person`. `Member` and `Guardian` extend it with their +respective specifics. + +Construct via `Spond.get_groups()` and walk `group.members`; or via +`Spond.get_person(user)`, which can return either kind. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel, LenientDate + + +class Person(DictCompatModel): + """Shared base for `Member` and `Guardian`. + + Carries only the fields that both flavours include (uid, names, profile, + phone). Subclasses add their specifics. Not intended to be instantiated + directly — use `Spond.get_person()` to obtain a Person of the right + concrete type. + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + arbitrary_types_allowed=True, + ) + + uid: str = Field(alias="id") + # `first_name` and `last_name` are present on every member/guardian + # we've seen in the wild, but defaulted to "" so a missing field in + # one record can't crash the entire `get_groups()` payload. + first_name: str = Field(default="", alias="firstName") + last_name: str = Field(default="", alias="lastName") + profile: dict[str, Any] | None = None + """Profile reference dict — `{id, contactMethod, ...}`. Kept as a raw + dict (not a typed `Profile` model) because Spond's `members[].profile` + payload is a sparse reference, not the rich account record that + `Spond.get_profile()` returns. The two have different shapes; modelling + them as one type would create false equivalence.""" + phone_number: str | None = Field(default=None, alias="phoneNumber") + + # Non-serialised reference back to the Spond client for HTTP calls. + _client: Any = PrivateAttr(default=None) + + @property + def full_name(self) -> str: + """Convenience: non-empty `first_name` and `last_name` joined by a + single space. Returns `""` when both are missing (avoids the bare + `" "` artefact when name fields default to empty strings).""" + return " ".join(part for part in (self.first_name, self.last_name) if part) + + def __str__(self) -> str: + # `` mirrors the `"?"` sentinel used by Event/Post/Comment + # for missing timestamps — better debug output than an empty + # `name=''` when both first_name and last_name default to empty. + name = self.full_name or "" + return f"{self.__class__.__name__}(uid={self.uid!r}, name={name!r})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise full_name + email. Returns the same + kind tag (`"Person"`) for Member and Guardian, so two records + for the same human (one a member, one a guardian elsewhere) + compare unequal only if their identifiers differ — matching + Spond's data model where the same uid never appears in both + roles for the same account.""" + if self.uid: + return ("Person", self.uid) + email = getattr(self, "email", None) + if self.full_name or email: + return ("Person", None, self.full_name, email) + return None + + +class Guardian(Person): + """A guardian attached to a `Member`. + + Guardians receive notifications about the member they care for and may + respond to events on the member's behalf. They have a strictly smaller + field set than Members (no `email`, no `roles`, no nested `guardians`). + + Constructed by `Group.from_api()` (via Pydantic) and wired with + `_client` post-validation. Don't instantiate directly. + """ + + async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: + """Send a chat message directly to this guardian. + + Spond routes the message via the guardian's profile id. Requires the + group context (`group_uid`) the guardian is reachable through. + + Returns + ------- + dict + The Spond chat API's response for the send operation. + """ + return await _send_message_to_person(self, text, group_uid) + + +class Member(Person): + """A member of a Group — someone who can be invited to events. + + Carries Member-specific fields (email, date of birth, roles, guardians, + subgroup memberships) on top of the shared `Person` base. + + `model_config` is inherited from `Person` — no redeclaration needed. + """ + + email: str | None = None + """Email address. May be absent on minor members.""" + + date_of_birth: LenientDate = Field(default=None, alias="dateOfBirth") + + created_time: datetime | None = Field(default=None, alias="createdTime") + + guardians: list[Guardian] = Field(default_factory=list) + """Guardians attached to this member. Empty for adult members.""" + + role_uids: list[str] = Field(default_factory=list, alias="roles") + """UIDs of `Role`s this member holds within the group.""" + + subgroup_uids: list[str] = Field(default_factory=list, alias="subGroups") + """UIDs of `Subgroup`s this member belongs to within the group. + + Note: the API alias here is `subGroups` (not `subGroupIds`) because + that's the field name Spond's groups endpoint actually returns inside + each member object. `Post.subgroup_uids` aliases `subGroupIds` for the + same reason — Spond's posts endpoint uses a different key name for + what is conceptually the same kind of list. The asymmetry is in the + API, not in this SDK. + """ + + respondent: bool = False + """Whether this member personally responds to events (False for child + members whose guardians respond on their behalf).""" + + custom_fields: dict[str, Any] = Field(default_factory=dict, alias="fields") + """Per-member custom fields the group admin has defined (e.g. shirt + size, dietary requirements). Maps the API's `fields` key, but exposed + here as `custom_fields` to avoid confusion with Pydantic's + `model_fields` metadata vocabulary.""" + + async def send_message(self, text: str, group_uid: str) -> dict[str, Any]: + """Send a chat message directly to this member. + + Parameters + ---------- + text : str + Message body. + group_uid : str + UID of the group context the chat belongs to. + + Returns + ------- + dict + The Spond chat API's response for the send operation. + """ + return await _send_message_to_person(self, text, group_uid) + + +async def _send_message_to_person( + person: Person, text: str, group_uid: str +) -> dict[str, Any]: + """Send a chat message to any Person (Member or Guardian). + + Implementation shared between `Member.send_message` and + `Guardian.send_message` — Spond routes by the recipient's profile id, + which is the same field on both kinds. + """ + client = person._client + if client is None: + raise RuntimeError( + f"{type(person).__name__} has no client attached; instantiate via " + f"Spond.get_person() or walk Spond.get_groups()." + ) + + # Validate caller args BEFORE the chat-server handshake so a pure + # client-side argument error doesn't trigger a network round-trip. + # Mirrors the same fail-fast ordering in `Spond.send_message`. + if not isinstance(person.profile, dict) or "id" not in person.profile: + raise ValueError( + f"{type(person).__name__} {person.uid} has no profile id; " + f"Spond cannot route a message without one." + ) + + # Lazy chat handshake (Spond's chat API uses a separate host + token). + if client._auth is None: + await client._login_chat() + + payload = { + "text": text, + "type": "TEXT", + "recipient": person.profile["id"], + "groupId": group_uid, + } + url = f"{client._chat_url}/messages" + async with client.clientsession.post( + url, json=payload, headers={"auth": client._auth} + ) as r: + return await r.json() diff --git a/spond/post.py b/spond/post.py new file mode 100644 index 0000000..b6ca2c7 --- /dev/null +++ b/spond/post.py @@ -0,0 +1,332 @@ +"""Typed `Post` model — group-wall announcements with ActiveRecord behaviour. + +`Post`s are the announcement-style messages posted to a Group's wall (as +opposed to chat messages or events). Returned by `Spond.get_posts()`. + +The write surface mirrors `Event`'s shape: + +- `post.save(client=spond)` — create (no uid) or update (uid present) +- `post.delete()` — DELETE `/posts/{uid}` and prune from cache +- `post.add_comment(text)` — POST `/posts/{uid}/comments`, returns the + new `Comment` and appends it to `post.comments` +""" + +from __future__ import annotations + +from datetime import datetime +from typing import TYPE_CHECKING, Any + +from pydantic import ConfigDict, Field, PrivateAttr + +from ._compat import DictCompatModel +from .comment import Comment +from .exceptions import SpondAPIError + +if TYPE_CHECKING: + from .spond import Spond + + +# Python field names that `Post.save()` strips from the POST payload +# on both the create and update paths. Mirrors `_EVENT_READ_ONLY_FIELDS` +# in event.py — same reasoning: server-managed timestamps and identifiers, +# per-user view state, and nested sub-resources that have their own +# endpoints (`comments` → `post.add_comment(...)`; reactions are managed +# elsewhere). Sending these back risks Spond treating stale local state +# as authoritative or, on create, having client-supplied values +# silently overridden by server-managed ones. +_POST_READ_ONLY_FIELDS = frozenset( + { + "owner_uid", # set by Spond from the authenticated user + "timestamp", # set by Spond on create; immutable + "unread", # per-user view state + "muted", # per-user view state + "reactions", # has its own dedicated endpoint + "comments", # has its own endpoint (post.add_comment) + } +) + + +class Post(DictCompatModel): + """A post on a Group's wall (announcement, not a chat message). + + Construct via `Spond.get_posts()` — which wires `_client` for you. + For a freshly-created post, build with `Post(...)` and call + `await post.save(client=spond)`; subsequent `save()` calls (and + `delete()` / `add_comment()`) use the bound client automatically. + + Example + ------- + ```python + async with Spond(username, password) as s: + # Read + posts = await s.get_posts(group_id="GRP") + for p in posts: + print(p.title, [c.text for c in p.comments]) + + # Create + post = Post(uid="", type="PLAIN", group_uid="GRP", + title="Hello", body="Welcome to the group.") + await post.save(client=s) + assert post.uid + + # Comment + await post.add_comment("First!") + + # Delete + await post.delete() + ``` + """ + + model_config = ConfigDict( + populate_by_name=True, + extra="allow", + # See Event.model_config — same rationale: ensures + # mutate-then-save() picks up direct attribute assignments + # to fields that weren't in the source payload. + validate_assignment=True, + ) + + uid: str = Field(alias="id") + type: str = "PLAIN" + title: str | None = None + body: str | None = None + timestamp: datetime | None = None + group_uid: str | None = Field(default=None, alias="groupId") + subgroup_uids: list[str] = Field(default_factory=list, alias="subGroupIds") + owner_uid: str | None = Field(default=None, alias="ownerId") + visibility: str | None = None + """e.g. \"ADULTS_GUARDIANS_ONLY\", \"ALL\".""" + unread: bool = False + muted: bool = False + comments_disabled: bool = Field(default=False, alias="commentsDisabled") + select_member_poll: bool = Field(default=False, alias="selectMemberPoll") + media: list[Any] = Field(default_factory=list) + attachments: list[Any] = Field(default_factory=list) + reactions: dict[str, Any] = Field(default_factory=dict) + """Emoji-reactions map. Empty for unreacted posts.""" + comments: list[Comment] = Field(default_factory=list) + """Typed `Comment` instances. Only populated when fetched with + `include_comments=True` (the default for `Spond.get_posts()`).""" + + # Non-serialised reference back to the Spond client for HTTP calls. + _client: Any = PrivateAttr(default=None) + + def __str__(self) -> str: + # `timestamp` is optional after the resilience relaxation, so guard + # the .isoformat() call the same way `Event.__str__` guards + # `start_time`. Avoids `AttributeError` when Spond omits the field. + ts = self.timestamp.isoformat() if self.timestamp else "?" + return f"Post(uid={self.uid!r}, title={self.title!r}, timestamp={ts})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise (title, timestamp) for unsaved posts.""" + if self.uid: + return ("Post", self.uid) + if self.title or self.timestamp: + return ("Post", None, self.title, self.timestamp) + return None + + @classmethod + def from_api(cls, data: dict[str, Any], client: Spond | None) -> Post: + """Construct a `Post` from raw API data and bind the client.""" + instance = cls.model_validate(data) + instance._client = client + return instance + + async def save(self, client: Spond | None = None) -> Post: + """Persist this post to Spond — universal create-or-update. + + - `self.uid` empty → POST `/posts/` to create. Mutates self in + place with the persisted state (uid populated, server-managed + fields copied in) and appends to `client.posts`. + - `self.uid` set → POST `/posts/{uid}` to update. Mutates self + with the refreshed state. + + On first save of a freshly-constructed instance, pass + `client=spond` to bind a client; subsequent saves use the + bound client. + + Raises + ------ + RuntimeError + No client is bound and `client` was not supplied. + SpondAPIError + Spond rejected the create or update. + """ + if client is not None: + self._client = client + if self._client is None: + raise RuntimeError( + "Post has no client bound. Pass `client=spond` to " + "`post.save(client=...)` on first save." + ) + await self._client._ensure_authenticated() + + # Apply `_POST_READ_ONLY_FIELDS` on BOTH paths. On update it + # prevents round-tripping stale local state of fields with + # dedicated endpoints. On create the same set is excluded + # because Spond sets `owner_uid`/`timestamp` itself, ignores + # client-supplied `unread`/`muted` (per-user state), and the + # `comments`/`reactions` lists are populated through their own + # endpoints — sending them at create time risks the caller + # accidentally seeding stale data from a Post built off another + # post's response payload. + payload = self.model_dump( + by_alias=True, + mode="json", + exclude=_POST_READ_ONLY_FIELDS, + exclude_unset=True, + exclude_none=True, + ) + payload.pop("id", None) # never echo uid in the body + + # Spond uses different verbs for create vs update on posts: + # POST `/posts/` to create, PUT `/posts/{uid}` to update. + # Verified live against the test group (POST `/posts/{uid}` + # returns 405 Method Not Allowed; PUT returns 200 with the + # updated post). This is inconsistent with Spond's event API + # (which uses POST for both), but matches what the SDK has to + # do to round-trip changes. + if self.uid: + url = f"{self._client.api_url}posts/{self.uid}" + http = self._client.clientsession.put + else: + url = f"{self._client.api_url}posts/" + http = self._client.clientsession.post + + async with http(url, json=payload, headers=self._client.auth_headers) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + new_data = await r.json() + + refreshed = type(self).from_api(new_data, self._client) + is_create = not self.uid + + # Apply refreshed state to self IN PLACE (ActiveRecord contract). + # `object.__setattr__` is used deliberately to bypass any + # `validate_assignment=True` or custom `__setattr__` a future + # subclass might add — the values in `refreshed` have already + # passed full Pydantic validation via `from_api`, so re-running + # validation per-field here would be redundant work AND would + # incorrectly re-trigger any validators that have side effects + # (e.g. mutation timestamps). The wholesale + # `__pydantic_fields_set__` replacement on the line below keeps + # `exclude_unset=True` dumps consistent with what Spond emitted. + # + # `comments` and `reactions` are deliberately skipped on the + # UPDATE path: those fields have dedicated endpoints + # (`add_comment()`, reactions API) and Spond's POST /posts/{uid} + # update response doesn't always include them. Overwriting from + # the response would silently wipe a comment that + # `await add_comment()` had just appended locally. On the CREATE + # path Spond's response is the canonical fresh state (an empty + # list / dict for a brand-new post), so we let the overwrite + # proceed and treat it as the authoritative initial value. + skip_on_update = set() if is_create else {"comments", "reactions"} + for field_name in type(self).model_fields: + if field_name in skip_on_update: + continue + object.__setattr__(self, field_name, getattr(refreshed, field_name)) + # Replace, don't merge: `model_fields_set` is replaced wholesale + # on the line below, so the extras dict must follow the same + # "this is the post-refresh canonical state" semantics. Merging + # (.update()) would leave stale extras that were on self before + # the refresh but absent from the response, producing an + # inconsistent dict-compat view where `list(post)` reports + # fields that aren't actually in the model anymore. + if self.__pydantic_extra__ is not None: + self.__pydantic_extra__.clear() + self.__pydantic_extra__.update(refreshed._pydantic_extras()) + self.__pydantic_fields_set__ = set(refreshed.__pydantic_fields_set__) + + # Cache management. On create, prepend so subsequent + # `get_posts(...)` filter scans find it without a re-fetch. + if is_create: + if self._client.posts is None: + self._client.posts = [self] + else: + self._client.posts.insert(0, self) + + return self + + async def delete(self) -> None: + """Delete this post from Spond. + + Issues `DELETE /posts/{uid}` and prunes the post from the + client's `posts` cache. + + Raises + ------ + RuntimeError + The post has no client bound or no `uid` (was never + persisted). + SpondAPIError + Spond rejected the delete. + """ + if self._client is None: + raise RuntimeError("Post has no client bound; cannot delete.") + if not self.uid: + raise RuntimeError( + "Cannot delete an unsaved Post (no uid). Call save() first." + ) + await self._client._ensure_authenticated() + url = f"{self._client.api_url}posts/{self.uid}" + async with self._client.clientsession.delete( + url, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + if self._client.posts is not None: + self._client.posts = [p for p in self._client.posts if p.uid != self.uid] + + async def add_comment(self, text: str) -> Comment: + """Post a comment on this Post. + + Issues `POST /posts/{uid}/comments` with `{"text": text}` and + appends the resulting `Comment` to `self.comments` in place + so the parent post stays consistent without an explicit refresh. + + Parameters + ---------- + text : str + Comment body. + + Returns + ------- + Comment + The newly-created comment (also appended to `self.comments`). + + Raises + ------ + RuntimeError + No client bound, or `self.uid` is empty (the post hasn't + been saved to Spond yet — comments need a parent uid). + SpondAPIError + Spond rejected the comment (e.g. `commentsDisabled=True` on + the post). + """ + if self._client is None: + raise RuntimeError("Post has no client bound; cannot add comment.") + if not self.uid: + raise RuntimeError( + "Cannot add a comment to an unsaved Post. Call save() first." + ) + await self._client._ensure_authenticated() + url = f"{self._client.api_url}posts/{self.uid}/comments" + async with self._client.clientsession.post( + url, json={"text": text}, headers=self._client.auth_headers + ) as r: + if not r.ok: + raise SpondAPIError(r.status, await r.text(), url) + data = await r.json() + # Comments carry no ActiveRecord operations of their own + # (Spond exposes no edit/delete endpoints for individual + # comments via the consumer API), so the new instance doesn't + # need `_client` wiring. If Comment grows behaviour later + # (e.g. `comment.react()`), revisit this construction site to + # thread the client through. + comment = Comment.model_validate(data) + # Keep the parent in sync — callers reading `post.comments` + # immediately after this call should see the new comment. + self.comments.append(comment) + return comment diff --git a/spond/profile.py b/spond/profile.py new file mode 100644 index 0000000..58e85bd --- /dev/null +++ b/spond/profile.py @@ -0,0 +1,85 @@ +"""Typed `Profile` model for the authenticated user's account. + +`Profile` represents the rich user record returned by `Spond.get_profile()`. +It carries account-level details (email, phone, timezone, locale, +preferences) — strictly more than the `profile` dict referenced inside +Members and Guardians, which is a sparse subset. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel, LenientDate + + +class Profile(DictCompatModel): + """The authenticated user's full account profile. + + Returned by `Spond.get_profile()`. Not the same as the `profile` dict + nested inside Member/Guardian — that's a sparse reference shape. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + # Defaulted to "" so a missing field on the authenticated user's + # profile response can't crash `get_profile()` outright. + first_name: str = Field(default="", alias="firstName") + last_name: str = Field(default="", alias="lastName") + primary_email: str | None = Field(default=None, alias="primaryEmail") + phone_number: str | None = Field(default=None, alias="phoneNumber") + formatted_phone_number: str | None = Field( + default=None, alias="formattedPhoneNumber" + ) + image_url: str | None = Field(default=None, alias="imageUrl") + date_of_birth: LenientDate = Field(default=None, alias="dateOfBirth") + gender: str | None = None + locale: str | None = None + country_code: str | None = Field(default=None, alias="countryCode") + timezone: str | None = None + contact_method: str | None = Field(default=None, alias="contactMethod") + deleted: bool = False + internal: bool = False + dummy: bool = False + unable_to_reach: bool = Field(default=False, alias="unableToReach") + preferences: dict[str, Any] | None = None + """Nested preference settings (push, email, locale, etc.). Unmodelled.""" + + # Fields observed in the live API but absent from the original Spond SDK's + # reverse-engineered shape. All optional so a future field-drop doesn't + # crash get_profile(). + tos_version: int | None = Field(default=None, alias="tosVersion") + """Version of the Spond Terms of Service the user has accepted.""" + contact: bool = False + """Whether the user can be listed as a group contact person.""" + + # Internal/analytics fields — preserved for completeness but not part of + # the user-meaningful API surface. Treat as opaque. + tracking_id: str | None = Field(default=None, alias="trackingId") + """Internal analytics identifier. Opaque; do not rely on the shape.""" + unsubscribe_code: str | None = Field(default=None, alias="unsubscribeCode") + """Email-unsubscribe token. Internal.""" + + @property + def full_name(self) -> str: + """Convenience: non-empty `first_name` and `last_name` joined by a + single space. Returns `""` when both are missing (avoids the bare + `" "` artefact when name fields default to empty strings).""" + return " ".join(part for part in (self.first_name, self.last_name) if part) + + def __str__(self) -> str: + # Same `` sentinel as `Person.__str__` for consistency. + name = self.full_name or "" + return f"Profile(uid={self.uid!r}, name={name!r})" + + def _natural_key(self) -> tuple | None: + """uid when set; otherwise full_name distinguishes + freshly-constructed profile instances.""" + if self.uid: + return ("Profile", self.uid) + if self.full_name: + return ("Profile", None, self.full_name) + return None diff --git a/spond/role.py b/spond/role.py new file mode 100644 index 0000000..b0af30e --- /dev/null +++ b/spond/role.py @@ -0,0 +1,33 @@ +"""Typed `Role` model — a permission role within a `Group`. + +Roles are passive data — they have no behaviour. Members reference them by +UID via `Member.role_uids`; resolve to `Role` objects by walking +`group.roles`. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Role(DictCompatModel): + """A named permission role within a `Group` (e.g. \"Coach\", \"Treasurer\").""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + name: str = "" + permissions: list[str] = Field(default_factory=list) + """API permission strings, e.g. `["members", "events", "posts"]`.""" + + def __str__(self) -> str: + return f"Role(uid={self.uid!r}, name={self.name!r})" + + def _natural_key(self) -> tuple | None: + if self.uid: + return ("Role", self.uid) + if self.name: + return ("Role", None, self.name) + return None diff --git a/spond/spond.py b/spond/spond.py index 5570406..d92e77e 100644 --- a/spond/spond.py +++ b/spond/spond.py @@ -9,11 +9,39 @@ from __future__ import annotations +import warnings from typing import TYPE_CHECKING, ClassVar from . import JSONDict -from ._event_template import _EVENT_TEMPLATE from .base import _SpondBase +from .chat import Chat +from .event import Event +from .exceptions import ( + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, +) +from .group import Group +from .match import Match +from .person import Member, Person +from .post import Post +from .profile import Profile + + +def _typed_event(data: JSONDict, client: Spond | None) -> Event: + """Construct a `Match` if `matchEvent` is True on the raw payload, else + a plain `Event`. Centralises the typed-vs-typed dispatch so both + `get_events()` and any future singular-fetch path use the same logic. + + `client` is optional to make this helper usable in tests (which + construct events without a live Spond client). At runtime, every + production call site passes a real `Spond`; only tests pass `None`, + and they never invoke methods that would dereference `_client`. + """ + cls = Match if data.get("matchEvent") else Event + return cls.from_api(data, client) + if TYPE_CHECKING: from datetime import datetime @@ -33,13 +61,23 @@ class Spond(_SpondBase): refresh, set the relevant attribute to `None` and call the `get_*` method again, or call the underlying `get_*s()` method directly. - Remember to close the underlying aiohttp session when finished: + `Spond` is an async context manager — the idiomatic shape closes the + underlying aiohttp session automatically: + + ```python + async with Spond(username="...", password="...") as s: + groups = await s.get_groups() + ... + # session closed automatically on exit, even if the body raises + ``` + + The older explicit-cleanup shape still works for callers that need + a long-lived client outside a `with` block: ```python s = Spond(username="...", password="...") try: groups = await s.get_groups() - ... finally: await s.clientsession.close() ``` @@ -51,11 +89,11 @@ class Spond(_SpondBase): from spond import spond async def main(): - s = spond.Spond(username="me@example.invalid", password="secret") - groups = await s.get_groups() or [] - for g in groups: - print(g["name"]) - await s.clientsession.close() + async with spond.Spond(username="me@example.invalid", + password="secret") as s: + groups = await s.get_groups() or [] + for g in groups: + print(g.name) asyncio.run(main()) ``` @@ -63,7 +101,6 @@ async def main(): _API_BASE_URL: ClassVar = "https://api.spond.com/core/v1/" _DT_FORMAT: ClassVar = "%Y-%m-%dT00:00:00.000Z" - _EVENT_TEMPLATE: ClassVar = _EVENT_TEMPLATE _EVENT: ClassVar = "event" _GROUP: ClassVar = "group" @@ -72,9 +109,9 @@ def __init__(self, username: str, password: str) -> None: The credentials are stored on the instance and used to obtain an access token on the first authenticated call. An aiohttp `ClientSession` is - opened immediately; close it via `await s.clientsession.close()` - (where `s` is the constructed instance) when finished, to avoid - `Unclosed client session` warnings. + opened immediately; use `Spond` as an `async with` context manager to + close it automatically, or call `await s.clientsession.close()` when + finished to avoid `Unclosed client session` warnings. Parameters ---------- @@ -87,11 +124,11 @@ def __init__(self, username: str, password: str) -> None: super().__init__(username, password, self._API_BASE_URL) self._chat_url = None self._auth = None - self.groups: list[JSONDict] | None = None - self.events: list[JSONDict] | None = None - self.posts: list[JSONDict] | None = None - self.messages: list[JSONDict] | None = None - self.profile: JSONDict | None = None + self.groups: list[Group] | None = None + self.events: list[Event] | None = None + self.posts: list[Post] | None = None + self.messages: list[Chat] | None = None + self.profile: Profile | None = None async def _login_chat(self) -> None: """Perform the secondary handshake with Spond's chat server. @@ -104,51 +141,56 @@ async def _login_chat(self) -> None: client. """ api_chat_url = f"{self.api_url}chat" - r = await self.clientsession.post(api_chat_url, headers=self.auth_headers) - result = await r.json() + async with self.clientsession.post( + api_chat_url, headers=self.auth_headers + ) as r: + result = await r.json() self._chat_url = result["url"] self._auth = result["auth"] @_SpondBase.require_authentication - async def get_profile(self) -> JSONDict: + async def get_profile(self) -> Profile: """Retrieve the authenticated user's profile. - The profile dict includes at least the user's `id`, `firstName`, and - `lastName`, plus contact details and account preferences. The full + Returns a `Profile` instance with the user's account details (id, + first/last name, email, phone, timezone, preferences). The full response is cached on `self.profile`. Returns ------- - JSONDict - The profile object as returned by the Spond API. + Profile + The authenticated user's profile. """ url = f"{self._API_BASE_URL}profile" async with self.clientsession.get(url, headers=self.auth_headers) as r: - self.profile = await r.json() - return self.profile + raw = await r.json() + self.profile = Profile.model_validate(raw) + return self.profile @_SpondBase.require_authentication - async def get_groups(self) -> list[JSONDict] | None: + async def get_groups(self) -> list[Group] | None: """Retrieve every group the authenticated user is a member of. - Each group dict includes a `members` list, with each member dict - containing `id`, `firstName`, `lastName`, and (for child profiles - managed by another account) a nested `guardians` list of the same - shape. The full response is cached on `self.groups` and reused by - `get_group(uid)` and `get_person(user)`. + Each `Group` carries its `members` (typed `Member` instances, each + with their own `guardians: list[Guardian]`), `subgroups: list[Subgroup]`, + and `roles: list[Role]`. The full list is cached on `self.groups` and + reused by `get_group(uid)` and `get_person(user)`. Returns ------- - list[JSONDict] or None - A list of groups, each represented as a dictionary. `None` if the - account has no groups at all. + list[Group] or None + A list of groups, or `None` if the account has no groups at all. """ url = f"{self.api_url}groups/" async with self.clientsession.get(url, headers=self.auth_headers) as r: - self.groups = await r.json() - return self.groups - - async def get_group(self, uid: str) -> JSONDict: + raw = await r.json() + if raw is None: + self.groups = None + return None + self.groups = [Group.from_api(g, self) for g in raw] + return self.groups + + async def get_group(self, uid: str) -> Group: """Look up a single group by its unique id. Searches the cached `self.groups` (populated by `get_groups()` on @@ -161,9 +203,9 @@ async def get_group(self, uid: str) -> JSONDict: Returns ------- - JSONDict - The group's details, with the same shape as elements returned by - `get_groups()`. + Group + The group, with members/subgroups/roles materialised as typed + objects. Raises ------ @@ -173,7 +215,7 @@ async def get_group(self, uid: str) -> JSONDict: return await self._get_entity(self._GROUP, uid) @_SpondBase.require_authentication - async def get_person(self, user: str) -> JSONDict: + async def get_person(self, user: str) -> Person: """Look up a member or guardian by any of several identifiers. Searches every member of every cached group (and each member's @@ -185,17 +227,24 @@ async def get_person(self, user: str) -> JSONDict: user : str Identifier to match against. Accepted forms: - - the member's `id` - - the member's email (exact match) + - the person's `uid` + - the person's `email` (Members only — exact match) - first and last name joined by a single space (e.g. `"Ola Thoresen"`) - - the member's `profile.id` (different from `id` for child profiles) + - the person's `profile.id` (different from `uid` for child + members managed by another account) Returns ------- - JSONDict - The first matching member or guardian dict. Shape matches the - entries in a group's `members` list from `get_groups()`. + Person + The first matching `Member` or `Guardian`. Use `isinstance` to + distinguish if needed. + + Note: the returned object's `.profile` attribute is a raw + `dict[str, Any] | None` (a sparse reference shape Spond serves + inside `members[]`), NOT a typed `Profile` model. That latter + shape is only what `Spond.get_profile()` returns for the + authenticated user — different endpoint, richer response. Raises ------ @@ -204,19 +253,30 @@ async def get_person(self, user: str) -> JSONDict: """ if not self.groups: await self.get_groups() + # Distinct error path for "the account has no groups at all" vs. + # "groups exist but no member/guardian matched" — same exception + # type so existing `except KeyError:` callers keep working, but + # the message tells the caller which situation they're in. + if not self.groups: + raise PersonNotFoundError( + f"No person matched with identifier {user!r}: account has " + f"no groups, so there are no members to search." + ) for group in self.groups: - for member in group["members"]: + for member in group.members: if self._match_person(member, user): return member - if "guardians" in member: - for guardian in member["guardians"]: - if self._match_person(guardian, user): - return guardian - errmsg = f"No person matched with identifier '{user}'." - raise KeyError(errmsg) + for guardian in member.guardians: + if self._match_person(guardian, user): + return guardian + raise PersonNotFoundError( + f"No person matched with identifier {user!r}: scanned " + f"{sum(len(g.members) for g in self.groups)} member(s) across " + f"{len(self.groups)} group(s)." + ) @staticmethod - def _match_person(person: JSONDict, match_str: str) -> bool: + def _match_person(person: Person, match_str: str) -> bool: """Return True if `match_str` matches any of the person's identifiers. Used internally by `get_person` to scan group members and guardians. @@ -224,8 +284,9 @@ def _match_person(person: JSONDict, match_str: str) -> bool: Parameters ---------- - person : JSONDict - A member or guardian dict from a group's `members` list. + person : Person + A `Member` or `Guardian` from a group's `members` list (or one + of its nested `guardians`). match_str : str The identifier to test against. @@ -234,11 +295,28 @@ def _match_person(person: JSONDict, match_str: str) -> bool: bool True on first matching identifier; False otherwise. """ + if person.uid == match_str: + return True + # `full_name` returns "" when both first/last default to empty; + # without this guard, an empty `match_str` would erroneously match + # every nameless record. Mirrors the `bool(person.email)` guard + # below. + if person.full_name and person.full_name == match_str: + return True + # `person.profile` is typed `dict | None`, but `extra="allow"` and + # API drift mean we shouldn't trust the shape at runtime — guard + # against Spond ever returning a non-dict (string id, null wrapper) + # rather than raising AttributeError mid-scan. + if isinstance(person.profile, dict) and person.profile.get("id") == match_str: + return True + # Members have email; Guardians don't. Guard against the + # `None == None` trap — if the member has no email on record and + # the caller (somehow) supplied None as match_str, we don't want + # to claim a match. return ( - person["id"] == match_str - or ("email" in person and person["email"]) == match_str - or person["firstName"] + " " + person["lastName"] == match_str - or ("profile" in person and person["profile"]["id"] == match_str) + isinstance(person, Member) + and bool(person.email) + and person.email == match_str ) @_SpondBase.require_authentication @@ -247,9 +325,8 @@ async def get_posts( group_id: str | None = None, max_posts: int = 20, include_comments: bool = True, - ) -> list[JSONDict] | None: - """ - Retrieve posts from group walls. + ) -> list[Post] | None: + """Retrieve posts from group walls. Posts are announcements/messages posted to group walls, as opposed to chat messages or events. @@ -269,9 +346,8 @@ async def get_posts( Returns ------- - list[JSONDict] or None - A list of posts, each represented as a dictionary, or None if no - posts are available. + list[Post] or None + A list of posts, or `None` if the account has no posts. Raises ------ @@ -292,19 +368,23 @@ async def get_posts( ) as r: if not r.ok: error_details = await r.text() - raise ValueError( - f"Request failed with status {r.status}: {error_details}" - ) - self.posts = await r.json() - return self.posts + raise SpondAPIError(r.status, error_details, url) + raw = await r.json() + if raw is None: + self.posts = None + return None + self.posts = [Post.from_api(p, self) for p in raw] + return self.posts @_SpondBase.require_authentication - async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: - """Retrieve recent chats (one-to-one and group conversations). + async def get_messages(self, max_chats: int = 100) -> list[Chat] | None: + """Retrieve recent chat threads (one-to-one, group, system, campaign). - "Chats" here refers to the in-app direct/group messaging feature, not - comments on events or posts. Uses Spond's separate chat-server host - and chat token (handled internally by `_login_chat`). + "Chats" here refers to the in-app direct/group messaging feature, + not comments on events or posts. Uses Spond's separate chat-server + host and chat token (handled internally by `_login_chat`). Each + returned `Chat` carries its most recent embedded `Message`; full + thread history isn't exposed by the underlying API. The full response is cached on `self.messages`. @@ -316,9 +396,9 @@ async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: Returns ------- - list[JSONDict] or None - A list of chat objects ordered by most recent activity. `None` if - the account has no chats. + list[Chat] or None + Typed `Chat` instances ordered by most recent activity. `None` + if the account has no chats. """ if not self._auth: await self._login_chat() @@ -328,7 +408,11 @@ async def get_messages(self, max_chats: int = 100) -> list[JSONDict] | None: headers={"auth": self._auth}, params={"max": str(max_chats)}, ) as r: - self.messages = await r.json() + raw = await r.json() + if raw is None: + self.messages = None + return None + self.messages = [Chat.from_api(c, self) for c in raw] return self.messages @_SpondBase.require_authentication @@ -354,8 +438,10 @@ async def _continue_chat(self, chat_id: str, text: str) -> JSONDict: await self._login_chat() url = f"{self._chat_url}/messages" data = {"chatId": chat_id, "text": text, "type": "TEXT"} - r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) - return await r.json() + async with self.clientsession.post( + url, json=data, headers={"auth": self._auth} + ) as r: + return await r.json() @_SpondBase.require_authentication async def send_message( @@ -407,19 +493,27 @@ async def send_message( of the authenticated user's groups (propagated from `get_person`). """ + # Validate caller args BEFORE the chat-server handshake so a pure + # client-side argument error doesn't trigger a network round-trip. + if chat_id is None and (group_uid is None or user is None): + raise ValueError( + "send_message requires either chat_id (to continue an existing " + "chat) or both user and group_uid (to start a new one)." + ) + if self._auth is None: await self._login_chat() if chat_id is not None: return await self._continue_chat(chat_id, text) - if group_uid is None or user is None: - raise ValueError( - "send_message requires either chat_id (to continue an existing " - "chat) or both user and group_uid (to start a new one)." - ) user_obj = await self.get_person(user) - user_uid = user_obj["profile"]["id"] + if not isinstance(user_obj.profile, dict) or "id" not in user_obj.profile: + raise ValueError( + f"Person {user_obj.uid} has no profile id; Spond cannot route " + f"a message without one." + ) + user_uid = user_obj.profile["id"] url = f"{self._chat_url}/messages" data = { "text": text, @@ -427,8 +521,10 @@ async def send_message( "recipient": user_uid, "groupId": group_uid, } - r = await self.clientsession.post(url, json=data, headers={"auth": self._auth}) - return await r.json() + async with self.clientsession.post( + url, json=data, headers={"auth": self._auth} + ) as r: + return await r.json() @_SpondBase.require_authentication async def get_events( @@ -442,7 +538,7 @@ async def get_events( max_start: datetime | None = None, min_start: datetime | None = None, max_events: int = 100, - ) -> list[JSONDict] | None: + ) -> list[Event] | None: """Retrieve events visible to the authenticated user. Filters can narrow by group/subgroup, by start/end timestamp window, @@ -495,9 +591,8 @@ async def get_events( Returns ------- - list[JSONDict] or None - A list of events, each represented as a dictionary, or None if no events - are available. + list[Event] or None + A list of `Event` instances, or `None` if no events match. Raises ------ @@ -531,13 +626,15 @@ async def get_events( ) as r: if not r.ok: error_details = await r.text() - raise ValueError( - f"Request failed with status {r.status}: {error_details}" - ) - self.events = await r.json() - return self.events - - async def get_event(self, uid: str) -> JSONDict: + raise SpondAPIError(r.status, error_details, url) + raw = await r.json() + if raw is None: + self.events = None + return None + self.events = [_typed_event(e, self) for e in raw] + return self.events + + async def get_event(self, uid: str) -> Event: """Look up a single event by its unique id. Routes through the cached events list (populated by `get_events()`), @@ -553,9 +650,8 @@ async def get_event(self, uid: str) -> JSONDict: Returns ------- - JSONDict - The event's details, with the same shape as elements returned by - `get_events()`. + Event + The matching event. Raises ------ @@ -566,106 +662,95 @@ async def get_event(self, uid: str) -> JSONDict: @_SpondBase.require_authentication async def update_event(self, uid: str, updates: JSONDict) -> JSONDict: - """Update an existing event by merging changes into the current state. - - The implementation fetches the event via `_get_entity()`, copies the - fields present in `_EVENT_TEMPLATE` from the existing event as the - base, then overlays any keys provided in `updates`. The merged event - is POSTed back to `sponds/{uid}`. - - Parameters - ---------- - uid : str - UID of the event to update. - updates : JSONDict - Mapping of keys to new values. Only keys present in - `_EVENT_TEMPLATE` are honoured. Example: - - ```python - await s.update_event(uid, {"description": "New description"}) - ``` - - Returns - ------- - JSONDict - The Spond API response from the POST — the updated event as - persisted server-side. + """Deprecated — use `Event.update()` on the typed object instead. + + ```python + # Old: + await spond.update_event(uid, {"description": "..."}) + + # New: + event = await spond.get_event(uid) + await event.update(description="...") + ``` + + Delegates to `Event.update()`; unknown keys in `updates` pass through + to Spond verbatim (Spond decides what it accepts, the SDK doesn't + gate). Returns the updated event as a dict for shape parity with the + pre-OO API. The returned dict reflects the fields populated during + Pydantic validation of Spond's POST response — declared fields plus + any unmodelled extras preserved via `extra="allow"`. With current + config (every top-level type uses `extra="allow"`) that's + equivalent to "everything Spond sent back"; if a future model + switched to `extra="ignore"`, unknown keys would be dropped from + this return value. Emits `DeprecationWarning`. """ - event = await self._get_entity(self._EVENT, uid) - url = f"{self.api_url}sponds/{uid}" - - base_event = self._EVENT_TEMPLATE.copy() - for key in base_event: - if event.get(key) is not None and not updates.get(key): - base_event[key] = event[key] - elif updates.get(key) is not None: - base_event[key] = updates[key] - - async with self.clientsession.post( - url, json=base_event, headers=self.auth_headers - ) as r: - return await r.json() + warnings.warn( + "Spond.update_event() is deprecated; use Event.update() on the " + "object returned by Spond.get_event() instead.", + DeprecationWarning, + stacklevel=2, + ) + event = await self.get_event(uid) + # Pass as positional dict, not **kwargs — `updates` may contain keys + # like `self` or `cls` that would clash with bound-method calling. + new_event = await event.update(updates) + # `exclude_unset=True` so the returned dict matches the shape Spond + # actually sent back (only the fields populated during validation), + # not "every class field including defaults" — closer to pre-OO + # behaviour where the return was the raw POST-response JSON. + return new_event.model_dump(by_alias=True, mode="json", exclude_unset=True) @_SpondBase.require_authentication async def get_event_attendance_xlsx(self, uid: str) -> bytes: - """Download the attendance report for an event as XLSX bytes. - - Thin wrapper around Spond's own "Export attendance history" feature - in the web UI. The columns and format are determined by Spond, not by - this library — for example, the export does not include member ids. - For a customisable CSV alternative built from `get_event()` data, - see `examples/attendance.py`. + """Deprecated — use `Event.attendance_xlsx()` on the typed object instead. - Parameters - ---------- - uid : str - UID of the event whose attendance report to fetch. - - Returns - ------- - bytes - Raw XLSX file contents. Typically written directly to disk: + ```python + # Old: + data = await spond.get_event_attendance_xlsx(uid) - ```python - import pathlib + # New: + event = await spond.get_event(uid) + data = await event.attendance_xlsx() + ``` - data = await s.get_event_attendance_xlsx(uid) - pathlib.Path(f"{uid}.xlsx").write_bytes(data) - ``` + Kept as a thin pass-through for backward compatibility. Emits + `DeprecationWarning`. """ + warnings.warn( + "Spond.get_event_attendance_xlsx() is deprecated; use " + "Event.attendance_xlsx() on the object returned by " + "Spond.get_event() instead.", + DeprecationWarning, + stacklevel=2, + ) url = f"{self.api_url}sponds/{uid}/export" async with self.clientsession.get(url, headers=self.auth_headers) as r: return await r.read() @_SpondBase.require_authentication async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSONDict: - """Update a single member's response (accept/decline) for an event. + """Deprecated — use `Event.change_response()` on the typed object instead. - Useful for managing attendance on someone else's behalf (e.g. a coach - accepting on behalf of a player who can't reach the app). The caller - must have permission on the event. + ```python + # Old: + await spond.change_response(uid, member_uid, {"accepted": "true"}) - Parameters - ---------- - uid : str - UID of the event. - user : str - UID of the member whose response to change. Note: this is the - *member's* id (as seen in `group["members"][i]["id"]`), not the - authenticated user's id. - payload : JSONDict - The response body. Common shapes: - - - `{"accepted": "true"}` — accept the invitation - - `{"accepted": "false"}` — decline (Spond may also accept a - `"declineMessage"` field with a reason) + # New: + event = await spond.get_event(uid) + await event.change_response(member_uid, accepted=True) + ``` - Returns - ------- - JSONDict - The event's `responses` object with the updated id lists - (`acceptedIds`, `declinedIds`, `unansweredIds`, etc.). + Kept as a **thin pass-through** for backward compatibility: forwards + `payload` verbatim to the API. Any extra keys the caller supplies + (beyond `accepted` / `declineMessage`) reach the server unchanged, + matching the old semantics. Emits `DeprecationWarning`. """ + warnings.warn( + "Spond.change_response() is deprecated; use Event.change_response() " + "on the object returned by Spond.get_event() instead.", + DeprecationWarning, + stacklevel=2, + ) url = f"{self.api_url}sponds/{uid}/responses/{user}" async with self.clientsession.put( url, headers=self.auth_headers, json=payload @@ -673,7 +758,7 @@ async def change_response(self, uid: str, user: str, payload: JSONDict) -> JSOND return await r.json() @_SpondBase.require_authentication - async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: + async def _get_entity(self, entity_type: str, uid: str) -> Event | Group: """Internal lookup helper shared by `get_event` and `get_group`. Routes to the relevant cache (`self.events` or `self.groups`), @@ -692,8 +777,10 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: Returns ------- - JSONDict - The matching entity dict. + Event | Group + The matching typed entity (`Event` for `entity_type="event"`, + `Group` for `entity_type="group"`). Subclasses (e.g. `Match`) + are preserved. Raises ------ @@ -716,10 +803,16 @@ async def _get_entity(self, entity_type: str, uid: str) -> JSONDict: raise NotImplementedError(errmsg) errmsg = f"No {entity_type} with id='{uid}'." + # Pick the typed subclass so callers can `except EventNotFoundError` + # (or `except GroupNotFoundError`) specifically. Both still inherit + # from KeyError for pre-OO compat. + exc_cls = ( + EventNotFoundError if entity_type == self._EVENT else GroupNotFoundError + ) if not entities: - raise KeyError(errmsg) + raise exc_cls(errmsg) for entity in entities: - if entity["id"] == uid: + if entity.uid == uid: return entity - raise KeyError(errmsg) + raise exc_cls(errmsg) diff --git a/spond/subgroup.py b/spond/subgroup.py new file mode 100644 index 0000000..ca04c5c --- /dev/null +++ b/spond/subgroup.py @@ -0,0 +1,35 @@ +"""Typed `Subgroup` model — a sub-division of a `Group`. + +Subgroups are passive data — they have no behaviour of their own. Surface +them via `group.subgroups`. +""" + +from __future__ import annotations + +from pydantic import ConfigDict, Field + +from ._compat import DictCompatModel + + +class Subgroup(DictCompatModel): + """A sub-division within a `Group` (e.g. a team within a club). + + Members reference subgroups by UID via `Member.subgroup_uids`. + """ + + model_config = ConfigDict(populate_by_name=True, extra="allow") + + uid: str = Field(alias="id") + name: str = "" + color: str | None = None + image_url: str | None = Field(default=None, alias="imageUrl") + + def __str__(self) -> str: + return f"Subgroup(uid={self.uid!r}, name={self.name!r})" + + def _natural_key(self) -> tuple | None: + if self.uid: + return ("Subgroup", self.uid) + if self.name: + return ("Subgroup", None, self.name) + return None diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..fbfeaa0 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,59 @@ +"""Shared fixtures and constants for the Spond test suite. + +pytest auto-discovers this file, so fixtures defined here (e.g. `mock_token`) +are available to every test file without explicit import. Constants that +test files need to reference directly are imported via +`from .conftest import ...`. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from spond import JSONDict + + +# Minimum-viable Event API payload — all required fields filled with placeholder +# data. Tests that need an Event object can spread/override. +_MIN_EVENT_PAYLOAD: dict = { + "id": "ID1", + "heading": "Event One", + "startTimestamp": "2026-01-01T10:00:00Z", + "endTimestamp": "2026-01-01T11:00:00Z", + "createdTime": "2025-12-01T10:00:00Z", + "type": "EVENT", + "responses": { + "acceptedIds": [], + "declinedIds": [], + "unansweredIds": [], + "waitinglistIds": [], + "unconfirmedIds": [], + }, +} + + +MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" +MOCK_TOKEN = "MOCK_TOKEN" +MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} + + +# Authentication is bypassed by setting `s.token = mock_token` on every test +# `Spond` instance before invoking a decorated method. The real +# `require_authentication` decorator then short-circuits its `if not +# self.token: await self.login()` check without issuing HTTP. No +# class-level monkey-patch is needed (and wouldn't work anyway — +# `@_SpondBase.require_authentication` is applied at class-definition +# time, so reassigning the attribute later doesn't re-decorate methods). + + +@pytest.fixture +def mock_token() -> str: + return MOCK_TOKEN + + +@pytest.fixture +def mock_payload() -> JSONDict: + return MOCK_PAYLOAD diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..21ec76d --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,152 @@ +"""Tests for authentication — `_extract_access_token` parsing, login flow, +and the `require_authentication` decorator's metadata preservation.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import AuthenticationError +from spond.base import _SpondBase +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestLogin: + @pytest.mark.parametrize( + ("login_result", "expected"), + [ + ( + {"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, + "ABC", + ), + ], + ) + def test_extract_access_token__happy_path(self, login_result, expected) -> None: + assert _SpondBase._extract_access_token(login_result) == expected + + @pytest.mark.parametrize( + "login_result", + [ + {"error": "Invalid credentials"}, + {"accessToken": None}, + {"accessToken": {}}, + {"accessToken": {"token": ""}}, + {"accessToken": {"token": None}}, + ], + ) + def test_extract_access_token__bad_shape_raises(self, login_result) -> None: + with pytest.raises(AuthenticationError): + _SpondBase._extract_access_token(login_result) + + def test_extract_access_token__error_message_drops_sensitive_fields( + self, + ) -> None: + """The exception message must not leak unwhitelisted fields from the + login response (e.g. a 2FA challenge `token` or `phoneNumber`).""" + login_result = { + "token": "TWO_FA_CHALLENGE_TOKEN_VALUE", + "phoneNumber": "****12", + "errorKey": "twoFactorRequired", + } + with pytest.raises(AuthenticationError) as exc_info: + _SpondBase._extract_access_token(login_result) + + message = str(exc_info.value) + assert "TWO_FA_CHALLENGE_TOKEN_VALUE" not in message + assert "phoneNumber" not in message + assert "twoFactorRequired" in message # whitelisted field surfaces + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_login__happy_path(self, mock_post) -> None: + mock_response = { + "accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}, + "refreshToken": {"token": "REF", "expiration": "2026-08-11T12:00:00Z"}, + "passwordToken": {"token": "PWD", "expiration": "2026-05-13T13:00:00Z"}, + } + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=mock_response + ) + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + await s.login() + + mock_post.assert_called_once_with( + "https://api.spond.com/core/v1/auth2/login", + json={"email": MOCK_USERNAME, "password": MOCK_PASSWORD}, + ) + assert s.token == "ABC" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_login__error_response_raises(self, mock_post) -> None: + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"error": "Invalid credentials"} + ) + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + with pytest.raises(AuthenticationError): + await s.login() + assert s.token is None + + +class TestRequireAuthenticationDecorator: + """The `require_authentication` decorator must preserve the wrapped + method's metadata (signature, docstring, name) so `inspect`-based + tools — pdoc, IDE help, tab completion — see the real method + rather than the wrapper's `(*args, **kwargs)` shim. + """ + + def test_decorator_preserves_signature(self) -> None: + """Decorated methods must expose their real parameter list.""" + import inspect + + # `get_posts` is decorated and has a distinctive signature + params = list(inspect.signature(Spond.get_posts).parameters) + assert params == ["self", "group_id", "max_posts", "include_comments"] + + def test_decorator_preserves_docstring(self) -> None: + """Decorated methods must expose their own docstring, not the + wrapper's.""" + import inspect + + doc = inspect.getdoc(Spond.get_profile) or "" + # Wrapper docstring would start with 'Decorator that...' if leaked. + assert "Retrieve the authenticated user's profile." in doc + + def test_decorator_preserves_name(self) -> None: + """`__name__` must be the method's, not 'wrapper'.""" + assert Spond.get_events.__name__ == "get_events" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_require_auth_closes_session_on_auth_error(self, mock_post) -> None: + """When `login()` raises `AuthenticationError`, the `require_authentication` + decorator must close the aiohttp session before re-raising — prevents + `Unclosed client session` warnings and resource leaks.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"error": "Bad credentials"} + ) + + # Spy on the close method so we can assert it was called + close_called = [] + real_close = s.clientsession.close + + async def spy_close(): + close_called.append(True) + await real_close() + + s.clientsession.close = spy_close + + with pytest.raises(AuthenticationError): + await s.get_profile() + + assert close_called, ( + "clientsession.close() was not called on AuthenticationError" + ) diff --git a/tests/test_backward_compat.py b/tests/test_backward_compat.py new file mode 100644 index 0000000..94d9b4b --- /dev/null +++ b/tests/test_backward_compat.py @@ -0,0 +1,162 @@ +"""Backward-compatibility regression guards for pre-OO callers. + +These tests don't probe new behaviour — they exist to catch the day a +refactor accidentally drops one of the pre-OO patterns that callers in +the wild depend on. If anything here fails, it's a release-blocker for +the next v1.x. + +Covered: +- `event["heading"]` and `event.get("heading")` still work +- `"heading" in event`, `len(event)`, `list(event)` still work +- `from spond import AuthenticationError` still works +- `except KeyError:` still catches `get_event`/`get_group`/`get_person` + lookup failures +- `except ValueError:` still catches HTTP failures +- `event.model_equals(other)` escape hatch returns full-field equality + for callers who depended on Pydantic's pre-natural-key default +- Deprecated wrappers `Spond.update_event`/`change_response`/ + `get_event_attendance_xlsx` still exist and emit `DeprecationWarning` +""" + +from __future__ import annotations + +import warnings + +import pytest + +from spond import ( + AuthenticationError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, +) +from spond.event import Event +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +class TestDictStyleAccessStillWorks: + """The DictCompatModel shim is the central backward-compat surface.""" + + def test_subscript_returns_value_with_deprecation_warning(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + value = e["heading"] + assert value == "Event One" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_alias_subscript_also_works(self) -> None: + """Pre-OO callers used the camelCase API name (`startTimestamp`), + not the Python name (`start_time`).""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + assert e["startTimestamp"] is not None + assert e["id"] == "ID1" + + def test_get_with_default_returns_default(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + sentinel = object() + assert e.get("nope", sentinel) is sentinel + + def test_contains_works(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert "heading" in e + assert "startTimestamp" in e + assert "missing" not in e + + def test_iter_and_len_consistent(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert len(e) == len(keys) + for k in keys: + assert k in e + + +class TestExceptionsBackwardCompat: + """Existing `except KeyError:` / `except ValueError:` / `from spond + import AuthenticationError` patterns must keep working.""" + + def test_authentication_error_top_level_import(self) -> None: + """`from spond import AuthenticationError` is the pre-OO shape; + the new exceptions module just re-exports it.""" + from spond.exceptions import AuthenticationError as AE2 + + assert AuthenticationError is AE2 + + def test_event_not_found_caught_as_keyerror(self) -> None: + """Pre-OO code: `try: e = await s.get_event(uid) ... except KeyError:`""" + assert issubclass(EventNotFoundError, KeyError) + + def test_group_not_found_caught_as_keyerror(self) -> None: + assert issubclass(GroupNotFoundError, KeyError) + + def test_person_not_found_caught_as_keyerror(self) -> None: + assert issubclass(PersonNotFoundError, KeyError) + + def test_api_error_caught_as_valueerror(self) -> None: + """Pre-OO code did `except ValueError:` for HTTP failures.""" + assert issubclass(SpondAPIError, ValueError) + + +class TestModelEqualsEscapeHatch: + """`model_equals` provides full-field equality for callers who + depended on Pydantic's pre-natural-key default.""" + + def test_same_uid_different_state_unequal_under_model_equals(self) -> None: + """Two events with the same uid but different heading are equal + under `==` (natural-key match) but unequal under `model_equals` + (full field comparison).""" + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + b = Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "X", "heading": "Different"} + ) + + assert a == b # natural-key equality + assert not a.model_equals(b) # field-by-field disagreement + + def test_identical_state_equal_under_model_equals(self) -> None: + a = Event.model_validate(_MIN_EVENT_PAYLOAD) + b = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert a.model_equals(b) + + def test_different_class_unequal_under_model_equals(self) -> None: + from spond.group import Group + + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + g = Group.model_validate({"id": "X"}) + assert not e.model_equals(g) + + +class TestDeprecatedWrappersStillExist: + """The pre-OO write methods (`update_event`, `change_response`, + `get_event_attendance_xlsx`) must still be present on `Spond` and + must emit `DeprecationWarning`.""" + + @pytest.mark.asyncio + async def test_update_event_emits_deprecation(self, mock_token) -> None: + from unittest.mock import AsyncMock, patch + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + + with patch("aiohttp.ClientSession.post") as mock_post: + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + await s.update_event(uid="ID1", updates={"heading": "X"}) + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_change_response_method_present(self) -> None: + assert callable(Spond.change_response) + + def test_get_event_attendance_xlsx_method_present(self) -> None: + assert callable(Spond.get_event_attendance_xlsx) diff --git a/tests/test_club.py b/tests/test_club.py new file mode 100644 index 0000000..14a2ee0 --- /dev/null +++ b/tests/test_club.py @@ -0,0 +1,192 @@ +"""Tests for the Spond Club finance API — `Transaction` model and +`SpondClub.get_transactions()` pagination.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.club import SpondClub, Transaction + +from .conftest import MOCK_PASSWORD, MOCK_TOKEN, MOCK_USERNAME + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_TX1 = { + "id": "TX1", + "paidAt": "2026-01-15T10:30:00Z", + "paymentName": "Season fee", + "paidByName": "Alice Smith", +} +_TX2 = { + "id": "TX2", + "paidAt": "2026-02-01T08:00:00Z", + "paymentName": "Kit", + "paidByName": "Bob Jones", +} + + +class TestTransaction: + """Unit tests for the `Transaction` typed model.""" + + def test_transaction_parses_all_fields(self) -> None: + t = Transaction.model_validate(_TX1) + assert t.uid == "TX1" + assert t.payment_name == "Season fee" + assert t.paid_by_name == "Alice Smith" + assert t.paid_at is not None + + def test_transaction_str(self) -> None: + t = Transaction.model_validate(_TX1) + s = str(t) + assert "TX1" in s + assert "Season fee" in s + assert "Alice Smith" in s + + def test_transaction_minimal_only_id_required(self) -> None: + """All non-uid fields have defaults; only `id` is required.""" + t = Transaction.model_validate({"id": "TX_MIN"}) + assert t.uid == "TX_MIN" + assert t.payment_name == "" + assert t.paid_by_name == "" + assert t.paid_at is None + + def test_transaction_extra_fields_preserved(self) -> None: + """Unknown Spond fields survive via `extra='allow'` and are accessible + through the dict-compat shim.""" + import warnings as _w + + t = Transaction.model_validate({**_TX1, "futureField": "value"}) + assert "futureField" in t + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + assert t["futureField"] == "value" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_transaction_dict_compat_subscript_warns(self) -> None: + import warnings as _w + + t = Transaction.model_validate(_TX1) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + val = t["id"] + assert val == "TX1" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + +class TestSpondClubGetTransactions: + """Integration tests for `SpondClub.get_transactions()` pagination.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_single_page(self, mock_get) -> None: + """A single page of results (< max_items) is returned without + further recursive calls.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[[_TX1, _TX2], []] + ) + + txs = await sc.get_transactions(club_id="CLUB1", max_items=100) + + assert len(txs) == 2 + assert all(isinstance(t, Transaction) for t in txs) + assert txs[0].uid == "TX1" + assert txs[1].uid == "TX2" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_empty_result(self, mock_get) -> None: + """If Spond returns an empty first page, an empty list comes back.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + txs = await sc.get_transactions(club_id="CLUB1") + + assert txs == [] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_paginates_until_empty(self, mock_get) -> None: + """When the first page is full the method fetches additional pages + until an empty page is returned.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + # Page 1: 2 items; page 2: 1 item; page 3: empty (stops) + page3 = [] + page2 = [_TX2] + page1 = [_TX1, _TX2] + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[page1, page2, page3] + ) + + txs = await sc.get_transactions(club_id="CLUB1", max_items=100) + + # 2 + 1 = 3 calls produced 2+1=3 Transaction objects + assert len(txs) == 3 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_stops_at_max_items(self, mock_get) -> None: + """Once `len(self.transactions) >= max_items`, no further page is + fetched even if the last page was full.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + # Two-item page; max_items=2 means we must NOT recurse after page 1. + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[_TX1, _TX2] + ) + + txs = await sc.get_transactions(club_id="CLUB1", max_items=2) + + assert len(txs) == 2 + # Only one HTTP call should have been made + assert mock_get.call_count == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_passes_club_id_header(self, mock_get) -> None: + """The `X-Spond-Clubid` header must carry the supplied `club_id`.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await sc.get_transactions(club_id="CLUB_ABC") + + _, kwargs = mock_get.call_args[0], mock_get.call_args[1] + assert kwargs["headers"]["X-Spond-Clubid"] == "CLUB_ABC" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_transactions_skip_param_on_second_page(self, mock_get) -> None: + """On the recursive call, `skip` must equal the number of records + already fetched.""" + sc = SpondClub(MOCK_USERNAME, MOCK_PASSWORD) + sc.token = MOCK_TOKEN + + # First page: 2 items; second page: empty + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[[_TX1, _TX2], []] + ) + + await sc.get_transactions(club_id="CLUB1", max_items=100) + + assert mock_get.call_count == 2 + second_call_params = mock_get.call_args_list[1][1].get("params") + assert second_call_params == {"skip": 2} diff --git a/tests/test_comment.py b/tests/test_comment.py new file mode 100644 index 0000000..27f29fc --- /dev/null +++ b/tests/test_comment.py @@ -0,0 +1,122 @@ +"""Tests for the typed `Comment` model. + +Covers field mapping (alias resolution), natural-key equality with +Post/Event coverage of typed comments, and the resilience defaults +(only `uid` is strictly required).""" + +from __future__ import annotations + +from datetime import datetime + +from spond.comment import Comment +from spond.event import Event +from spond.post import Post + +from .conftest import _MIN_EVENT_PAYLOAD + +_RAW_COMMENT = { + "id": "CMT1", + "fromProfileId": "PROF1", + "timestamp": "2026-05-15T10:00:00Z", + "text": "Looks great!", + "reactions": {}, +} + + +class TestCommentParsing: + def test_parses_all_fields(self) -> None: + c = Comment.model_validate(_RAW_COMMENT) + assert c.uid == "CMT1" + assert c.from_profile_uid == "PROF1" + assert c.text == "Looks great!" + assert c.reactions == {} + assert c.timestamp is not None + assert c.timestamp.year == 2026 + + def test_minimal_only_uid_required(self) -> None: + """All non-uid fields have defaults; only `id` is strictly required. + Locks in the resilience guarantee against API drift.""" + c = Comment.model_validate({"id": "CMT_MIN"}) + assert c.uid == "CMT_MIN" + assert c.from_profile_uid is None + assert c.text == "" + assert c.timestamp is None + assert c.reactions == {} + + def test_str_includes_uid_and_snippet(self) -> None: + c = Comment.model_validate(_RAW_COMMENT) + s = str(c) + assert "CMT1" in s + assert "Looks great!" in s + + def test_str_handles_missing_timestamp(self) -> None: + """`__str__` must not crash when timestamp is None (same defensive + guard as `Post.__str__` / `Event.__str__`).""" + c = Comment.model_validate({"id": "X", "text": "no ts"}) + assert "?" in str(c) + assert "no ts" in str(c) + + def test_long_text_is_truncated_in_str(self) -> None: + c = Comment.model_validate({"id": "X", "text": "x" * 200}) + s = str(c) + assert "…" in s + assert len(s) < 200 + + def test_extra_fields_preserved(self) -> None: + """`extra="allow"` lets Spond add fields without crashing + validation. The extras are reachable via the dict-compat shim.""" + c = Comment.model_validate({**_RAW_COMMENT, "futureField": 42}) + assert "futureField" in c + assert c.__pydantic_extra__["futureField"] == 42 + + +class TestCommentNaturalKey: + """Comment uses uid when set, else (from_profile_uid, timestamp, text).""" + + def test_same_uid_equal_regardless_of_state(self) -> None: + a = Comment.model_validate({"id": "CMT1", "text": "hi"}) + b = Comment.model_validate({"id": "CMT1", "text": "different"}) + assert a == b + assert hash(a) == hash(b) + + def test_unsaved_equal_by_natural_key(self) -> None: + ts = datetime.fromisoformat("2026-05-15T10:00:00+00:00") + a = Comment(uid="", from_profile_uid="P1", timestamp=ts, text="hi") + b = Comment(uid="", from_profile_uid="P1", timestamp=ts, text="hi") + assert a == b + + +class TestCommentsOnPost: + """`Post.comments` must materialize as typed `Comment` instances.""" + + def test_post_comments_are_typed(self) -> None: + raw = { + "id": "POST1", + "type": "PLAIN", + "comments": [_RAW_COMMENT, {"id": "CMT2", "text": "second"}], + } + p = Post.model_validate(raw) + assert len(p.comments) == 2 + assert all(isinstance(c, Comment) for c in p.comments) + assert p.comments[0].text == "Looks great!" + assert p.comments[1].uid == "CMT2" + + def test_post_with_no_comments_defaults_to_empty_list(self) -> None: + p = Post.model_validate({"id": "P1"}) + assert p.comments == [] + + +class TestCommentsOnEvent: + """`Event.comments` must materialize as typed `Comment` instances too — + same shape across the two parent kinds.""" + + def test_event_comments_are_typed(self) -> None: + raw = {**_MIN_EVENT_PAYLOAD, "comments": [_RAW_COMMENT]} + e = Event.model_validate(raw) + assert len(e.comments) == 1 + assert isinstance(e.comments[0], Comment) + assert e.comments[0].text == "Looks great!" + + def test_event_with_no_comments_defaults_to_empty_list(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert e.comments == [] diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..3c9f21d --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,299 @@ +"""Tests for the `DictCompatModel` shim — the bridge that lets pre-OO +callers keep using dict-style subscript/`.get()`/`in`/`len()`/`iter()` +against typed Pydantic models while emitting `DeprecationWarning`s. + +Also covers `extra="allow"` forward-compat behaviour and a couple of +Event-update-payload regression guards (which exercise the same +shim machinery via `model_dump(exclude_unset=True, exclude_none=True)`).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.event import Event +from spond.group import Group +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +class TestDictCompat: + """The DictCompatModel shim makes typed models behave like the dicts they + replaced, with a DeprecationWarning on subscript and `.get()`.""" + + def test_subscript_via_alias_warns_and_returns_value(self) -> None: + import warnings as _w + + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["id"] # API alias + assert value == "ID1" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_subscript_via_python_name_warns_and_returns_value(self) -> None: + import warnings as _w + + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["heading"] + assert value == "Event One" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_subscript_missing_key_raises_keyerror(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + with pytest.raises(KeyError): + _ = e["does_not_exist"] + + def test_get_with_default_returns_default_for_missing_key(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + sentinel = object() + assert e.get("does_not_exist", sentinel) is sentinel + + def test_contains_works_for_alias_and_python_name(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert "id" in e # alias + assert "heading" in e # python name + assert "startTimestamp" in e # alias + assert "start_time" in e # python name + assert "does_not_exist" not in e + + def test_iter_yields_api_shaped_keys(self) -> None: + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert "id" in keys # alias, not "uid" + assert "startTimestamp" in keys # alias, not "start_time" + + def test_len_contains_and_iter_agree(self) -> None: + """`__len__`, `__contains__`, and `__iter__` must all reflect the + same view of "what's actually in this object" — pre-OO callers + relied on dict semantics where these three are always consistent.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + keys = list(e) + assert len(e) == len(keys) + for k in keys: + assert k in e + # A field with a default that wasn't in the source data must NOT + # appear in any of the three views. + assert "description" not in e # not in _MIN_EVENT_PAYLOAD + assert "description" not in keys + + def test_extra_allow_preserves_unmodelled_fields(self) -> None: + """With `model_config = extra="allow"`, Spond-side fields the SDK + doesn't model are preserved on the instance and accessible via the + dict-compat shim (with deprecation warning).""" + import warnings as _w + + payload = {**_MIN_EVENT_PAYLOAD, "futureSpondField": "preserved"} + e = Event.model_validate(payload) + # Iteration includes the extra + assert "futureSpondField" in e + assert "futureSpondField" in list(e) + with _w.catch_warnings(record=True) as caught: + _w.simplefilter("always") + value = e["futureSpondField"] + assert value == "preserved" + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + + def test_models_survive_missing_optional_fields(self) -> None: + """A previously-required field dropping to a default must not crash + the typed model. Locks in the resilience relaxation done after + rounds of review feedback.""" + from spond.person import Member + from spond.post import Post + from spond.profile import Profile + + # Member with no name fields — used to crash, now defaults to "" + m = Member.model_validate({"id": "M1"}) + assert m.first_name == "" + assert m.last_name == "" + # Post without timestamp — used to crash, now None + p = Post.model_validate({"id": "P1"}) + assert p.timestamp is None + # __str__ must not raise even though timestamp is None — guards the + # AttributeError that resilience relaxation otherwise re-introduced. + assert "?" in str(p) + # Profile with no name fields — same relaxation + pr = Profile.model_validate({"id": "PR1"}) + assert pr.first_name == "" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_excludes_unset_default_collections( + self, mock_post, mock_token + ) -> None: + """`Event.update()` must NOT send empty-list defaults for fields + the source API didn't include (e.g. `owners=[]`, `attachments=[]`). + Spond could interpret an explicit empty list as 'clear all + owners', overwriting concurrent server-side changes. + Regression guard for the `exclude_unset=True` fix.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # _MIN_EVENT_PAYLOAD doesn't include `owners` or `attachments`, so + # the Event has them at their default ([]). They must NOT round-trip. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.owners == [] + assert event.attachments == [] + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + await event.update(heading="Renamed") + + posted = mock_post.call_args[1]["json"] + # The caller's update was applied + assert posted["heading"] == "Renamed" + # Empty-list defaults were NOT sent (because they weren't in the source) + assert "owners" not in posted + assert "attachments" not in posted + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_excludes_none_fields( + self, mock_post, mock_token + ) -> None: + """`Event.update()` must NOT send `null` for optional fields that + Spond didn't populate — Spond could interpret `null` as 'clear this + field' rather than 'leave unchanged'.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # _MIN_EVENT_PAYLOAD has no `description`, so Event.description=None. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.description is None + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + await event.update(heading="Renamed") + + # The POST payload must contain `heading` (caller updated it) but + # NOT `description` (it was None and shouldn't be cleared). + posted = mock_post.call_args[1]["json"] + assert posted["heading"] == "Renamed" + assert "description" not in posted + + def test_extra_allow_on_profile_and_group(self) -> None: + """`extra="allow"` must work uniformly across top-level types, not + just Event — this is the forward-compat invariant for the whole + public surface.""" + from spond.profile import Profile + + p = Profile.model_validate( + {"id": "P1", "firstName": "A", "lastName": "B", "newSpondField": 42} + ) + assert "newSpondField" in p + # `__pydantic_extra__` is the portable accessor for `extra="allow"` + # extras — native attribute access (`p.newSpondField`) works on + # current Pydantic 2.x but behaviour varies subtly across minor + # versions for camelCase keys, so this is the version-stable form. + assert p.__pydantic_extra__["newSpondField"] == 42 + + g = Group.model_validate({"id": "G1", "name": "GG", "newGroupAttr": ["x"]}) + assert "newGroupAttr" in g + assert g.__pydantic_extra__["newGroupAttr"] == ["x"] + + def test_contains_non_string_key_returns_false(self) -> None: + """`__contains__` with a non-string key must return False without + raising — dict-compat callers may inadvertently pass ints.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert 42 not in e + assert None not in e + + def test_keys_returns_api_shaped_list(self) -> None: + """`DictCompatModel.keys()` yields API-alias names for fields that + were populated from source data, matching `.keys()` on a raw dict.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + k = e.keys() + assert isinstance(k, list) + assert "id" in k # alias, not "uid" + assert "heading" in k + assert "startTimestamp" in k # alias, not "start_time" + assert "description" not in k # absent from _MIN_EVENT_PAYLOAD + + def test_values_returns_values_for_set_fields(self) -> None: + """`DictCompatModel.values()` returns the values for fields present + in the source payload, in field-declaration order.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + vals = e.values() + assert isinstance(vals, list) + # "ID1" must be among the values (it's `uid`, present in source) + assert "ID1" in vals + assert "Event One" in vals # heading + + def test_items_returns_key_value_pairs(self) -> None: + """`DictCompatModel.items()` returns `(api_key, value)` tuples for + every field present in the source data, mirroring dict.items().""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + items = dict(e.items()) + assert items["id"] == "ID1" + assert items["heading"] == "Event One" + assert "description" not in items # not in source + + def test_items_includes_extra_fields(self) -> None: + """Extra (unmodelled) fields surface in `.items()` with their + original key names.""" + payload = {**_MIN_EVENT_PAYLOAD, "customExtra": "hello"} + e = Event.model_validate(payload) + items = dict(e.items()) + assert items["customExtra"] == "hello" + + +class TestLenientDate: + """Unit tests for the `_parse_date_lenient` validator used by + `LenientDate` fields on `Member` and `Profile`.""" + + def test_none_passes_through(self) -> None: + from spond._compat import _parse_date_lenient + + assert _parse_date_lenient(None) is None + + def test_date_object_passes_through(self) -> None: + from datetime import date + + from spond._compat import _parse_date_lenient + + d = date(2000, 6, 15) + assert _parse_date_lenient(d) is d + + def test_valid_iso_string_parsed(self) -> None: + from datetime import date + + from spond._compat import _parse_date_lenient + + result = _parse_date_lenient("1995-03-22") + assert result == date(1995, 3, 22) + + def test_invalid_date_string_returns_none(self) -> None: + """Malformed dates (e.g. impossible day) must not raise — return None.""" + from spond._compat import _parse_date_lenient + + assert _parse_date_lenient("2012-03-99") is None + assert _parse_date_lenient("not-a-date") is None + + def test_non_string_non_date_returns_none(self) -> None: + """Non-string, non-date values (e.g. an int) must return None rather + than raising TypeError.""" + from spond._compat import _parse_date_lenient + + assert _parse_date_lenient(20001231) is None + assert _parse_date_lenient([]) is None + + def test_lenient_date_field_on_member(self) -> None: + """A malformed `dateOfBirth` in a Member payload must not crash + validation — the field silently becomes None.""" + from spond.person import Member + + m = Member.model_validate({"id": "M1", "dateOfBirth": "2012-03-99"}) + assert m.date_of_birth is None + + def test_lenient_date_field_on_profile(self) -> None: + """Same resilience on Profile.date_of_birth.""" + from spond.profile import Profile + + p = Profile.model_validate({"id": "P1", "dateOfBirth": "invalid"}) + assert p.date_of_birth is None diff --git a/tests/test_context_manager.py b/tests/test_context_manager.py new file mode 100644 index 0000000..37e95ca --- /dev/null +++ b/tests/test_context_manager.py @@ -0,0 +1,69 @@ +"""Tests for the `async with Spond(...)` context-manager shape. + +`__aenter__` returns self; `__aexit__` closes the aiohttp ClientSession. +""" + +from __future__ import annotations + +import pytest + +from spond.club import SpondClub +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestSpondAsContextManager: + @pytest.mark.asyncio + async def test_enter_returns_self(self) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + async with s as ctx: + assert ctx is s + + @pytest.mark.asyncio + async def test_session_closed_on_exit(self) -> None: + """The clientsession must be closed when the `with` block exits.""" + async with Spond(MOCK_USERNAME, MOCK_PASSWORD) as s: + assert not s.clientsession.closed + assert s.clientsession.closed + + @pytest.mark.asyncio + async def test_session_closed_even_on_exception(self) -> None: + """Cleanup must fire even when the body raises — that's the + whole point of `async with`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + with pytest.raises(RuntimeError, match="intentional"): + async with s: + raise RuntimeError("intentional") + assert s.clientsession.closed + + @pytest.mark.asyncio + async def test_double_close_does_not_raise_and_skips_second_close(self) -> None: + """If the caller manually closed the session inside the block, + `__aexit__` must (a) not blow up on top of it, and (b) actually + skip the redundant close — exercised via a spy on `close()` + that counts how many times it's invoked.""" + from unittest.mock import AsyncMock + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + async with s: + await s.clientsession.close() + # Install a spy on close() AFTER the manual close, so the + # counter starts at zero and we can verify __aexit__'s + # closed-check skips the call. + spy = AsyncMock() + s.clientsession.close = spy + # No exception escaped, AND __aexit__ saw closed=True and + # never called the real close() a second time. + assert spy.await_count == 0, ( + "__aexit__ should have skipped close() because clientsession " + "was already closed; instead it called close() " + f"{spy.await_count} times" + ) + + @pytest.mark.asyncio + async def test_spondclub_also_supports_context_manager(self) -> None: + """Both subclasses of `_SpondBase` inherit the shape.""" + async with SpondClub(MOCK_USERNAME, MOCK_PASSWORD) as sc: + assert not sc.clientsession.closed + assert sc.clientsession.closed diff --git a/tests/test_event_convenience.py b/tests/test_event_convenience.py new file mode 100644 index 0000000..6bbf2fb --- /dev/null +++ b/tests/test_event_convenience.py @@ -0,0 +1,192 @@ +"""Tests for the synchronous convenience properties on `Event`: +`is_past`, `is_upcoming`, `duration`, `has_responded(uid)`, +`response_for(uid)`. + +All five are pure-Python: no HTTP, no client required. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from spond.event import Event + +from .conftest import _MIN_EVENT_PAYLOAD + + +def _event_at(start: datetime, end: datetime | None = None, **overrides) -> Event: + """Build an Event with the given start/end and arbitrary overrides.""" + payload = { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": start.isoformat(), + "endTimestamp": (end or start + timedelta(hours=1)).isoformat(), + **overrides, + } + return Event.model_validate(payload) + + +class TestIsPast: + def test_event_ending_in_past_is_past(self) -> None: + past_start = datetime.now(UTC) - timedelta(days=2) + past_end = past_start + timedelta(hours=1) + assert _event_at(past_start, past_end).is_past + + def test_event_ending_in_future_is_not_past(self) -> None: + future_start = datetime.now(UTC) + timedelta(days=2) + future_end = future_start + timedelta(hours=1) + assert not _event_at(future_start, future_end).is_past + + def test_event_without_start_or_end_is_not_past(self) -> None: + """A half-populated record (no start/end) is never "past" — we don't + know when it happens, so we don't claim it's over.""" + e = Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "startTimestamp": None, "endTimestamp": None} + ) + assert not e.is_past + + def test_event_with_only_start_time_uses_start(self) -> None: + """If `end_time` is unset, `is_past` falls back to `start_time`.""" + past_start = datetime.now(UTC) - timedelta(days=2) + e = Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": past_start.isoformat(), + "endTimestamp": None, + } + ) + assert e.is_past + + +class TestIsUpcoming: + def test_future_event_is_upcoming(self) -> None: + future = datetime.now(UTC) + timedelta(days=2) + assert _event_at(future).is_upcoming + + def test_past_event_is_not_upcoming(self) -> None: + past = datetime.now(UTC) - timedelta(days=2) + assert not _event_at(past).is_upcoming + + def test_event_without_start_is_not_upcoming(self) -> None: + """Symmetric with `is_past=False` for the no-time case — neither + property fires when both start_time and end_time are None.""" + e = Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "startTimestamp": None, "endTimestamp": None} + ) + assert not e.is_upcoming + assert not e.is_past + + +class TestDuration: + def test_duration_returns_timedelta(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + end = datetime(2026, 6, 1, 11, 30, tzinfo=UTC) + e = _event_at(start, end) + assert e.duration == timedelta(hours=1, minutes=30) + + def test_duration_is_none_when_end_time_missing(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + e = Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": start.isoformat(), + "endTimestamp": None, + } + ) + assert e.duration is None + + def test_duration_is_none_when_start_time_missing(self) -> None: + end = datetime(2026, 6, 1, 11, 0, tzinfo=UTC) + e = Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "startTimestamp": None, + "endTimestamp": end.isoformat(), + } + ) + assert e.duration is None + + +class TestResponseFor: + """`response_for(uid)` returns the bucket the uid lives in.""" + + def _event_with_responses(self) -> Event: + return Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "responses": { + "acceptedIds": ["A1", "A2"], + "declinedIds": ["D1"], + "unansweredIds": ["U1"], + "waitinglistIds": ["W1"], + "unconfirmedIds": ["X1"], + }, + } + ) + + def test_accepted_uid_returns_accepted(self) -> None: + e = self._event_with_responses() + assert e.response_for("A1") == "accepted" + assert e.response_for("A2") == "accepted" + + def test_declined_uid_returns_declined(self) -> None: + assert self._event_with_responses().response_for("D1") == "declined" + + def test_unanswered_uid_returns_unanswered(self) -> None: + assert self._event_with_responses().response_for("U1") == "unanswered" + + def test_waiting_list_uid_returns_waiting_list(self) -> None: + assert self._event_with_responses().response_for("W1") == "waiting_list" + + def test_unconfirmed_uid_returns_unconfirmed(self) -> None: + assert self._event_with_responses().response_for("X1") == "unconfirmed" + + def test_unknown_uid_returns_none(self) -> None: + assert self._event_with_responses().response_for("NOPE") is None + + def test_no_responses_returns_none(self) -> None: + """An event with default-empty responses returns None for any uid.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert e.response_for("ANYONE") is None + + +class TestHasResponded: + """`has_responded(uid)` is True for any concrete response other than + `unanswered`.""" + + def _event_with_responses(self) -> Event: + return Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "responses": { + "acceptedIds": ["A1"], + "declinedIds": ["D1"], + "unansweredIds": ["U1"], + "waitinglistIds": ["W1"], + "unconfirmedIds": ["X1"], + }, + } + ) + + def test_accepted_has_responded(self) -> None: + assert self._event_with_responses().has_responded("A1") + + def test_declined_has_responded(self) -> None: + assert self._event_with_responses().has_responded("D1") + + def test_waiting_list_has_responded(self) -> None: + assert self._event_with_responses().has_responded("W1") + + def test_unconfirmed_has_responded(self) -> None: + """`unconfirmed` is still a concrete response — only `unanswered` + means "no response given yet".""" + assert self._event_with_responses().has_responded("X1") + + def test_unanswered_has_not_responded(self) -> None: + """The whole point of the `unanswered` bucket: these uids haven't + responded yet.""" + assert not self._event_with_responses().has_responded("U1") + + def test_unknown_uid_has_not_responded(self) -> None: + """An uid not invited to the event returns False — not a typed + record of non-response, but also not a positive response.""" + assert not self._event_with_responses().has_responded("NOPE") diff --git a/tests/test_event_members.py b/tests/test_event_members.py new file mode 100644 index 0000000..82e3ecf --- /dev/null +++ b/tests/test_event_members.py @@ -0,0 +1,184 @@ +"""Tests for the async member-resolution helpers on `Event`: +`accepted_members()`, `declined_members()`, `unanswered_members()`, +`waiting_list_members()`, `unconfirmed_members()`. + +Each resolves the corresponding `responses.*_uids` list to typed +`Member`/`Guardian` objects via the client's group cache, fetching it +lazily if empty. UIDs that no longer correspond to a current group +member are silently dropped (left members aren't an error). +""" + +from __future__ import annotations + +import pytest + +from spond.event import Event +from spond.group import Group +from spond.person import Guardian, Member +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +def _spond_with_group() -> Spond: + """Build a Spond client with one group containing three members and + one guardian, all pre-cached so no HTTP fires.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK_TOKEN" + s.groups = [ + Group.from_api( + { + "id": "GID1", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "A", + "guardians": [ + {"id": "G1", "firstName": "Pat", "lastName": "A"} + ], + }, + {"id": "M2", "firstName": "Bob", "lastName": "B"}, + {"id": "M3", "firstName": "Charlie", "lastName": "C"}, + ], + }, + s, + ) + ] + return s + + +def _event_with_responses( + accepted: list[str] | None = None, + declined: list[str] | None = None, + unanswered: list[str] | None = None, + waiting_list: list[str] | None = None, + unconfirmed: list[str] | None = None, +) -> Event: + return Event.model_validate( + { + **_MIN_EVENT_PAYLOAD, + "responses": { + "acceptedIds": accepted or [], + "declinedIds": declined or [], + "unansweredIds": unanswered or [], + "waitinglistIds": waiting_list or [], + "unconfirmedIds": unconfirmed or [], + }, + } + ) + + +class TestAcceptedMembers: + @pytest.mark.asyncio + async def test_resolves_uids_to_typed_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(accepted=["M1", "M3"]) + e._client = s + + members = await e.accepted_members() + + assert len(members) == 2 + assert all(isinstance(m, Member) for m in members) + uids = [m.uid for m in members] + assert uids == ["M1", "M3"] # preserves order from response list + + @pytest.mark.asyncio + async def test_resolves_guardian_uids(self) -> None: + """A uid that matches a guardian (not a member) returns the + Guardian — same lookup index covers both.""" + s = _spond_with_group() + e = _event_with_responses(accepted=["G1"]) + e._client = s + + members = await e.accepted_members() + assert len(members) == 1 + assert isinstance(members[0], Guardian) + assert members[0].uid == "G1" + + @pytest.mark.asyncio + async def test_unknown_uids_are_silently_dropped(self) -> None: + """A uid in the response list that no longer matches any group + member (e.g. they left the group) is silently omitted — the rest + still resolve.""" + s = _spond_with_group() + e = _event_with_responses(accepted=["M1", "EXMEMBER", "M2"]) + e._client = s + + members = await e.accepted_members() + assert [m.uid for m in members] == ["M1", "M2"] + + @pytest.mark.asyncio + async def test_empty_accepted_list_returns_empty(self) -> None: + s = _spond_with_group() + e = _event_with_responses(accepted=[]) + e._client = s + + assert await e.accepted_members() == [] + + @pytest.mark.asyncio + async def test_no_client_raises_runtime_error(self) -> None: + """An Event constructed without a client (e.g. via + `model_validate(raw)`) can't fetch groups.""" + e = _event_with_responses(accepted=["M1"]) + # _client is None + with pytest.raises(RuntimeError, match="no client attached"): + await e.accepted_members() + + @pytest.mark.asyncio + async def test_empty_groups_cache_returns_empty(self) -> None: + """When the client has no groups at all (or `get_groups()` returns + None), all helpers return an empty list rather than raising.""" + from unittest.mock import AsyncMock + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK_TOKEN" + s.groups = None + s.get_groups = AsyncMock(return_value=None) + + e = _event_with_responses(accepted=["ANY"]) + e._client = s + + assert await e.accepted_members() == [] + + +class TestSiblingHelpers: + """Verify the other four siblings route to their respective uid lists.""" + + @pytest.mark.asyncio + async def test_declined_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(declined=["M2"]) + e._client = s + + members = await e.declined_members() + assert [m.uid for m in members] == ["M2"] + + @pytest.mark.asyncio + async def test_unanswered_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(unanswered=["M3", "M1"]) + e._client = s + + members = await e.unanswered_members() + assert [m.uid for m in members] == ["M3", "M1"] + + @pytest.mark.asyncio + async def test_waiting_list_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(waiting_list=["M2"]) + e._client = s + + members = await e.waiting_list_members() + assert [m.uid for m in members] == ["M2"] + + @pytest.mark.asyncio + async def test_unconfirmed_members(self) -> None: + s = _spond_with_group() + e = _event_with_responses(unconfirmed=["G1"]) + e._client = s + + members = await e.unconfirmed_members() + assert [m.uid for m in members] == ["G1"] + assert isinstance(members[0], Guardian) diff --git a/tests/test_event_save_delete.py b/tests/test_event_save_delete.py new file mode 100644 index 0000000..3dccd8a --- /dev/null +++ b/tests/test_event_save_delete.py @@ -0,0 +1,426 @@ +"""Tests for the ActiveRecord write surface: `Event.save()` and +`Event.delete()`. + +`save()` is the universal create-or-update operation: dispatches on +`self.uid` presence (empty → POST `/sponds/` to create; set → POST +`/sponds/{uid}` via the existing `update()` machinery). On create it +mutates `self` in place with the persisted state from Spond and binds +the client. + +`delete()` issues DELETE `/sponds/{uid}` and prunes the event from the +client's `events` cache so subsequent `get_event(uid)` lookups raise +`EventNotFoundError`. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import EventNotFoundError, SpondAPIError +from spond.event import Event +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_USERNAME + + +def _fresh_event() -> Event: + """Build an unsaved Event (no uid) suitable for the create path.""" + return Event( + uid="", + heading="New Event", + start_time=datetime(2026, 6, 1, 10, 0, tzinfo=UTC), + end_time=datetime(2026, 6, 1, 11, 0, tzinfo=UTC), + type="EVENT", + owners=[{"id": "PROFILE1", "response": "accepted"}], + recipients={"group": {"id": "GROUP1"}}, + ) + + +class TestSaveCreate: + """Create path: `event.save()` on an instance with no uid.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_populates_uid_in_place(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + # Spond returns the created event with a fresh uid + server-managed + # fields populated. + api_response = { + **_MIN_EVENT_PAYLOAD, + "id": "NEWUID", + "heading": "New Event", + "creatorId": "PROFILE1", + "createdTime": "2026-05-14T20:00:00Z", + } + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await event.save(client=s) + + # save() returns self (mutated in place), not a fresh instance. + assert result is event + assert event.uid == "NEWUID" + # Server-managed fields applied to self + assert event.creator_uid == "PROFILE1" + # Client bound for subsequent operations + assert event._client is s + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_posts_to_collection_url(self, mock_post) -> None: + """The create path POSTs to `/sponds/` (no uid in path).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/sponds/"), ( + f"create should POST to /sponds/ (collection), got {called_url}" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_includes_recipients_in_payload(self, mock_post) -> None: + """`recipients` is required for create (and was previously being + stripped by `_EVENT_READ_ONLY_FIELDS`). Locks in the fix.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + posted = mock_post.call_args[1]["json"] + assert "recipients" in posted, ( + "create payload must include recipients — Spond requires it" + ) + assert posted["recipients"] == {"group": {"id": "GROUP1"}} + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_prepends_to_client_cache(self, mock_post) -> None: + """A newly-saved event is prepended to `events` (position 0) so it + appears first in subsequent `get_event(uid)` scans — matches Spond's + own newest-first ordering on `get_events()`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # Pre-existing event in the cache, so we can observe the position + # the new one is inserted at. + existing = Event.from_api({**_MIN_EVENT_PAYLOAD, "id": "EXISTING"}, s) + s.events = [existing] + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + # New event at position 0; existing event slid down. + assert len(s.events) == 2 + assert s.events[0].uid == "NEWUID" + assert s.events[1].uid == "EXISTING" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_caches_self_not_refreshed_copy(self, mock_post) -> None: + """Identity guarantee: after `event.save()`, `event is + s.events[0]` — not a distinct same-state copy. Matches the + identity contract `Post.save()` already enforces; consistency + across ActiveRecord types lets callers rely on a single rule.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = [] + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + assert s.events[0] is event, ( + "cache should hold the saved instance itself, not a copy" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_update_caches_self(self, mock_post) -> None: + """Same identity guarantee on the update path: even though + `save()` delegates to `update()` (which writes a fresh instance + to the cache), `save()` overwrites that slot with self + afterwards.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [event] + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "heading": "Renamed"} + ) + + event.heading = "Renamed" + await event.save() + + assert s.events[0] is event, ( + "after save() the cache must still hold self, not the " + "intermediate instance delegated-update() wrote" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_initialises_empty_cache(self, mock_post) -> None: + """When `s.events is None` (never fetched), create initialises the + cache rather than no-oping.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = None + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "NEWUID"} + ) + + await event.save(client=s) + + assert s.events is not None + assert len(s.events) == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_raises_on_http_error(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + + mock_post.return_value.__aenter__.return_value.ok = False + mock_post.return_value.__aenter__.return_value.status = 500 + mock_post.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Server Error" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await event.save(client=s) + assert exc_info.value.status == 500 + + @pytest.mark.asyncio + async def test_save_without_client_raises(self) -> None: + """Calling `save()` on an unbound new instance without passing + `client=` must raise — there's no way to send the POST.""" + event = _fresh_event() + with pytest.raises(RuntimeError, match="no client bound"): + await event.save() + + +class TestSaveUpdate: + """Update path: `event.save()` on an instance with an existing uid.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_existing_posts_to_uid_url(self, mock_post) -> None: + """A saved Event saves to `/sponds/{uid}` (delegates to `update()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Renamed"} + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + event.heading = "Renamed" + await event.save() + + called_url = mock_post.call_args[0][0] + assert called_url.endswith(f"/sponds/{event.uid}"), ( + f"update should POST to /sponds/{event.uid}, got {called_url}" + ) + assert event.heading == "Renamed" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_persists_mutation_of_unset_field(self, mock_post) -> None: + """Regression guard for the `validate_assignment=True` config: + mutating a field that wasn't in the source payload must still + reach the POST body. Without `validate_assignment=True`, direct + attribute assignment doesn't update `__pydantic_fields_set__`, + and `exclude_unset=True` would silently drop the change.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # `_MIN_EVENT_PAYLOAD` deliberately omits `description`. + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + assert event.description is None + assert "description" not in event.__pydantic_fields_set__ + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + + # Direct attribute assignment to a previously-unset field. + event.description = "Now described" + await event.save() + + # `description` must reach Spond — the silent-drop footgun. + posted = mock_post.call_args[1]["json"] + assert posted.get("description") == "Now described", ( + "mutate-then-save dropped the change; validate_assignment " + "is not enabled or model_fields_set is not being tracked" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_existing_uses_bound_client(self, mock_post) -> None: + """Subsequent saves don't need a `client` argument.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + + # No client kwarg — should use event._client which from_api wired. + await event.save() + mock_post.assert_called_once() + + +class TestDelete: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_issues_delete_request(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [event] + + mock_delete.return_value.__aenter__.return_value.ok = True + mock_delete.return_value.__aenter__.return_value.status = 200 + + await event.delete() + + called_url = mock_delete.call_args[0][0] + assert called_url.endswith(f"/sponds/{event.uid}"), ( + f"delete should DELETE /sponds/{event.uid}, got {called_url}" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_prunes_from_client_cache(self, mock_delete) -> None: + """After delete, the event must no longer be in `client.events` so + a subsequent `get_event(uid)` raises `EventNotFoundError`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # Cache has two events — one we'll delete, one we won't + e1 = Event.from_api({**_MIN_EVENT_PAYLOAD, "id": "TODELETE"}, s) + e2 = Event.from_api({**_MIN_EVENT_PAYLOAD, "id": "KEEP"}, s) + s.events = [e1, e2] + + mock_delete.return_value.__aenter__.return_value.ok = True + + await e1.delete() + + # e1 gone from cache; e2 stays + assert len(s.events) == 1 + assert s.events[0].uid == "KEEP" + + # And get_event(deleted_uid) raises + with pytest.raises(EventNotFoundError): + await s.get_event("TODELETE") + + @pytest.mark.asyncio + async def test_delete_without_client_raises(self) -> None: + event = Event.model_validate(_MIN_EVENT_PAYLOAD) + # No client wired (didn't go through from_api) + with pytest.raises(RuntimeError, match="no client"): + await event.delete() + + @pytest.mark.asyncio + async def test_delete_without_uid_raises(self) -> None: + """Cannot delete an event that was never persisted (no uid).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = _fresh_event() + event._client = s # bound but unsaved + + with pytest.raises(RuntimeError, match="unsaved"): + await event.delete() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_raises_on_http_error(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_delete.return_value.__aenter__.return_value.ok = False + mock_delete.return_value.__aenter__.return_value.status = 403 + mock_delete.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Forbidden" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await event.delete() + assert exc_info.value.status == 403 + + +class TestSaveRoundtrip: + """Combined save/delete roundtrip — the canonical ActiveRecord flow.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.post") + async def test_create_then_save_then_delete(self, mock_post, mock_delete) -> None: + """Construct → save (create) → mutate → save (update) → delete. + The canonical ActiveRecord lifecycle, end to end.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.events = [] + event = _fresh_event() + + # Create: returns with uid + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "C1", "heading": "New Event"} + ) + + await event.save(client=s) + assert event.uid == "C1" + + # Update: mutate + save + event.heading = "Renamed" + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_MIN_EVENT_PAYLOAD, "id": "C1", "heading": "Renamed"} + ) + await event.save() + assert event.heading == "Renamed" + + # Delete + mock_delete.return_value.__aenter__.return_value.ok = True + await event.delete() + assert event.uid not in {e.uid for e in s.events} diff --git a/tests/test_events.py b/tests/test_events.py new file mode 100644 index 0000000..2e4e587 --- /dev/null +++ b/tests/test_events.py @@ -0,0 +1,599 @@ +"""Tests for Event surface — read APIs, deprecated wrappers, and the +ActiveRecord-style methods on the `Event` typed model (including the +`Match` subclass).""" + +from __future__ import annotations + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.event import Event +from spond.spond import Spond + +from .conftest import _MIN_EVENT_PAYLOAD, MOCK_PASSWORD, MOCK_TOKEN, MOCK_USERNAME + + +class TestEventMethods: + @pytest.fixture + def mock_events(self) -> list[Event]: + """Two typed Event instances with placeholder data.""" + return [ + Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "ID1", "heading": "Event One"} + ), + Event.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "ID2", "heading": "Event Two"} + ), + ] + + @pytest.mark.asyncio + async def test_get_event__happy_path( + self, mock_events: list[Event], mock_token + ) -> None: + """Test that a valid `id` returns the matching event.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.events = mock_events + s.token = mock_token + g = await s.get_event("ID1") + + assert isinstance(g, Event) + assert g.uid == "ID1" + assert g.heading == "Event One" + + @pytest.mark.asyncio + async def test_get_event__no_match_raises_exception( + self, mock_events: list[Event], mock_token + ) -> None: + """Test that a non-matched `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.events = mock_events + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_event("ID3") + + @pytest.mark.asyncio + async def test_get_event__blank_id_match_raises_exception( + self, mock_events: list[Event], mock_token + ) -> None: + """Test that a blank `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.events = mock_events + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_event("") + + @pytest.mark.asyncio + async def test_get_event__no_events_available_raises_keyerror( + self, mock_token + ) -> None: + """`get_events()` is documented to return None when no events exist; + `get_event()` should surface this as KeyError, not TypeError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = None + s.get_events = AsyncMock() # leaves self.events as None + + with pytest.raises(KeyError): + await s.get_event("ID1") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_update_event__returns_api_response( + self, mock_post, mock_token + ) -> None: + """Deprecated `Spond.update_event()` should still return the POST response + as a dict for backward compatibility (delegates to `Event.update()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + result = await s.update_event(uid="ID1", updates={"heading": "New"}) + + # Deprecation warning fired + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + # Result is a dict (model_dump output), with the updated heading + assert isinstance(result, dict) + assert result["heading"] == "New" + # Regression guard for issue #239: the result must NOT be the + # cached events list (the bug that was: `return self.events`). + assert result is not s.events + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_change_response(self, mock_put, mock_payload, mock_token) -> None: + """Deprecated `Spond.change_response()` should still PUT to the same URL + and return the API response (delegates to `Event.change_response()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.from_api(_MIN_EVENT_PAYLOAD, s)] + + mock_response_data = { + "acceptedIds": ["PID1", "PID2"], + "declinedIds": ["PID3"], + "unansweredIds": [], + "waitinglistIds": [], + "unconfirmedIds": [], + "declineMessages": {"PID3": "sick cannot make it"}, + } + mock_put.return_value.__aenter__.return_value.status = 200 + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value=mock_response_data + ) + + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + response = await s.change_response( + uid="ID1", user="PID3", payload=mock_payload + ) + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" + # The wrapper forwards `payload` verbatim — same bytes that go on + # the wire on the pre-OO code path. + mock_put.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + json=mock_payload, + ) + assert response == mock_response_data + + +class TestEventOOMethods: + """ActiveRecord methods on Event.""" + + def test_event_str_with_start_time(self) -> None: + """`Event.__str__` includes uid, heading and ISO start_time.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + s = str(e) + assert "ID1" in s + assert "Event One" in s + assert "2026-01-01" in s # from startTimestamp + + def test_event_str_without_start_time(self) -> None: + """`Event.__str__` uses '?' sentinel when start_time is None.""" + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "startTimestamp": None}) + s = str(e) + assert "?" in s + + def test_event_url_property(self) -> None: + """`Event.url` must return the canonical Spond web URL.""" + e = Event.model_validate(_MIN_EVENT_PAYLOAD) + assert e.url == "https://spond.com/client/sponds/ID1/" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_returns_new_event(self, mock_post, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Updated"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await event.update(heading="Updated") + assert isinstance(result, Event) + assert result.heading == "Updated" + assert result is not event # immutable: returns new + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_accepts_positional_dict( + self, mock_post, mock_token + ) -> None: + """`event.update(updates_dict)` works for keys that would clash with + reserved kwargs when passed via `**` (e.g. `self`, `cls`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "New"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + # Use the positional dict form + result = await event.update({"heading": "New", "selfish": "any"}) + assert result.heading == "New" + # Unknown key "selfish" was passed through to the API payload + posted = mock_post.call_args[1]["json"] + assert posted["selfish"] == "any" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_converts_native_python_values( + self, mock_post, mock_token + ) -> None: + """Caller-supplied native Python values (datetime, date, …) must be + JSON-serialised before they hit aiohttp's `json.dumps`. Without + this, `event.update(start_time=datetime(...))` crashes with + `TypeError: Object of type datetime is not JSON serializable`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_MIN_EVENT_PAYLOAD + ) + + new_start = datetime(2027, 3, 15, 10, 0, tzinfo=UTC) + await event.update(start_time=new_start) + + posted = mock_post.call_args[1]["json"] + # The datetime must have been converted to its ISO string form + # (no naked `datetime` instance leaks into the JSON payload). + assert isinstance(posted["startTimestamp"], str) + assert posted["startTimestamp"].startswith("2027-03-15T10:00") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_event_update_refreshes_client_cache( + self, mock_post, mock_token + ) -> None: + """After `event.update()`, the client's events cache must hold the + new instance — not the stale pre-update one — so subsequent + `spond.get_event(uid)` calls return current state.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + original = Event.from_api(_MIN_EVENT_PAYLOAD, s) + s.events = [original] + + api_response = {**_MIN_EVENT_PAYLOAD, "heading": "Refreshed"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await original.update(heading="Refreshed") + # Cache must now point at the new instance (in-place replacement + # preserves the list identity for callers holding `s.events`). + assert s.events is not None + assert s.events[0] is result + assert s.events[0].heading == "Refreshed" + assert s.events[0] is not original + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_event_change_response_accepts(self, mock_put, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"acceptedIds": ["MID1"]} + ) + result = await event.change_response("MID1", accepted=True) + assert result == {"acceptedIds": ["MID1"]} + call_args = mock_put.call_args + assert call_args[0][0].endswith("/sponds/ID1/responses/MID1") + assert call_args[1]["json"]["accepted"] == "true" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_event_change_response_declines_with_message( + self, mock_put, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"declinedIds": ["MID1"]} + ) + await event.change_response("MID1", accepted=False, decline_message="busy") + sent = mock_put.call_args[1]["json"] + assert sent["accepted"] == "false" + assert sent["declineMessage"] == "busy" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_event_attendance_xlsx_returns_bytes( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + event = Event.from_api(_MIN_EVENT_PAYLOAD, s) + + mock_get.return_value.__aenter__.return_value.read = AsyncMock( + return_value=b"PKxlsx-bytes" + ) + data = await event.attendance_xlsx() + assert data == b"PKxlsx-bytes" + assert mock_get.call_args[0][0].endswith("/sponds/ID1/export") + + +class TestMatch: + """Match (Event subclass) dispatch and field parsing.""" + + _MATCH_PAYLOAD = { + **_MIN_EVENT_PAYLOAD, + "matchEvent": True, + "matchInfo": { + "teamName": "Home FC", + "opponentName": "Away FC", + "type": "HOME", + "teamScore": 2, + "opponentScore": 1, + "scoresSet": True, + "scoresFinal": True, + "scoresSetEver": True, + "scoresPublic": True, + }, + } + + def test_dispatch_returns_match_when_match_event_true(self) -> None: + """`Spond.get_events()` constructs `Match` (Event subclass) when the + raw payload has `matchEvent=True`, plain `Event` otherwise.""" + from spond.match import Match, MatchInfo + from spond.spond import _typed_event + + # _typed_event doesn't call methods on the client, so None is fine + # for the dispatch + parsing assertions below. + regular = _typed_event(_MIN_EVENT_PAYLOAD, None) + m = _typed_event(self._MATCH_PAYLOAD, None) + + assert type(regular) is Event + assert isinstance(m, Match) + assert isinstance(m, Event) # subclass relationship + assert isinstance(m.match_info, MatchInfo) + assert m.match_info.team_name == "Home FC" + assert m.match_info.opponent_name == "Away FC" + assert m.match_info.type == "HOME" + assert m.match_info.team_score == 2 + assert m.match_info.opponent_score == 1 + assert m.match_info.scores_final is True + assert m.match_info.scores_public is True + + def test_match_score_update_path_is_through_event_update(self) -> None: + """Match inherits Event.update; the `match_info` field must be + included in the POST payload (not in _EVENT_READ_ONLY_FIELDS), so + callers can edit scores via `match.update(matchInfo={...})`.""" + from spond.event import _EVENT_READ_ONLY_FIELDS + from spond.match import Match + + assert "match_info" not in _EVENT_READ_ONLY_FIELDS + # And it really is a declared field on Match (vs an unmodelled + # passthrough via extra="allow"): + assert "match_info" in Match.model_fields + + def test_match_info_optional_for_resilience(self) -> None: + """A future API variant emitting matchEvent=True without matchInfo + (or a half-populated match record) must not crash construction.""" + from spond.match import Match + from spond.spond import _typed_event + + bare = _typed_event({**_MIN_EVENT_PAYLOAD, "matchEvent": True}, None) + assert isinstance(bare, Match) + assert bare.match_info is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_match_update_preserves_match_type( + self, mock_post, mock_token + ) -> None: + """`match.update(...)` must return a `Match` instance, not a plain + `Event` — otherwise subclass identity is silently dropped and the + cache replacement loop demotes the entry to a non-Match. Regression + guard for the `type(self).from_api(...)` fix.""" + from spond.match import Match + from spond.spond import _typed_event + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + original = _typed_event(self._MATCH_PAYLOAD, s) + assert isinstance(original, Match) + s.events = [original] + + response = {**self._MATCH_PAYLOAD, "heading": "Updated Match"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=response + ) + + result = await original.update(heading="Updated Match") + assert isinstance(result, Match), f"Got {type(result).__name__}, expected Match" + # And the cache entry got swapped to the new Match instance, not a demoted Event. + assert isinstance(s.events[0], Match) + assert s.events[0] is result + + +class TestGetEventsHTTP: + """Tests for the `Spond.get_events()` HTTP-fetch path — query parameter + construction, error surfacing, and cache management.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_happy_path(self, mock_get) -> None: + """Two events come back from the API as typed Event objects and are + cached on `self.events`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + raw = [ + {**_MIN_EVENT_PAYLOAD, "id": "E1", "heading": "First"}, + {**_MIN_EVENT_PAYLOAD, "id": "E2", "heading": "Second"}, + ] + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=raw) + + events = await s.get_events() + + assert events is not None + assert len(events) == 2 + assert all(isinstance(e, Event) for e in events) + assert events[0].uid == "E1" + assert events[1].heading == "Second" + assert s.events is events # cache identity + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_returns_none_when_api_returns_null( + self, mock_get + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) + + events = await s.get_events() + assert events is None + assert s.events is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_api_error_raises_valueerror(self, mock_get) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 403 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Forbidden" + ) + + with pytest.raises(ValueError, match="403"): + await s.get_events() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_datetime_filters_in_params(self, mock_get) -> None: + """Datetime filter args must be serialised to the `_DT_FORMAT` string + and included as query parameters.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + min_start = datetime(2026, 1, 1, tzinfo=UTC) + max_start = datetime(2026, 6, 30, tzinfo=UTC) + await s.get_events(min_start=min_start, max_start=max_start) + + params = mock_get.call_args[1]["params"] + assert "minStartTimestamp" in params + assert "maxStartTimestamp" in params + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_include_hidden_param(self, mock_get) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_events(include_hidden=True) + + params = mock_get.call_args[1]["params"] + assert params.get("includeHidden") == "true" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_group_and_subgroup_params(self, mock_get) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_events(group_id="GRP1", subgroup_id="SUB1") + + params = mock_get.call_args[1]["params"] + assert params["groupId"] == "GRP1" + assert params["subGroupId"] == "SUB1" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_events_min_end_max_end_params( + self, mock_get, mock_token + ) -> None: + """The `min_end` and `max_end` datetime args must also be serialised + and sent as `minEndTimestamp` / `maxEndTimestamp` query parameters.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + min_end = datetime(2026, 3, 1, tzinfo=UTC) + max_end = datetime(2026, 12, 31, tzinfo=UTC) + await s.get_events(min_end=min_end, max_end=max_end) + + params = mock_get.call_args[1]["params"] + assert "minEndTimestamp" in params + assert "maxEndTimestamp" in params + + @pytest.mark.asyncio + async def test_get_entity_unsupported_type_raises_not_implemented( + self, mock_token + ) -> None: + """Passing an unknown entity-type string to `_get_entity` must raise + `NotImplementedError` rather than silently returning None.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + with pytest.raises(NotImplementedError, match="not supported"): + await s._get_entity("banana", "UID1") + + +class TestGetProfile: + """Tests for `Spond.get_profile()` — HTTP fetch and caching.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_profile_happy_path(self, mock_get) -> None: + """Profile is returned as a typed Profile object and cached on + `self.profile`.""" + from spond.profile import Profile + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + raw = { + "id": "PROF1", + "firstName": "Ola", + "lastName": "Thoresen", + "primaryEmail": "ola@example.invalid", + } + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=raw) + + profile = await s.get_profile() + + assert isinstance(profile, Profile) + assert profile.uid == "PROF1" + assert profile.first_name == "Ola" + assert profile.full_name == "Ola Thoresen" + assert s.profile is profile # cache identity + + def test_profile_str(self) -> None: + """`Profile.__str__` includes uid and full name.""" + from spond.profile import Profile + + p = Profile.model_validate({"id": "P1", "firstName": "Jane", "lastName": "Doe"}) + s = str(p) + assert "P1" in s + assert "Jane Doe" in s diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py new file mode 100644 index 0000000..e340dc6 --- /dev/null +++ b/tests/test_exceptions.py @@ -0,0 +1,175 @@ +"""Tests for the typed exception hierarchy. + +Covers: +- Inheritance graph (every typed exception descends from `SpondError`). +- Backward compatibility: `*NotFoundError` is still a `KeyError`, + `SpondAPIError` is still a `ValueError`, and `AuthenticationError` + is still importable from `spond` top-level. +- Raise sites use the typed forms (`get_event` raises `EventNotFoundError`, + `get_person` raises `PersonNotFoundError`, `get_posts` HTTP failure + raises `SpondAPIError`). +""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import ( + AuthenticationError, + ChatNotFoundError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + SpondAPIError, + SpondError, + SpondNotFoundError, +) +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestExceptionHierarchy: + """The hierarchy must be coherent: catching the base catches everything.""" + + def test_all_descend_from_spond_error(self) -> None: + for cls in ( + AuthenticationError, + SpondAPIError, + SpondNotFoundError, + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + ChatNotFoundError, + ): + assert issubclass(cls, SpondError), ( + f"{cls.__name__} must descend from SpondError" + ) + + def test_not_found_exceptions_are_keyerror(self) -> None: + """Pre-OO callers wrote `except KeyError:` — the typed forms must + remain compatible with that pattern.""" + for cls in ( + EventNotFoundError, + GroupNotFoundError, + PersonNotFoundError, + ChatNotFoundError, + SpondNotFoundError, + ): + assert issubclass(cls, KeyError), ( + f"{cls.__name__} must inherit from KeyError" + ) + + def test_api_error_is_valueerror(self) -> None: + """Pre-OO callers wrote `except ValueError:` for HTTP failures — + `SpondAPIError` must remain compatible.""" + assert issubclass(SpondAPIError, ValueError) + + def test_api_error_carries_status_body_url(self) -> None: + exc = SpondAPIError(401, "Unauthorized", "https://api.spond.com/test") + assert exc.status == 401 + assert exc.body == "Unauthorized" + assert exc.url == "https://api.spond.com/test" + # And the legacy message shape is preserved for substring-matchers + assert "401" in str(exc) + assert "Unauthorized" in str(exc) + + def test_api_error_truncates_long_body(self) -> None: + long_body = "x" * 10000 + exc = SpondAPIError(500, long_body) + # body attr keeps the full string, but the str() form is bounded + assert exc.body == long_body + assert len(str(exc)) < 1500 + + def test_authentication_error_still_top_level_importable(self) -> None: + """Pre-OO callers do `from spond import AuthenticationError` — that + import path must keep working through the v1.x deprecation cycle.""" + # Already imported at module level; just assert it's the same class + from spond.exceptions import AuthenticationError as AE2 + + assert AuthenticationError is AE2 + + +class TestRaiseSitesUseTypedExceptions: + """Verify the production raise sites use the typed forms, not bare + stdlib classes.""" + + @pytest.mark.asyncio + async def test_get_event_raises_event_not_found(self, mock_token) -> None: + from spond.event import Event + + from .conftest import _MIN_EVENT_PAYLOAD + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # Non-empty cache so the re-fetch doesn't fire; just no match. + s.events = [Event.model_validate(_MIN_EVENT_PAYLOAD)] + + with pytest.raises(EventNotFoundError): + await s.get_event("NOSUCHID") + + @pytest.mark.asyncio + async def test_get_event_caught_by_keyerror(self, mock_token) -> None: + """Backward compat: `except KeyError:` still works for `get_event`.""" + from spond.event import Event + + from .conftest import _MIN_EVENT_PAYLOAD + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.events = [Event.model_validate(_MIN_EVENT_PAYLOAD)] + + with pytest.raises(KeyError): + await s.get_event("NOSUCHID") + + @pytest.mark.asyncio + async def test_get_group_raises_group_not_found(self, mock_token) -> None: + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [Group.model_validate({"id": "EXISTS"})] + + with pytest.raises(GroupNotFoundError): + await s.get_group("NOSUCHID") + + @pytest.mark.asyncio + async def test_get_person_raises_person_not_found(self, mock_token) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [] + s.get_groups = AsyncMock(return_value=None) + + with pytest.raises(PersonNotFoundError): + await s.get_person("anyone") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_http_failure_raises_spond_api_error( + self, mock_get, mock_token + ) -> None: + """`get_posts` HTTP failure path now raises `SpondAPIError`. + Verify both the typed form and the legacy `ValueError` catch path.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 503 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Service Unavailable" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await s.get_posts() + + assert exc_info.value.status == 503 + assert "Service Unavailable" in exc_info.value.body + + # And the legacy `except ValueError:` shape still works + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Service Unavailable" + ) + with pytest.raises(ValueError): # noqa: PT011 — testing inheritance + await s.get_posts() diff --git a/tests/test_export.py b/tests/test_export.py new file mode 100644 index 0000000..bbf6bea --- /dev/null +++ b/tests/test_export.py @@ -0,0 +1,53 @@ +"""Tests for the deprecated event-attendance xlsx export wrapper. + +The OO-rewrite path is covered by `TestEventOOMethods` (the `Event.attendance_xlsx()` +method) — this file pins down the *backward-compat shape* of +`Spond.get_event_attendance_xlsx()` so callers on the old API surface keep +working until the deprecation cycle completes.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestExportMethod: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_export(self, mock_get, mock_token) -> None: + """Deprecated `Spond.get_event_attendance_xlsx()` should still GET the + export endpoint and return raw bytes (delegates to + `Event.attendance_xlsx()`).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + # Note: `s.events` is intentionally not pre-populated — the + # deprecated wrapper does a direct GET on the export endpoint, it + # doesn't consult the events cache. + + mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld + mock_get.return_value.__aenter__.return_value.status = 200 + mock_get.return_value.__aenter__.return_value.read = AsyncMock( + return_value=mock_binary + ) + + import warnings as _warnings + + with _warnings.catch_warnings(record=True) as caught: + _warnings.simplefilter("always") + data = await s.get_event_attendance_xlsx(uid="ID1") + + assert any(issubclass(w.category, DeprecationWarning) for w in caught) + mock_url = "https://api.spond.com/core/v1/sponds/ID1/export" + mock_get.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + ) + assert data == mock_binary diff --git a/tests/test_group_navigation_helpers.py b/tests/test_group_navigation_helpers.py new file mode 100644 index 0000000..4fd362e --- /dev/null +++ b/tests/test_group_navigation_helpers.py @@ -0,0 +1,222 @@ +"""Tests for the new Group navigation helpers and typed `FieldDef`. + +Adapted from the patterns in elliot-100/Spond-classes +(`member_by_uid`, `role_by_uid`, `subgroup_by_uid`, +`members_by_subgroup`, `members_by_role`) — they encode common +"membership graph navigation" queries that every caller would +otherwise write inline. +""" + +from __future__ import annotations + +from spond.field_def import FieldDef +from spond.group import Group +from spond.role import Role +from spond.subgroup import Subgroup + +_GROUP_PAYLOAD = { + "id": "GID", + "name": "Test Group", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "A", + "roles": ["R1"], + "subGroups": ["S1", "S2"], + }, + { + "id": "M2", + "firstName": "Bob", + "lastName": "B", + "roles": ["R2"], + "subGroups": ["S1"], + }, + { + "id": "M3", + "firstName": "Carol", + "lastName": "C", + "roles": [], + "subGroups": ["S2"], + }, + ], + "roles": [ + {"id": "R1", "name": "Coach"}, + {"id": "R2", "name": "Treasurer"}, + ], + "subGroups": [ + {"id": "S1", "name": "Team A"}, + {"id": "S2", "name": "Team B"}, + ], + "fieldDefs": [ + {"id": "F1", "name": "Shirt size"}, + {"id": "F2", "name": "Emergency contact"}, + ], +} + + +class TestRoleByUid: + def test_returns_role_when_present(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + r = g.role_by_uid("R1") + assert isinstance(r, Role) + assert r.name == "Coach" + + def test_returns_none_when_missing(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.role_by_uid("NOSUCH") is None + + def test_returns_none_on_empty_group(self) -> None: + g = Group.model_validate({"id": "X", "name": "Empty"}) + assert g.role_by_uid("ANY") is None + + +class TestSubgroupByUid: + def test_returns_subgroup_when_present(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + sg = g.subgroup_by_uid("S2") + assert isinstance(sg, Subgroup) + assert sg.name == "Team B" + + def test_returns_none_when_missing(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.subgroup_by_uid("NOSUCH") is None + + +class TestMemberByUid: + def test_returns_member_when_present(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + m = g.member_by_uid("M2") + assert m is not None + assert m.full_name == "Bob B" + + def test_returns_none_when_missing(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.member_by_uid("NOSUCH") is None + + def test_matches_find_member_uid_shape(self) -> None: + """`member_by_uid(x)` is documented as a shorthand for + `find_member(uid=x)` — verify they return the same result.""" + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.member_by_uid("M1") is g.find_member(uid="M1") + + +class TestMembersBySubgroup: + def test_filters_by_subgroup_object(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + s1 = g.subgroup_by_uid("S1") + members = g.members_by_subgroup(s1) + assert [m.uid for m in members] == ["M1", "M2"] + + def test_filters_by_subgroup_uid_string(self) -> None: + """Either a Subgroup instance OR its uid string works — for + callers who only have a uid (e.g. from `member.subgroup_uids`).""" + g = Group.model_validate(_GROUP_PAYLOAD) + members = g.members_by_subgroup("S2") + assert [m.uid for m in members] == ["M1", "M3"] + + def test_empty_when_no_members_match(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.members_by_subgroup("UNKNOWN") == [] + + def test_empty_when_no_subgroups_at_all(self) -> None: + g = Group.model_validate({"id": "X", "name": "Empty"}) + assert g.members_by_subgroup("ANY") == [] + + +class TestMembersByRole: + def test_filters_by_role_object(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + coach = g.role_by_uid("R1") + coaches = g.members_by_role(coach) + assert [m.uid for m in coaches] == ["M1"] + + def test_filters_by_role_uid_string(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + members = g.members_by_role("R2") + assert [m.uid for m in members] == ["M2"] + + def test_empty_when_no_members_match(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert g.members_by_role("UNKNOWN") == [] + + +class TestFieldDef: + def test_field_defs_materialise_as_typed(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + assert len(g.field_defs) == 2 + assert all(isinstance(fd, FieldDef) for fd in g.field_defs) + assert g.field_defs[0].uid == "F1" + assert g.field_defs[0].name == "Shirt size" + assert g.field_defs[1].name == "Emergency contact" + + def test_empty_when_no_field_defs(self) -> None: + g = Group.model_validate({"id": "X", "name": "No FDs"}) + assert g.field_defs == [] + + def test_field_def_str_contains_uid_and_name(self) -> None: + fd = FieldDef.model_validate({"id": "F1", "name": "Shirt size"}) + s = str(fd) + assert "F1" in s + assert "Shirt size" in s + + def test_field_def_natural_key_uid_based(self) -> None: + a = FieldDef.model_validate({"id": "F1", "name": "Different"}) + b = FieldDef.model_validate({"id": "F1", "name": "Names"}) + assert a == b # same uid → equal + assert hash(a) == hash(b) + + def test_field_def_minimal_only_uid_required(self) -> None: + """Resilience: only `id` required; `name` defaults to empty.""" + fd = FieldDef.model_validate({"id": "F1"}) + assert fd.uid == "F1" + assert fd.name == "" + + def test_field_defs_pair_with_member_custom_fields(self) -> None: + """The motivating use case: render label/value pairs by joining + `group.field_defs` (label) with `member.custom_fields` (value).""" + g = Group.model_validate( + { + **_GROUP_PAYLOAD, + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "A", + "fields": {"F1": "Medium", "F2": "555-1234"}, + } + ], + } + ) + member = g.member_by_uid("M1") + rendered = {fd.name: member.custom_fields.get(fd.uid) for fd in g.field_defs} + assert rendered == {"Shirt size": "Medium", "Emergency contact": "555-1234"} + + +class TestNavigationHelpersCompositeFlow: + """End-to-end: walk subgroups → list members → check their roles. + The motivating use case for the helpers landing together.""" + + def test_walk_subgroups_with_role_lookup(self) -> None: + g = Group.model_validate(_GROUP_PAYLOAD) + results: dict[str, list[str]] = {} + for sg in g.subgroups: + for m in g.members_by_subgroup(sg): + # For each member of this subgroup, what role names do they hold? + role_names = [ + r.name + for r_uid in m.role_uids + if (r := g.role_by_uid(r_uid)) is not None + ] + results.setdefault(sg.name, []).append(f"{m.full_name}: {role_names}") + # Team A (S1) has Alice (Coach) and Bob (Treasurer) + assert "Team A" in results + assert any( + "Alice A" in entry and "Coach" in entry for entry in results["Team A"] + ) + assert any( + "Bob B" in entry and "Treasurer" in entry for entry in results["Team A"] + ) + # Team B (S2) has Alice (Coach) and Carol (no roles) + assert "Team B" in results + assert any("Carol C" in entry and "[]" in entry for entry in results["Team B"]) diff --git a/tests/test_groups.py b/tests/test_groups.py new file mode 100644 index 0000000..8fdf8d6 --- /dev/null +++ b/tests/test_groups.py @@ -0,0 +1,414 @@ +"""Tests for Group surface — read APIs and the inter-dependency navigation +(Group → Member → Guardian).""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest +import pytest_asyncio + +from spond.group import Group +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +class TestGroupMethods: + @pytest.fixture + def mock_groups(self) -> list[Group]: + """Two typed Group instances with placeholder data.""" + return [ + Group.model_validate({"id": "ID1", "name": "Group One"}), + Group.model_validate({"id": "ID2", "name": "Group Two"}), + ] + + @pytest.mark.asyncio + async def test_get_group__happy_path( + self, mock_groups: list[Group], mock_token + ) -> None: + """Test that a valid `id` returns the matching group.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.groups = mock_groups + s.token = mock_token + g = await s.get_group("ID2") + + assert isinstance(g, Group) + assert g.uid == "ID2" + assert g.name == "Group Two" + + @pytest.mark.asyncio + async def test_get_group__no_match_raises_exception( + self, mock_groups: list[Group], mock_token + ) -> None: + """Test that a non-matched `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.groups = mock_groups + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_group("ID3") + + @pytest.mark.asyncio + async def test_get_group__blank_id_raises_exception( + self, mock_groups: list[Group], mock_token + ) -> None: + """Test that a blank `id` raises KeyError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.groups = mock_groups + s.token = mock_token + + with pytest.raises(KeyError): + await s.get_group("") + + @pytest.mark.asyncio + async def test_get_group__no_groups_available_raises_keyerror( + self, mock_token + ) -> None: + """`get_groups()` is documented to return None when no groups exist; + `get_group()` should surface this as KeyError, not TypeError.""" + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = None + # Production code consults `self.groups` (not the return value + # of `get_groups()`), so this mock just needs to be a no-op that + # doesn't trigger a real HTTP call. `s.groups` is already set + # to None on line 76; we never assert on the mock's return. + s.get_groups = AsyncMock(return_value=None) + + with pytest.raises(KeyError): + await s.get_group("ID1") + + +class TestGroupNavigation: + """Group → Member → Guardian wiring.""" + + def test_group_materializes_typed_members_and_guardians(self) -> None: + raw = { + "id": "GID", + "name": "Test Group", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.invalid", + "guardians": [ + { + "id": "G1", + "firstName": "Bob", + "lastName": "Smith", + "phoneNumber": "+1", + } + ], + }, + ], + } + from spond.person import Guardian, Member + + group = Group.model_validate(raw) + assert isinstance(group.members[0], Member) + assert group.members[0].full_name == "Alice Smith" + assert isinstance(group.members[0].guardians[0], Guardian) + assert group.members[0].guardians[0].full_name == "Bob Smith" + + def test_find_member_by_email(self) -> None: + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "email": "a@b.invalid", + }, + ], + } + ) + found = group.find_member(email="a@b.invalid") + assert found is not None + assert found.uid == "M1" + + def test_find_member_returns_none_when_no_match(self) -> None: + group = Group.model_validate({"id": "GID", "name": "G", "members": []}) + assert group.find_member(uid="missing") is None + + def test_find_member_requires_exactly_one_criterion(self) -> None: + group = Group.model_validate({"id": "GID", "name": "G", "members": []}) + with pytest.raises(ValueError, match="exactly one"): + group.find_member() + with pytest.raises(ValueError, match="exactly one"): + group.find_member(uid="X", email="a@b.invalid") + + def test_member_custom_fields_alias_works_via_either_name(self) -> None: + """`Member.custom_fields` aliases the API's `"fields"` key — both + forms must populate the attribute identically.""" + from spond.person import Member + + # API-style (via alias): + m1 = Member.model_validate( + {"id": "M1", "firstName": "A", "lastName": "B", "fields": {"height": "175"}} + ) + # Python-style (via name): + m2 = Member.model_validate( + { + "id": "M2", + "firstName": "C", + "lastName": "D", + "custom_fields": {"height": "180"}, + } + ) + assert m1.custom_fields == {"height": "175"} + assert m2.custom_fields == {"height": "180"} + + def test_group_str(self) -> None: + """`Group.__str__` includes uid, name, and member count.""" + raw = { + "id": "GID1", + "name": "My Team", + "members": [ + {"id": "M1", "firstName": "A", "lastName": "B"}, + {"id": "M2", "firstName": "C", "lastName": "D"}, + ], + } + g = Group.model_validate(raw) + s = str(g) + assert "GID1" in s + assert "My Team" in s + assert "2" in s # member count + + @pytest.mark.asyncio + async def test_group_from_api_wires_client_on_members_and_guardians(self) -> None: + """`Group.from_api()` must set `_client` on the group, each member, + and each nested guardian so per-instance HTTP methods work.""" + from spond.spond import Spond + + raw = { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "guardians": [{"id": "G1", "firstName": "C", "lastName": "D"}], + } + ], + } + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + group = Group.from_api(raw, s) + + assert group._client is s + assert group.members[0]._client is s + assert group.members[0].guardians[0]._client is s + + def test_find_member_by_name(self) -> None: + """`find_member(name=...)` matches against `member.full_name`.""" + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + {"id": "M1", "firstName": "Charlie", "lastName": "Brown"}, + {"id": "M2", "firstName": "Alice", "lastName": "Smith"}, + ], + } + ) + found = group.find_member(name="Alice Smith") + assert found is not None + assert found.uid == "M2" + + def test_find_member_by_uid(self) -> None: + """`find_member(uid=...)` returns the member with the matching id.""" + group = Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [ + {"id": "M1", "firstName": "A", "lastName": "B"}, + {"id": "M2", "firstName": "C", "lastName": "D"}, + ], + } + ) + found = group.find_member(uid="M1") + assert found is not None + assert found.uid == "M1" + + def test_person_str(self) -> None: + """`Person.__str__` includes class name, uid, and full_name.""" + from spond.person import Member + + m = Member.model_validate({"id": "M99", "firstName": "Ola", "lastName": "N"}) + s = str(m) + assert "Member" in s + assert "M99" in s + assert "Ola N" in s + + def test_role_str(self) -> None: + """`Role.__str__` includes uid and name.""" + from spond.role import Role + + r = Role.model_validate({"id": "R1", "name": "Coach"}) + s = str(r) + assert "R1" in s + assert "Coach" in s + + def test_subgroup_str(self) -> None: + """`Subgroup.__str__` includes uid and name.""" + from spond.subgroup import Subgroup + + sg = Subgroup.model_validate({"id": "SG1", "name": "Team A"}) + s = str(sg) + assert "SG1" in s + assert "Team A" in s + + +class TestGetPersonMethod: + """Tests for `Spond.get_person()` — member/guardian lookup by various + identifiers.""" + + _MEMBER_WITH_GUARDIAN = { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "email": "alice@example.invalid", + "profile": {"id": "PROF1"}, + "guardians": [ + { + "id": "G1", + "firstName": "Bob", + "lastName": "Smith", + "profile": {"id": "PROF_G1"}, + } + ], + } + _GROUP_PAYLOAD = { + "id": "GID1", + "name": "Test Group", + "members": [_MEMBER_WITH_GUARDIAN], + } + + @pytest_asyncio.fixture + async def spond_with_groups(self, mock_token): + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [Group.model_validate(self._GROUP_PAYLOAD)] + return s + + @pytest.mark.asyncio + async def test_get_person_by_uid(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("M1") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_by_email(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("alice@example.invalid") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_by_full_name(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("Alice Smith") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_by_profile_id(self, spond_with_groups) -> None: + person = await spond_with_groups.get_person("PROF1") + assert person.uid == "M1" + + @pytest.mark.asyncio + async def test_get_person_returns_guardian(self, spond_with_groups) -> None: + """When the uid matches a guardian (not a member), that guardian is + returned.""" + from spond.person import Guardian + + person = await spond_with_groups.get_person("G1") + assert isinstance(person, Guardian) + assert person.uid == "G1" + + @pytest.mark.asyncio + async def test_get_person_no_match_raises_keyerror(self, spond_with_groups) -> None: + with pytest.raises(KeyError, match="scanned"): + await spond_with_groups.get_person("NOBODY") + + @pytest.mark.asyncio + async def test_get_person_empty_string_does_not_match_nameless_member( + self, mock_token + ) -> None: + """`_match_person` must NOT treat `match_str=""` as a hit on records + whose first/last name both default to `""` (so `full_name == ""`). + Regression for the `full_name`-resilience interaction.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = [ + Group.model_validate( + { + "id": "GID", + "name": "G", + "members": [{"id": "M1"}], # no firstName/lastName → full_name="" + } + ) + ] + with pytest.raises(KeyError): + await s.get_person("") + + @pytest.mark.asyncio + async def test_get_person_no_groups_raises_keyerror(self, mock_token) -> None: + """When the account has no groups, a distinct KeyError message is raised.""" + from unittest.mock import AsyncMock + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s.groups = None + # Mock get_groups to leave self.groups = None (simulates no groups) + s.get_groups = AsyncMock(return_value=None) + + with pytest.raises(KeyError, match="no groups"): + await s.get_person("ANYONE") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_groups_http_path(self, mock_get, mock_token) -> None: + """The HTTP-fetch path of `get_groups()` returns typed Group objects + and caches them on `self.groups`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + raw_groups = [ + {"id": "G1", "name": "Alpha"}, + {"id": "G2", "name": "Beta"}, + ] + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=raw_groups + ) + + groups = await s.get_groups() + + assert groups is not None + assert len(groups) == 2 + assert all(isinstance(g, Group) for g in groups) + assert groups[0].uid == "G1" + assert groups[1].name == "Beta" + assert s.groups is groups # cache identity + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_groups_returns_none_when_api_returns_null( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) + + groups = await s.get_groups() + + assert groups is None + assert s.groups is None diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 0000000..ffd625b --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,208 @@ +"""Tests for entity-identity equality (`__eq__` / `__hash__`) on typed models. + +The `_natural_key()` hook on `DictCompatModel` drives equality and hashing. +Two typed instances compare equal when their natural keys match; the +natural key uses `uid` when set, and falls back to user-visible fields +(heading + start_time for Event, name for Group, etc.) for instances +that haven't been saved to Spond yet. + +Match/Event share an entity kind so `Match("X") == Event("X")` evaluates +True — they refer to the same Spond record. Member/Guardian share the +`"Person"` kind for the same reason. +""" + +from __future__ import annotations + +from datetime import UTC, datetime + +from spond.chat import Chat, Message +from spond.club import Transaction +from spond.event import Event +from spond.group import Group +from spond.match import Match +from spond.person import Guardian, Member +from spond.post import Post +from spond.profile import Profile +from spond.role import Role +from spond.subgroup import Subgroup + +from .conftest import _MIN_EVENT_PAYLOAD + + +class TestUIDBasedEquality: + """When uid is set, instances of the same entity-kind compare equal + iff their uids match — regardless of other field values.""" + + def test_two_events_with_same_uid_are_equal(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + # Different heading, same uid → still equal + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1", "heading": "X"}) + assert a == b + + def test_two_events_with_different_uid_are_unequal(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV2"}) + assert a != b + + def test_event_hash_matches_uid_identity(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1", "heading": "X"}) + assert hash(a) == hash(b) + assert {a, b} == {a} # set dedups on hash + eq + + def test_match_and_event_with_same_uid_are_equal(self) -> None: + """Match is a subclass of Event; they share the `"Event"` entity + kind so the same Spond record returned as `Event` or `Match` + compares equal.""" + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + m = Match.model_validate( + {**_MIN_EVENT_PAYLOAD, "id": "EV1", "matchEvent": True} + ) + assert e == m + assert hash(e) == hash(m) + + def test_member_and_guardian_with_same_uid_are_equal(self) -> None: + """Both inherit from Person → share the `"Person"` entity kind. + In practice the same Spond uid never appears in both roles, but + the equality semantics are consistent if it did.""" + mem = Member.model_validate({"id": "P1", "firstName": "A"}) + grd = Guardian.model_validate({"id": "P1", "firstName": "A"}) + assert mem == grd + + def test_group_uid_equality(self) -> None: + a = Group.model_validate({"id": "G1", "name": "Alpha"}) + b = Group.model_validate({"id": "G1", "name": "Beta"}) + assert a == b + + def test_post_uid_equality(self) -> None: + a = Post.model_validate({"id": "P1", "title": "Hi"}) + b = Post.model_validate({"id": "P1", "title": "Different"}) + assert a == b + + def test_chat_uid_equality(self) -> None: + a = Chat.model_validate({"id": "C1", "name": "x"}) + b = Chat.model_validate({"id": "C1", "name": "y"}) + assert a == b + + def test_transaction_uid_equality(self) -> None: + a = Transaction.model_validate({"id": "T1", "paymentName": "Fee"}) + b = Transaction.model_validate({"id": "T1", "paymentName": "Other"}) + assert a == b + + def test_subgroup_uid_equality(self) -> None: + assert Subgroup.model_validate( + {"id": "S1", "name": "x"} + ) == Subgroup.model_validate({"id": "S1", "name": "y"}) + + def test_role_uid_equality(self) -> None: + assert Role.model_validate({"id": "R1", "name": "x"}) == Role.model_validate( + {"id": "R1", "name": "y"} + ) + + +class TestNaturalKeyFallback: + """When uid is absent (a freshly-constructed unsaved instance), the + natural-key fallback distinguishes by user-visible fields.""" + + def test_two_unsaved_events_with_same_heading_and_start_are_equal(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + # Construct via model_validate without `id` — Event requires it + # but model_construct skips validation. Use the canonical form: + # set id="" so the natural_key falls through to the heading path. + a = Event(uid="", heading="Demo", start_time=start, end_time=start) + b = Event(uid="", heading="Demo", start_time=start, end_time=start) + assert a == b + assert hash(a) == hash(b) + + def test_two_unsaved_events_with_different_heading_are_unequal(self) -> None: + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + a = Event(uid="", heading="A", start_time=start, end_time=start) + b = Event(uid="", heading="B", start_time=start, end_time=start) + assert a != b + + def test_unsaved_event_unequal_to_saved_with_same_fields(self) -> None: + """A saved event (with uid) and an unsaved event (without uid) + but matching heading+start_time are NOT equal — the natural key + includes a sentinel `None` slot for unsaved entities.""" + start = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + saved = Event(uid="EV1", heading="Demo", start_time=start, end_time=start) + unsaved = Event(uid="", heading="Demo", start_time=start, end_time=start) + assert saved != unsaved + + def test_unsaved_group_by_name(self) -> None: + a = Group(uid="", name="Cool Group") + b = Group(uid="", name="Cool Group") + assert a == b + assert a != Group(uid="", name="Other") + + def test_unsaved_person_by_name_and_email(self) -> None: + a = Member(uid="", first_name="Alice", last_name="Smith", email="a@b.invalid") + b = Member(uid="", first_name="Alice", last_name="Smith", email="a@b.invalid") + assert a == b + # Differ on email — not equal + c = Member( + uid="", first_name="Alice", last_name="Smith", email="other@b.invalid" + ) + assert a != c + + def test_unsaved_profile_by_name(self) -> None: + a = Profile(uid="", first_name="Ola", last_name="N") + b = Profile(uid="", first_name="Ola", last_name="N") + assert a == b + + def test_unsaved_post_by_title_and_timestamp(self) -> None: + ts = datetime(2026, 6, 1, 10, 0, tzinfo=UTC) + a = Post(uid="", title="Hi", timestamp=ts) + b = Post(uid="", title="Hi", timestamp=ts) + assert a == b + + +class TestMessageNaturalKey: + """Message has no uid — identity is `(chat_id, msg_num)`.""" + + def test_messages_with_same_chat_id_and_num_are_equal(self) -> None: + a = Message.model_validate({"chatId": "C1", "msgNum": 7, "text": "hi"}) + b = Message.model_validate({"chatId": "C1", "msgNum": 7, "text": "different"}) + assert a == b + assert hash(a) == hash(b) + + def test_messages_with_different_msg_num_are_unequal(self) -> None: + a = Message.model_validate({"chatId": "C1", "msgNum": 7}) + b = Message.model_validate({"chatId": "C1", "msgNum": 8}) + assert a != b + + +class TestUseAsCollectionKey: + """The motivating use case for natural-key equality: typed models as + set members and dict keys.""" + + def test_event_dedup_in_set(self) -> None: + a = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1"}) + b = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV1", "heading": "X"}) + c = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "EV2"}) + assert len({a, b, c}) == 2 + + def test_member_as_dict_key(self) -> None: + m1 = Member.model_validate({"id": "P1", "firstName": "A", "lastName": "B"}) + m2 = Member.model_validate({"id": "P1", "firstName": "A", "lastName": "B"}) + d = {m1: "first"} + # Same uid → same key, overwrites + d[m2] = "second" + assert len(d) == 1 + assert d[m1] == "second" + + +class TestCrossTypeEquality: + """An entity of one kind should never equal an entity of a different + kind — even if their uids happen to collide.""" + + def test_event_unequal_to_group_with_same_uid(self) -> None: + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + g = Group.model_validate({"id": "X"}) + assert e != g + + def test_event_unequal_to_non_typed_object(self) -> None: + e = Event.model_validate({**_MIN_EVENT_PAYLOAD, "id": "X"}) + assert e != "X" + assert e != {"id": "X"} + assert e != 42 diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..7069c1a --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,621 @@ +"""Tests for messaging — both the low-level `Spond.send_message()` entrypoint +and the typed `Chat`/`Message` surface returned by `Spond.get_messages()`.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_TOKEN, MOCK_USERNAME + + +class TestSendMessage: + """Tests for `Spond.send_message()` — covers the fixes in #238.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_send_message__continues_chat_when_chat_id_given( + self, mock_post, mock_token + ) -> None: + """With `chat_id`, the call should route through `_continue_chat()` + and properly await it (regression: the await was missing). + `_continue_chat` now uses `async with`, so the post mock follows + the standard context-manager pattern used elsewhere in this file. + """ + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + api_response = {"ok": True, "messageId": "MID1"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await s.send_message(text="hello", chat_id="CHAT1") + + assert result == api_response + mock_post.assert_called_once() + _, kwargs = mock_post.call_args + assert kwargs["json"] == {"chatId": "CHAT1", "text": "hello", "type": "TEXT"} + + @pytest.mark.asyncio + async def test_send_message__missing_args_raises_valueerror( + self, mock_token + ) -> None: + """Without `chat_id` and without both `user` and `group_uid`, the + call should raise rather than silently return a sentinel dict.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + with pytest.raises(ValueError, match="chat_id"): + await s.send_message(text="hello") + + with pytest.raises(ValueError, match="user and group_uid"): + await s.send_message(text="hello", user="USER1") + + with pytest.raises(ValueError, match="user and group_uid"): + await s.send_message(text="hello", group_uid="GROUP1") + + +class TestChat: + """Chat/Message typed surface — replaces the old `list[JSONDict]` return + from `Spond.get_messages()`.""" + + _CHAT_PAYLOAD = { + "id": "CHAT1", + "name": "Demo Group", + "type": "GROUP", + "participants": ["P1", "P2"], + "newestTimestamp": "2026-05-14T12:00:00Z", + "unread": True, + "muted": False, + "message": { + "chatId": "CHAT1", + "msgNum": 42, + "type": "TEXT", + "timestamp": "2026-05-14T12:00:00Z", + "text": "hello", + "user": "P1", + }, + } + + def test_chat_parses_with_typed_message(self) -> None: + from spond.chat import Chat, Message + + c = Chat.from_api(self._CHAT_PAYLOAD, None) + assert c.uid == "CHAT1" + assert c.name == "Demo Group" + assert c.type == "GROUP" + assert c.unread is True + assert c.participants == ["P1", "P2"] + assert isinstance(c.message, Message) + assert c.message.type == "TEXT" + assert c.message.text == "hello" + assert c.message.user == "P1" + + def test_chat_str(self) -> None: + """`Chat.__str__` includes uid, name, and type.""" + from spond.chat import Chat + + c = Chat.from_api(self._CHAT_PAYLOAD, None) + s = str(c) + assert "CHAT1" in s + assert "Demo Group" in s + assert "GROUP" in s + + def test_chat_message_optional_for_resilience(self) -> None: + """A chat with no embedded message (rare but possible) must not crash + — the only required field on Chat is `uid`.""" + from spond.chat import Chat + + c = Chat.from_api({"id": "X"}, None) + assert c.uid == "X" + assert c.message is None + + def test_message_type_specific_extras_default_to_empty(self) -> None: + """A TEXT message must not falsely report `new_name` or `images` — + those are RENAME/IMAGES-specific.""" + from spond.chat import Message + + m = Message.model_validate(self._CHAT_PAYLOAD["message"]) + assert m.new_name is None + assert m.images == [] + assert m.internal_promo is None + assert m.campaign is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_chat_send_routes_through_chat_server( + self, mock_post, mock_token + ) -> None: + """`chat.send(text)` posts to the chat-server host with the chat-server + auth token (not the regular Bearer), preserving the same wire shape + the deprecated `Spond.send_message(chat_id=...)` path uses.""" + from spond.chat import Chat + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + chat = Chat.from_api(self._CHAT_PAYLOAD, s) + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"ok": True} + ) + result = await chat.send("ack") + assert result == {"ok": True} + assert mock_post.call_args[0][0] == "https://chat.example.invalid/messages" + kwargs = mock_post.call_args[1] + assert kwargs["json"] == {"chatId": "CHAT1", "text": "ack", "type": "TEXT"} + assert kwargs["headers"] == {"auth": "MOCK_CHAT_AUTH"} + + def test_chat_send_refuses_without_client(self) -> None: + """A Chat constructed without a client (test fixture, direct + instantiation) must raise rather than crashing with an attribute + error inside the send path.""" + import asyncio + + from spond.chat import Chat + + c = Chat.from_api(self._CHAT_PAYLOAD, None) + with pytest.raises(RuntimeError, match="no client attached"): + asyncio.run(c.send("hello")) + + +class TestGetMessages: + """Tests for `Spond.get_messages()` — chat-server handshake and list + returned as typed Chat instances.""" + + _CHAT_PAYLOAD = { + "id": "CHAT1", + "name": "Demo", + "type": "GROUP", + "participants": ["P1"], + "newestTimestamp": "2026-05-14T12:00:00Z", + "unread": False, + "muted": False, + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_messages_happy_path(self, mock_get, mock_token) -> None: + """get_messages() returns a list of Chat objects and caches on + `self.messages`. The chat-server token is pre-set so `_login_chat` + is not triggered.""" + from spond.chat import Chat + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[self._CHAT_PAYLOAD] + ) + + messages = await s.get_messages() + + assert messages is not None + assert len(messages) == 1 + assert isinstance(messages[0], Chat) + assert messages[0].uid == "CHAT1" + assert s.messages is messages + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_messages_returns_none_when_api_returns_null( + self, mock_get, mock_token + ) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) + + messages = await s.get_messages() + assert messages is None + assert s.messages is None + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_messages_max_chats_param(self, mock_get, mock_token) -> None: + """The `max` query parameter must reflect the `max_chats` argument.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_messages(max_chats=50) + + params = mock_get.call_args[1]["params"] + assert params["max"] == "50" + + +class TestSendMessageNewChat: + """Tests for `Spond.send_message()` when starting a *new* chat + (user + group_uid path, lines 489-506 in spond.py).""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_send_message_new_chat_happy_path( + self, mock_post, mock_token + ) -> None: + """With `user` and `group_uid`, `send_message()` looks up the member + by `get_person()`, extracts `profile.id`, and POSTs to the chat server.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + # Pre-populate groups so get_person() resolves locally + s.groups = [ + Group.model_validate( + { + "id": "GID1", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "Alice", + "lastName": "Smith", + "profile": {"id": "PROF1"}, + } + ], + } + ) + ] + + api_response = {"ok": True, "messageId": "MSG1"} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await s.send_message(text="Hello", user="M1", group_uid="GID1") + + assert result == api_response + kwargs = mock_post.call_args[1] + assert kwargs["json"]["recipient"] == "PROF1" + assert kwargs["json"]["groupId"] == "GID1" + assert kwargs["json"]["text"] == "Hello" + assert kwargs["json"]["type"] == "TEXT" + + @pytest.mark.asyncio + async def test_send_message_user_without_profile_raises(self, mock_token) -> None: + """If the located member has no `profile` dict with an `id`, a clear + `ValueError` is raised rather than crashing inside the POST.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "MOCK_CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + s.groups = [ + Group.model_validate( + { + "id": "GID1", + "name": "G", + "members": [ + { + "id": "M2", + "firstName": "Bob", + "lastName": "Jones", + # no profile → profile is None + } + ], + } + ) + ] + + with pytest.raises(ValueError, match="profile id"): + await s.send_message(text="hi", user="M2", group_uid="GID1") + + +class TestMemberSendMessage: + """Tests for `Member.send_message()` and `Guardian.send_message()` — + the per-instance HTTP helpers on `person.py`.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_member_send_message_routes_to_chat_server( + self, mock_post, mock_token + ) -> None: + """Calling `member.send_message()` POSTs the correct payload to the + chat-server host with the chat-server auth header.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + group = Group.from_api( + { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "profile": {"id": "PROF1"}, + } + ], + }, + s, + ) + member = group.members[0] + + api_response = {"ok": True} + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=api_response + ) + + result = await member.send_message("test msg", "GID") + + assert result == api_response + kwargs = mock_post.call_args[1] + assert kwargs["json"]["recipient"] == "PROF1" + assert kwargs["json"]["groupId"] == "GID" + assert kwargs["json"]["text"] == "test msg" + assert kwargs["headers"] == {"auth": "CHAT_AUTH"} + + def test_member_send_message_without_client_raises(self) -> None: + """A Member instantiated without a client (e.g. test fixture) must + raise `RuntimeError` rather than crashing with AttributeError.""" + import asyncio + + from spond.person import Member + + m = Member.model_validate( + {"id": "M1", "firstName": "A", "lastName": "B", "profile": {"id": "P1"}} + ) + # _client is None — no Group.from_api() was called + with pytest.raises(RuntimeError, match="no client"): + asyncio.run(m.send_message("hello", "GID")) + + @pytest.mark.asyncio + async def test_member_send_message_without_profile_id_raises(self) -> None: + """If the member has no `profile.id`, a clear `ValueError` is raised + (not an AttributeError inside the send path).""" + from spond.person import Member + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s._auth = "CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + m = Member.model_validate({"id": "M1", "firstName": "A", "lastName": "B"}) + m._client = s # wire client, but no profile + + with pytest.raises(ValueError, match="profile id"): + await m.send_message("hello", "GID") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_guardian_send_message_routes_to_chat_server( + self, mock_post, mock_token + ) -> None: + """Guardian.send_message() uses the same `_send_message_to_person` + helper as Member.send_message() — verify it also works.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + s._auth = "CHAT_AUTH" + s._chat_url = "https://chat.example.invalid" + + group = Group.from_api( + { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "Child", + "lastName": "A", + "guardians": [ + { + "id": "G1", + "firstName": "Parent", + "lastName": "A", + "profile": {"id": "PROF_G1"}, + } + ], + } + ], + }, + s, + ) + guardian = group.members[0].guardians[0] + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={"ok": True} + ) + + result = await guardian.send_message("guardian msg", "GID") + + assert result == {"ok": True} + kwargs = mock_post.call_args[1] + assert kwargs["json"]["recipient"] == "PROF_G1" + + +class TestLazyChatLogin: + """Tests for the lazy `_login_chat()` handshake path — verifies that + `get_messages()`, `_continue_chat()`, `send_message()`, `chat.send()`, + and `member.send_message()` all trigger `_login_chat()` when `_auth` is + None, and that `_login_chat()` itself correctly stores the chat-server + URL and token from the API response.""" + + _CHAT_HANDSHAKE = { + "url": "https://chat.example.invalid", + "auth": "FRESH_CHAT_AUTH", + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + @patch("aiohttp.ClientSession.post") + async def test_login_chat_sets_url_and_auth(self, mock_post, mock_get) -> None: + """`_login_chat()` must POST to `{api_url}chat`, then store the + returned `url` and `auth` on the client instance.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth and _chat_url are None — trigger lazy login + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._CHAT_HANDSHAKE + ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_messages() + + assert s._chat_url == "https://chat.example.invalid" + assert s._auth == "FRESH_CHAT_AUTH" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + @patch("aiohttp.ClientSession.post") + async def test_get_messages_triggers_lazy_login(self, mock_post, mock_get) -> None: + """`get_messages()` must call `_login_chat()` when `_auth` is None.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._CHAT_HANDSHAKE + ) + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + messages = await s.get_messages() + + # One POST for _login_chat, zero POSTs otherwise; one GET for chats + mock_post.assert_called_once() + assert messages == [] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_continue_chat_triggers_lazy_login(self, mock_post) -> None: + """`_continue_chat()` independently calls `_login_chat()` when its + own `_auth` guard fires — covers line 420 in spond.py.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth=None and _chat_url=None: _continue_chat must bootstrap both + + # Side-effect: first call is _login_chat POST, second is the message POST + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, # _login_chat response + {"ok": True}, # message send response + ] + ) + + result = await s._continue_chat("CHAT1", "hello") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" + assert mock_post.call_count == 2 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_send_message_chat_id_triggers_lazy_login(self, mock_post) -> None: + """`send_message(chat_id=...)` calls `_login_chat()` when `_auth` is + None — covers line 479 in spond.py.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, + {"ok": True}, + ] + ) + + result = await s.send_message(text="hi", chat_id="CHAT1") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_chat_send_triggers_lazy_login_on_client(self, mock_post) -> None: + """`chat.send()` triggers `_client._login_chat()` when `_client._auth` + is None — covers chat.py:158.""" + from spond.chat import Chat + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth is None — the chat's send() must bootstrap it + + chat = Chat.from_api( + { + "id": "CHAT1", + "name": "G", + "type": "GROUP", + "participants": [], + "newestTimestamp": "2026-01-01T00:00:00Z", + "unread": False, + "muted": False, + }, + s, + ) + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, # _login_chat + {"ok": True}, # message send + ] + ) + + result = await chat.send("ack") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_member_send_message_triggers_lazy_login(self, mock_post) -> None: + """`member.send_message()` calls `_login_chat()` when the client's + `_auth` is None — covers person.py:174.""" + from spond.group import Group + + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = MOCK_TOKEN + # _auth is None; _send_message_to_person must bootstrap it + + group = Group.from_api( + { + "id": "GID", + "name": "G", + "members": [ + { + "id": "M1", + "firstName": "A", + "lastName": "B", + "profile": {"id": "PROF1"}, + } + ], + }, + s, + ) + member = group.members[0] + + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + self._CHAT_HANDSHAKE, + {"ok": True}, + ] + ) + + result = await member.send_message("hello", "GID") + + assert result == {"ok": True} + assert s._auth == "FRESH_CHAT_AUTH" diff --git a/tests/test_post_save_delete.py b/tests/test_post_save_delete.py new file mode 100644 index 0000000..0c18b4a --- /dev/null +++ b/tests/test_post_save_delete.py @@ -0,0 +1,593 @@ +"""Tests for the ActiveRecord write surface on Post: `save()`, +`delete()`, and `add_comment()`. + +Mirrors `tests/test_event_save_delete.py` — same dispatch shape, same +backward-compat guards. Endpoints (`POST /posts/`, +`DELETE /posts/{uid}`, `POST /posts/{uid}/comments`) verified live +against the test group before these tests were written; this file +locks in the wire shape so future refactors can't drift.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, patch + +import pytest + +from spond import SpondAPIError +from spond.comment import Comment +from spond.post import Post +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + + +def _fresh_post() -> Post: + """Build an unsaved Post (no uid).""" + return Post( + uid="", + type="PLAIN", + group_uid="GROUP1", + title="New Post", + body="Some content.", + ) + + +_API_POST = { + "id": "NEWUID", + "type": "PLAIN", + "groupId": "GROUP1", + "title": "New Post", + "body": "Some content.", + "ownerId": "PROFILE1", + "timestamp": "2026-05-15T10:00:00Z", + "comments": [], +} + + +class TestPostSaveCreate: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_populates_uid_in_place(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + result = await post.save(client=s) + + # save() returns self (mutated in place) + assert result is post + assert post.uid == "NEWUID" + assert post.owner_uid == "PROFILE1" + assert post.timestamp is not None + assert post._client is s + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_posts_to_collection_url(self, mock_post) -> None: + """Create POSTs to `/posts/` (no uid in path).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/posts/") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_payload_excludes_server_managed(self, mock_post) -> None: + """The create payload includes user-set fields but NOT `id` + (Spond mints the uid).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + + posted = mock_post.call_args[1]["json"] + assert "id" not in posted + assert posted["title"] == "New Post" + assert posted["groupId"] == "GROUP1" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_prepends_to_cache(self, mock_post) -> None: + """A newly-saved post is prepended to `posts` (position 0) — same + ordering convention as `Event.save()`, matching Spond's + newest-first ordering on `get_posts()`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + existing = Post.from_api({**_API_POST, "id": "EXISTING"}, s) + s.posts = [existing] + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + + # New post at position 0; existing post slid down. + assert len(s.posts) == 2 + assert s.posts[0].uid == "NEWUID" + assert s.posts[1].uid == "EXISTING" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_caches_self_not_refreshed_copy(self, mock_post) -> None: + """Identity guarantee: after `post.save()`, `post is + s.posts[0]` — matches `Event.save()`'s identity contract.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + assert s.posts[0] is post + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_initialises_empty_cache(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = None + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=_API_POST + ) + + await post.save(client=s) + assert s.posts == [post] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_raises_on_http_error(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = False + mock_post.return_value.__aenter__.return_value.status = 500 + mock_post.return_value.__aenter__.return_value.text = AsyncMock( + return_value="boom" + ) + + with pytest.raises(SpondAPIError) as exc_info: + await post.save(client=s) + assert exc_info.value.status == 500 + + @pytest.mark.asyncio + async def test_save_without_client_raises(self) -> None: + post = _fresh_post() + with pytest.raises(RuntimeError, match="no client bound"): + await post.save() + + +class TestPostSaveUpdate: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_save_existing_puts_to_uid_url(self, mock_put) -> None: + """Post update uses **PUT** `/posts/{uid}` — verified live; the + create path uses POST `/posts/`. Different from Event (which + uses POST for both verbs).""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "title": "Renamed"} + ) + + post.title = "Renamed" + await post.save() + + called_url = mock_put.call_args[0][0] + assert called_url.endswith("/posts/NEWUID"), ( + f"update should PUT to /posts/NEWUID, got {called_url}" + ) + assert post.title == "Renamed" + mock_put.assert_called_once() + + +class TestPostDelete: + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_issues_delete(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + s.posts = [post] + + mock_delete.return_value.__aenter__.return_value.ok = True + + await post.delete() + + called_url = mock_delete.call_args[0][0] + assert called_url.endswith("/posts/NEWUID") + assert s.posts == [] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_prunes_only_target_from_cache(self, mock_delete) -> None: + """Other posts in the cache must survive an unrelated delete.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + p1 = Post.from_api({**_API_POST, "id": "DELME"}, s) + p2 = Post.from_api({**_API_POST, "id": "KEEP"}, s) + s.posts = [p1, p2] + + mock_delete.return_value.__aenter__.return_value.ok = True + + await p1.delete() + assert [p.uid for p in s.posts] == ["KEEP"] + + @pytest.mark.asyncio + async def test_delete_without_client_raises(self) -> None: + post = Post.model_validate(_API_POST) + with pytest.raises(RuntimeError, match="no client"): + await post.delete() + + @pytest.mark.asyncio + async def test_delete_without_uid_raises(self) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + post._client = s + with pytest.raises(RuntimeError, match="unsaved"): + await post.delete() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + async def test_delete_raises_on_http_error(self, mock_delete) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_delete.return_value.__aenter__.return_value.ok = False + mock_delete.return_value.__aenter__.return_value.status = 403 + mock_delete.return_value.__aenter__.return_value.text = AsyncMock( + return_value="nope" + ) + + with pytest.raises(SpondAPIError): + await post.delete() + + +class TestAddComment: + _API_COMMENT = { + "id": "CMT1", + "fromProfileId": "PROF1", + "timestamp": "2026-05-15T11:00:00Z", + "text": "Hello there", + "reactions": {}, + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_returns_typed_comment(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + + result = await post.add_comment("Hello there") + assert isinstance(result, Comment) + assert result.uid == "CMT1" + assert result.text == "Hello there" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_posts_to_comments_endpoint(self, mock_post) -> None: + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + + await post.add_comment("Hello there") + + called_url = mock_post.call_args[0][0] + assert called_url.endswith("/posts/NEWUID/comments") + # Body shape: just {"text": ...} + body = mock_post.call_args[1]["json"] + assert body == {"text": "Hello there"} + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_appends_to_post_comments(self, mock_post) -> None: + """After `add_comment()`, `post.comments` contains the new comment + without an explicit refresh.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + assert len(post.comments) == 0 + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + + c = await post.add_comment("Hello there") + assert len(post.comments) == 1 + assert post.comments[0] is c + + @pytest.mark.asyncio + async def test_add_comment_without_client_raises(self) -> None: + post = Post.model_validate(_API_POST) + with pytest.raises(RuntimeError, match="no client"): + await post.add_comment("hi") + + @pytest.mark.asyncio + async def test_add_comment_without_uid_raises(self) -> None: + """Can't comment on an unsaved Post — no parent uid for the URL.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = _fresh_post() + post._client = s + with pytest.raises(RuntimeError, match="unsaved"): + await post.add_comment("hi") + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_add_comment_raises_on_http_error(self, mock_post) -> None: + """E.g. when `commentsDisabled=True` on the post Spond rejects.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = False + mock_post.return_value.__aenter__.return_value.status = 403 + mock_post.return_value.__aenter__.return_value.text = AsyncMock( + return_value="comments disabled" + ) + + with pytest.raises(SpondAPIError): + await post.add_comment("hi") + + +class TestSaveExtrasReplaceNotMerge: + """`save()` replaces `__pydantic_extra__` from the refreshed + response rather than merging it in. The merge approach left stale + extras visible via the dict-compat iter/keys/items surface even + after Spond's response said they were gone.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_save_drops_stale_extras_not_in_response(self, mock_put) -> None: + """A Post with extra fields present pre-save, but absent from + the update response, must not show those extras on `list(post)` + after the save. The previous merge approach silently kept them.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + # Construct with a stale extra Spond used to include but no + # longer does. + post = Post.from_api({**_API_POST, "stalePreviewField": "old-value"}, s) + assert "stalePreviewField" in post + assert post.__pydantic_extra__["stalePreviewField"] == "old-value" + + # Update response omits the stale extra (Spond stopped sending it). + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "title": "Renamed"} + ) + post.title = "Renamed" + await post.save() + + # The stale extra is gone from the model. + assert "stalePreviewField" not in post, ( + "stale extra survived save() — extras should be REPLACED " + "from the response, not merged into the old set" + ) + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + async def test_save_keeps_extras_present_in_response(self, mock_put) -> None: + """Conversely: an extra in the refreshed response IS reflected + on self after save — replace, not just clear.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "newExtraField": 42, "title": "Renamed"} + ) + post.title = "Renamed" + await post.save() + + assert "newExtraField" in post + assert post.__pydantic_extra__["newExtraField"] == 42 + + +class TestSaveDoesNotWipeLocallyAddedComments: + """Regression guard: a Post that has a comment added via + `add_comment()` and is then `save()`-d must NOT lose that comment, + even if Spond's update response omits the `comments` array.""" + + _API_COMMENT = { + "id": "CMT_X", + "text": "locally added", + "fromProfileId": "P", + "timestamp": "2026-05-15T12:00:00Z", + } + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + @patch("aiohttp.ClientSession.post") + async def test_save_preserves_comments_when_response_omits_them( + self, mock_post, mock_put + ) -> None: + """The realistic case: Spond's PUT /posts/{uid} update response + doesn't include the comments array. `save()` must preserve + whatever's in `self.comments` rather than overwriting with + the (empty / missing) `refreshed.comments`. add_comment uses + POST `/posts/{uid}/comments`; save() update uses PUT — both + verbs are mocked here so the test exercises both paths.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + assert post.comments == [] + + # 1) add_comment (POST /posts/{uid}/comments) populates self.comments. + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self._API_COMMENT + ) + await post.add_comment("locally added") + assert len(post.comments) == 1 + assert post.comments[0].text == "locally added" + + # 2) Mock save's update (PUT) response WITHOUT a `comments` key. + update_response = {k: v for k, v in _API_POST.items() if k != "comments"} + update_response["title"] = "Renamed" + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value=update_response + ) + post.title = "Renamed" + await post.save() + + # 3) The locally-added comment must survive. + assert len(post.comments) == 1, ( + "save() wiped the locally-added comment — `comments` should be " + "skipped during the in-place state copy on update" + ) + assert post.comments[0].text == "locally added" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.put") + @patch("aiohttp.ClientSession.post") + async def test_save_preserves_comments_when_response_returns_empty_list( + self, mock_post, mock_put + ) -> None: + """Even if Spond's update response explicitly returns + `comments: []` (stale because they were fetched separately), + the local state must not be wiped — the server isn't the + authoritative view of comments through the update endpoint.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + post = Post.from_api(_API_POST, s) + + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**self._API_COMMENT, "text": "must survive"} + ) + await post.add_comment("must survive") + assert len(post.comments) == 1 + + # Spond's update response includes `comments: []` (also realistic). + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "comments": [], "title": "Renamed"} + ) + post.title = "Renamed" + await post.save() + + assert len(post.comments) == 1 + assert post.comments[0].text == "must survive" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.post") + async def test_save_create_DOES_apply_response_comments(self, mock_post) -> None: + """The skip-on-update behaviour must NOT apply on the create + path: a brand-new post has no local comments to preserve, and + Spond's create response IS the canonical fresh state.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + # Hypothetical create response that somehow includes a comment + # (unusual but verifies the create path doesn't short-circuit). + mock_post.return_value.__aenter__.return_value.ok = True + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "comments": [self._API_COMMENT]} + ) + await post.save(client=s) + + assert len(post.comments) == 1, ( + "create path should accept the response's comments as canonical" + ) + + +class TestPostRoundtrip: + """End-to-end ActiveRecord lifecycle: construct → save (create) → + mutate → save (update) → add_comment → delete.""" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.delete") + @patch("aiohttp.ClientSession.put") + @patch("aiohttp.ClientSession.post") + async def test_full_lifecycle(self, mock_post, mock_put, mock_delete) -> None: + """Mocks split by verb: POST is used for create + add_comment; + PUT is used for the update path; DELETE for delete.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = "MOCK" + s.posts = [] + post = _fresh_post() + + mock_post.return_value.__aenter__.return_value.ok = True + # POST is used for create then add_comment. Order matters. + mock_post.return_value.__aenter__.return_value.json = AsyncMock( + side_effect=[ + _API_POST, # 1) create response + { # 2) add_comment response + "id": "CMT", + "text": "hi", + "fromProfileId": "P", + "timestamp": "2026-05-15T12:00:00Z", + }, + ] + ) + # PUT is only used for the update path. + mock_put.return_value.__aenter__.return_value.ok = True + mock_put.return_value.__aenter__.return_value.json = AsyncMock( + return_value={**_API_POST, "title": "Renamed"} + ) + + await post.save(client=s) + assert post.uid == "NEWUID" + + post.title = "Renamed" + await post.save() + assert post.title == "Renamed" + + c = await post.add_comment("hi") + assert c.text == "hi" + assert post.comments[-1] is c + + mock_delete.return_value.__aenter__.return_value.ok = True + await post.delete() + assert post.uid not in {p.uid for p in s.posts} diff --git a/tests/test_posts.py b/tests/test_posts.py new file mode 100644 index 0000000..13e22f8 --- /dev/null +++ b/tests/test_posts.py @@ -0,0 +1,167 @@ +"""Tests for `Spond.get_posts()` — query-parameter construction, caching, +and error surfacing.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import AsyncMock, patch + +import pytest + +from spond.spond import Spond + +from .conftest import MOCK_PASSWORD, MOCK_USERNAME + +if TYPE_CHECKING: + from spond import JSONDict + + +class TestPostMethods: + MOCK_POSTS: list[JSONDict] = [ + { + "id": "POST1", + "type": "PLAIN", + "groupId": "GID1", + "title": "Post One", + "body": "Body of post one", + "timestamp": "2026-03-03T19:20:00.270Z", + "comments": [], + }, + { + "id": "POST2", + "type": "PLAIN", + "groupId": "GID2", + "title": "Post Two", + "body": "Body of post two", + "timestamp": "2026-02-20T19:21:20.447Z", + "comments": [{"id": "C1", "text": "A comment"}], + }, + ] + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__happy_path(self, mock_get, mock_token) -> None: + """Test that get_posts returns posts from the API.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=self.MOCK_POSTS + ) + + posts = await s.get_posts() + + mock_url = "https://api.spond.com/core/v1/posts/" + mock_get.assert_called_once_with( + mock_url, + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={ + "type": "PLAIN", + "max": "20", + "includeComments": "true", + }, + ) + assert posts is not None + assert len(posts) == 2 + assert posts[0].uid == "POST1" + assert posts[0].title == "Post One" + assert posts[1].uid == "POST2" + assert s.posts is posts # cache identity + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__with_group_id(self, mock_get, mock_token) -> None: + """Test that group_id is passed as a query parameter.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=[self.MOCK_POSTS[0]] + ) + + posts = await s.get_posts(group_id="GID1") + + mock_get.assert_called_once_with( + "https://api.spond.com/core/v1/posts/", + headers={ + "content-type": "application/json", + "Authorization": f"Bearer {mock_token}", + }, + params={ + "type": "PLAIN", + "max": "20", + "includeComments": "true", + "groupId": "GID1", + }, + ) + assert len(posts) == 1 + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__custom_max(self, mock_get, mock_token) -> None: + """Test that max_posts parameter is respected.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_posts(max_posts=5) + + call_params = mock_get.call_args[1]["params"] + assert call_params["max"] == "5" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__no_comments(self, mock_get, mock_token) -> None: + """Test that include_comments=False is passed correctly.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) + + await s.get_posts(include_comments=False) + + call_params = mock_get.call_args[1]["params"] + assert call_params["includeComments"] == "false" + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: + """Test that a failed API response raises ValueError.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = False + mock_get.return_value.__aenter__.return_value.status = 401 + mock_get.return_value.__aenter__.return_value.text = AsyncMock( + return_value="Unauthorized" + ) + + with pytest.raises(ValueError, match="401"): + await s.get_posts() + + @pytest.mark.asyncio + @patch("aiohttp.ClientSession.get") + async def test_get_posts__returns_none_when_api_returns_null( + self, mock_get, mock_token + ) -> None: + """When the API returns null, `get_posts()` returns None and sets + `self.posts = None`.""" + s = Spond(MOCK_USERNAME, MOCK_PASSWORD) + s.token = mock_token + + mock_get.return_value.__aenter__.return_value.ok = True + mock_get.return_value.__aenter__.return_value.json = AsyncMock( + return_value=None + ) + + result = await s.get_posts() + assert result is None + assert s.posts is None diff --git a/tests/test_spond.py b/tests/test_spond.py deleted file mode 100644 index 8c99583..0000000 --- a/tests/test_spond.py +++ /dev/null @@ -1,555 +0,0 @@ -"""Test suite for Spond class.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import AsyncMock, patch - -import pytest - -from spond import AuthenticationError -from spond.base import _SpondBase -from spond.spond import Spond - -if TYPE_CHECKING: - from spond import JSONDict - - -MOCK_USERNAME, MOCK_PASSWORD = "MOCK_USERNAME", "MOCK_PASSWORD" -MOCK_TOKEN = "MOCK_TOKEN" -MOCK_PAYLOAD = {"accepted": "false", "declineMessage": "sick cannot make it"} - - -# Mock the `require_authentication` decorator to bypass authentication -def mock_require_authentication(func): - async def wrapper(*args, **kwargs): - return await func(*args, **kwargs) - - return wrapper - - -_SpondBase.require_authentication = mock_require_authentication(Spond.get_event) - - -@pytest.fixture -def mock_token() -> str: - return MOCK_TOKEN - - -@pytest.fixture -def mock_payload() -> JSONDict: - return MOCK_PAYLOAD - - -class TestEventMethods: - @pytest.fixture - def mock_events(self) -> list[JSONDict]: - """Mock a minimal list of events.""" - return [ - { - "id": "ID1", - "name": "Event One", - }, - { - "id": "ID2", - "name": "Event Two", - }, - ] - - @pytest.mark.asyncio - async def test_get_event__happy_path( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a valid `id` returns the matching event.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - g = await s.get_event("ID1") - - assert g == { - "id": "ID1", - "name": "Event One", - } - - @pytest.mark.asyncio - async def test_get_event__no_match_raises_exception( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a non-matched `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_event("ID3") - - @pytest.mark.asyncio - async def test_get_event__blank_id_match_raises_exception( - self, mock_events: list[JSONDict], mock_token - ) -> None: - """Test that a blank `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.events = mock_events - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_event("") - - @pytest.mark.asyncio - async def test_get_event__no_events_available_raises_keyerror( - self, mock_token - ) -> None: - """`get_events()` is documented to return None when no events exist; - `get_event()` should surface this as KeyError, not TypeError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.events = None - s.get_events = AsyncMock() # leaves self.events as None - - with pytest.raises(KeyError): - await s.get_event("ID1") - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_update_event__returns_api_response( - self, mock_post, mock_token - ) -> None: - """`update_event()` should return the POST response, not the cached - events list (regression test for #239).""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.events = [{"id": "ID1", "heading": "Old"}] # cached event for _get_entity - - api_response = { - "id": "ID1", - "heading": "New", - "updated": "2026-05-14T13:00:00Z", - } - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=api_response - ) - - result = await s.update_event(uid="ID1", updates={"heading": "New"}) - - assert result == api_response - # The cached events list should NOT be what we returned. - assert result is not s.events - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.put") - async def test_change_response(self, mock_put, mock_payload, mock_token) -> None: - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_response_data = { - "acceptedIds": ["PID1", "PID2"], - "declinedIds": ["PID3"], - "unansweredIds": [], - "waitinglistIds": [], - "unconfirmedIds": [], - "declineMessages": {"PID3": "sick cannot make it"}, - } - mock_put.return_value.__aenter__.return_value.status = 200 - mock_put.return_value.__aenter__.return_value.json = AsyncMock( - return_value=mock_response_data - ) - - response = await s.change_response(uid="ID1", user="PID3", payload=mock_payload) - - mock_url = "https://api.spond.com/core/v1/sponds/ID1/responses/PID3" - mock_put.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - json=mock_payload, - ) - assert response == mock_response_data - - -class TestGroupMethods: - @pytest.fixture - def mock_groups(self) -> list[JSONDict]: - """Mock a minimal list of groups.""" - return [ - { - "id": "ID1", - "name": "Group One", - }, - { - "id": "ID2", - "name": "Group Two", - }, - ] - - @pytest.mark.asyncio - async def test_get_group__happy_path( - self, mock_groups: list[JSONDict], mock_token - ) -> None: - """Test that a valid `id` returns the matching group.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.groups = mock_groups - s.token = mock_token - g = await s.get_group("ID2") - - assert g == { - "id": "ID2", - "name": "Group Two", - } - - @pytest.mark.asyncio - async def test_get_group__no_match_raises_exception( - self, mock_groups: list[JSONDict], mock_token - ) -> None: - """Test that a non-matched `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.groups = mock_groups - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_group("ID3") - - @pytest.mark.asyncio - async def test_get_group__blank_id_raises_exception( - self, mock_groups: list[JSONDict], mock_token - ) -> None: - """Test that a blank `id` raises KeyError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.groups = mock_groups - s.token = mock_token - - with pytest.raises(KeyError): - await s.get_group("") - - @pytest.mark.asyncio - async def test_get_group__no_groups_available_raises_keyerror( - self, mock_token - ) -> None: - """`get_groups()` is documented to return None when no groups exist; - `get_group()` should surface this as KeyError, not TypeError.""" - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s.groups = None - s.get_groups = AsyncMock() # leaves self.groups as None - - with pytest.raises(KeyError): - await s.get_group("ID1") - - -class TestSendMessage: - """Tests for `Spond.send_message()` — covers the fixes in #238.""" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) - async def test_send_message__continues_chat_when_chat_id_given( - self, mock_post, mock_token - ) -> None: - """With `chat_id`, the call should route through `_continue_chat()` - and properly await it (regression: the await was missing).""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s._auth = "MOCK_CHAT_AUTH" - s._chat_url = "https://chat.example.invalid" - - api_response = {"ok": True, "messageId": "MID1"} - # _continue_chat does `r = await session.post(...)` (no `async with`), - # so the post mock must be AsyncMock and r.json must be AsyncMock too. - mock_post.return_value.json = AsyncMock(return_value=api_response) - - result = await s.send_message(text="hello", chat_id="CHAT1") - - assert result == api_response # was a coroutine before the fix - mock_post.assert_called_once() - _, kwargs = mock_post.call_args - assert kwargs["json"] == {"chatId": "CHAT1", "text": "hello", "type": "TEXT"} - - @pytest.mark.asyncio - async def test_send_message__missing_args_raises_valueerror( - self, mock_token - ) -> None: - """Without `chat_id` and without both `user` and `group_uid`, the - call should raise rather than silently return a sentinel dict.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - s._auth = "MOCK_CHAT_AUTH" - s._chat_url = "https://chat.example.invalid" - - with pytest.raises(ValueError, match="chat_id"): - await s.send_message(text="hello") - - with pytest.raises(ValueError, match="user and group_uid"): - await s.send_message(text="hello", user="USER1") - - with pytest.raises(ValueError, match="user and group_uid"): - await s.send_message(text="hello", group_uid="GROUP1") - - -class TestExportMethod: - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_export(self, mock_get, mock_token) -> None: - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_binary = b"\x68\x65\x6c\x6c\x6f\x77\x6f\x72\x6c\x64" # helloworld - mock_get.return_value.__aenter__.return_value.status = 200 - mock_get.return_value.__aenter__.return_value.read = AsyncMock( - return_value=mock_binary - ) - - data = await s.get_event_attendance_xlsx(uid="ID1") - - mock_url = "https://api.spond.com/core/v1/sponds/ID1/export" - mock_get.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - ) - assert data == mock_binary - - -class TestPostMethods: - MOCK_POSTS: list[JSONDict] = [ - { - "id": "POST1", - "type": "PLAIN", - "groupId": "GID1", - "title": "Post One", - "body": "Body of post one", - "timestamp": "2026-03-03T19:20:00.270Z", - "comments": [], - }, - { - "id": "POST2", - "type": "PLAIN", - "groupId": "GID2", - "title": "Post Two", - "body": "Body of post two", - "timestamp": "2026-02-20T19:21:20.447Z", - "comments": [{"id": "C1", "text": "A comment"}], - }, - ] - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__happy_path(self, mock_get, mock_token) -> None: - """Test that get_posts returns posts from the API.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=self.MOCK_POSTS - ) - - posts = await s.get_posts() - - mock_url = "https://api.spond.com/core/v1/posts/" - mock_get.assert_called_once_with( - mock_url, - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - params={ - "type": "PLAIN", - "max": "20", - "includeComments": "true", - }, - ) - assert posts == self.MOCK_POSTS - assert s.posts == self.MOCK_POSTS - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__with_group_id(self, mock_get, mock_token) -> None: - """Test that group_id is passed as a query parameter.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock( - return_value=[self.MOCK_POSTS[0]] - ) - - posts = await s.get_posts(group_id="GID1") - - mock_get.assert_called_once_with( - "https://api.spond.com/core/v1/posts/", - headers={ - "content-type": "application/json", - "Authorization": f"Bearer {mock_token}", - }, - params={ - "type": "PLAIN", - "max": "20", - "includeComments": "true", - "groupId": "GID1", - }, - ) - assert len(posts) == 1 - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__custom_max(self, mock_get, mock_token) -> None: - """Test that max_posts parameter is respected.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) - - await s.get_posts(max_posts=5) - - call_params = mock_get.call_args[1]["params"] - assert call_params["max"] == "5" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__no_comments(self, mock_get, mock_token) -> None: - """Test that include_comments=False is passed correctly.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = True - mock_get.return_value.__aenter__.return_value.json = AsyncMock(return_value=[]) - - await s.get_posts(include_comments=False) - - call_params = mock_get.call_args[1]["params"] - assert call_params["includeComments"] == "false" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.get") - async def test_get_posts__api_error_raises(self, mock_get, mock_token) -> None: - """Test that a failed API response raises ValueError.""" - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - s.token = mock_token - - mock_get.return_value.__aenter__.return_value.ok = False - mock_get.return_value.__aenter__.return_value.status = 401 - mock_get.return_value.__aenter__.return_value.text = AsyncMock( - return_value="Unauthorized" - ) - - with pytest.raises(ValueError, match="401"): - await s.get_posts() - - -class TestLogin: - @pytest.mark.parametrize( - ("login_result", "expected"), - [ - ( - {"accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}}, - "ABC", - ), - ], - ) - def test_extract_access_token__happy_path(self, login_result, expected) -> None: - assert _SpondBase._extract_access_token(login_result) == expected - - @pytest.mark.parametrize( - "login_result", - [ - {"error": "Invalid credentials"}, - {"accessToken": None}, - {"accessToken": {}}, - {"accessToken": {"token": ""}}, - {"accessToken": {"token": None}}, - ], - ) - def test_extract_access_token__bad_shape_raises(self, login_result) -> None: - with pytest.raises(AuthenticationError): - _SpondBase._extract_access_token(login_result) - - def test_extract_access_token__error_message_drops_sensitive_fields( - self, - ) -> None: - """The exception message must not leak unwhitelisted fields from the - login response (e.g. a 2FA challenge `token` or `phoneNumber`).""" - login_result = { - "token": "TWO_FA_CHALLENGE_TOKEN_VALUE", - "phoneNumber": "****12", - "errorKey": "twoFactorRequired", - } - with pytest.raises(AuthenticationError) as exc_info: - _SpondBase._extract_access_token(login_result) - - message = str(exc_info.value) - assert "TWO_FA_CHALLENGE_TOKEN_VALUE" not in message - assert "phoneNumber" not in message - assert "twoFactorRequired" in message # whitelisted field surfaces - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_login__happy_path(self, mock_post) -> None: - mock_response = { - "accessToken": {"token": "ABC", "expiration": "2026-05-14T12:00:00Z"}, - "refreshToken": {"token": "REF", "expiration": "2026-08-11T12:00:00Z"}, - "passwordToken": {"token": "PWD", "expiration": "2026-05-13T13:00:00Z"}, - } - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value=mock_response - ) - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - await s.login() - - mock_post.assert_called_once_with( - "https://api.spond.com/core/v1/auth2/login", - json={"email": MOCK_USERNAME, "password": MOCK_PASSWORD}, - ) - assert s.token == "ABC" - - @pytest.mark.asyncio - @patch("aiohttp.ClientSession.post") - async def test_login__error_response_raises(self, mock_post) -> None: - mock_post.return_value.__aenter__.return_value.json = AsyncMock( - return_value={"error": "Invalid credentials"} - ) - - s = Spond(MOCK_USERNAME, MOCK_PASSWORD) - with pytest.raises(AuthenticationError): - await s.login() - assert s.token is None - - -class TestRequireAuthenticationDecorator: - """The `require_authentication` decorator must preserve the wrapped - method's metadata (signature, docstring, name) so `inspect`-based - tools — pdoc, IDE help, tab completion — see the real method - rather than the wrapper's `(*args, **kwargs)` shim. - """ - - def test_decorator_preserves_signature(self) -> None: - """Decorated methods must expose their real parameter list.""" - import inspect - - # `get_posts` is decorated and has a distinctive signature - params = list(inspect.signature(Spond.get_posts).parameters) - assert params == ["self", "group_id", "max_posts", "include_comments"] - - def test_decorator_preserves_docstring(self) -> None: - """Decorated methods must expose their own docstring, not the - wrapper's.""" - import inspect - - doc = inspect.getdoc(Spond.get_profile) or "" - # Wrapper docstring would start with 'Decorator that...' if leaked. - assert "Retrieve the authenticated user's profile." in doc - - def test_decorator_preserves_name(self) -> None: - """`__name__` must be the method's, not 'wrapper'.""" - assert Spond.get_events.__name__ == "get_events"