Skip to content

Fix presence get for a status message with an expiry - #73

Merged
sionsmith merged 3 commits into
osodevops:mainfrom
aberoham:fix/presence-status-expiry
Aug 29, 2026
Merged

Fix presence get for a status message with an expiry#73
sionsmith merged 3 commits into
osodevops:mainfrom
aberoham:fix/presence-status-expiry

Conversation

@aberoham

@aberoham aberoham commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

teams presence get failed for any account whose Teams status message carries
an expiry. The HTTP call succeeded; deserialization did not.

GET /me/presence returns statusMessage.expiryDateTime as a
dateTimeTimeZone
object:

"expiryDateTime": { "dateTime": "9999-12-31T00:00:00.0000000", "timeZone": "UTC" }

PresenceStatusMessage::expiry_date_time was typed Option<String>, so serde
rejected the body and the command reported API error (200). The write path
already models that shape as SetStatusExpiry, which now derives Deserialize
and serves both directions.

Which read paths this affects

get_my_presence, get_user_presence and get_presence_batch all
deserialize into the same Presence struct, so the failure is data-dependent
rather than path-dependent: any of them breaks the moment the response
actually carries the field.

Two of the three do. Measured against one build, one profile, one minute:

Call Result Why
presence get (/me/presence) fails to parse target has a status message carrying an expiry
presence get --user <id> fails to parse same struct, same expiry
presence get --user <other-id> succeeds that account has no status message, so no expiry to mistype
presence get-batch --user-ids <both> succeeds the batch response omits expiryDateTime entirely

get-batch is unaffected because POST /communications/getPresencesByUserId
does not return the field, not because it parses a different shape. It
returned the same account's statusMessage.message with content and
contentType and no expiryDateTime alongside it.

So the corrected statement is that /me/presence and /users/{id}/presence
are both affected, for any target whose status message carries an expiry.
A single-user lookup is the call an agent makes most often when checking
whether a colleague is free, so it is worth being precise about.

Tests

The pre-existing round-trip test passed only because its fixture sets
statusMessage to null.

Two tests now cover a status message with an expiry. One parses a
/me/presence body. The other serves a /users/{id}/presence body over a
mock and reads it back through get_user_presence_at, the seam
get_user_presence delegates to, with the @odata.context wrapper and
publishedDateTime that Graph actually sends, following the pattern the
message and channel modules already use. Going through the call rather than
parsing a string is what makes it a guard on that path: a refactor giving the
user lookup a response type of its own would otherwise reintroduce the defect
with a fixture test on the shared struct still passing.

Reverting the type change fails both.

Note for whoever merges: #72 also appends a test module to
src/api/presence.rs, so whichever of the two lands second needs a trivial
conflict resolved there.

Fixes #69

🤖 Generated with Claude Code

https://claude.ai/code/session_01C736oc7CeNtRZmAJrXm8sG

aberoham and others added 3 commits August 22, 2026 08:05
`GET /me/presence` returns `statusMessage.expiryDateTime` as a
dateTimeTimeZone object, not a string, so `presence get` for your own
account failed to deserialize whenever the account's status message
carried an expiry. The write path already models that shape as
`SetStatusExpiry`; the read path now reuses it.

The existing round-trip test passed only because its fixture sets
`statusMessage` to null. The new test uses the response shape Graph
actually sends.

Fixes osodevops#69

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`GET /users/{id}/presence` returns the same expiry object as `/me/presence`
and deserializes through the same struct, so it failed the same way. It is a
distinct call site, though, and a refactor that gave it a response type of its
own would reintroduce the defect with a fixture test on the shared struct
still passing.

The test therefore serves the body over a mock and reads it back through
`get_user_presence_at`, the seam `get_user_presence` delegates to, following
the pattern the message and channel modules already use for this. Going
through the request is what makes it a guard on that path rather than on the
struct. The body is what Graph sends for a user whose status message carries
an expiry, the `@odata.context` wrapper and `publishedDateTime` included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C736oc7CeNtRZmAJrXm8sG
The expiry object serves both directions now, so name it after the Graph
resource it models rather than the request that first needed it.

Its two components become optional on the read path. Every other Graph
response model in the crate is optional throughout, and a CLI that fails a
whole `presence get` because one nested string was absent is the failure
mode osodevops#69 already was. The write path is the reason both components exist,
so a test asserts the serialized setStatusMessage body still carries them.

`publishedDateTime` is a documented presenceStatusMessage property that
serde was discarding; it now reaches the JSON envelope.

Adds the CHANGELOG entry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FqBTdhSRTBabYv7kkEiUkE
@sionsmith
sionsmith merged commit 5079d19 into osodevops:main Aug 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

presence get fails for your own account when the status message has an expiry

2 participants