diff --git a/AGENTS.md b/AGENTS.md index 51eb565..3bced64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -124,7 +124,7 @@ The CLI currently covers: - `channel`: list/get/create/update/delete/member operations. - `message`: send/list/get/reply/update/delete/react/unreact/pin/unpin. - `chat`: list/get/create/update/hide/unhide/member operations. -- `presence`: get, batch get, set, clear, status message. +- `presence`: get, batch get, set, clear, preferred set/clear, status message. - `search`: messages, users, teams. - `tag`: list/get/create/update/delete/member operations. - `meeting`: list/get/create/update/delete/join URL/attendance. diff --git a/CHANGELOG.md b/CHANGELOG.md index 682571d..0c16565 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ - `teams message send --subject TEXT` sets the subject line on a channel root message — the bold title Teams renders above the body, the same field the client offers behind "Add a subject". Channel sends only: chat messages have no subject, so `--subject` with `--chat` (or without `--channel`) is rejected as invalid input before anything is sent. - `teams message list --team T --channel C --message-id ROOT` lists the replies under one channel thread root, paged like any other listing. Without it a caller could not tell from the existing channel-list output whether a question had already been answered, because that listing returns thread roots only. - `teams message reply --mention USER` (repeatable) tags a person in a threaded reply the same way `message send --mention` does, so a name in a reply notifies rather than merely appears. +- `teams presence set-preferred --availability A [--expiration D]` and `teams presence clear-preferred` manage the user-preferred presence, the layer Microsoft Graph ranks above every presence session while one exists. The Teams client's "Appear offline" lives there as `Offline`/`OffWork`, and until now nothing in the CLI could reach it: `presence set` returned success and the account stayed offline. Each of the six availabilities Graph accepts here has exactly one activity, so the command derives it and reports both back. The expiration is checked as a positive ISO 8601 duration in whole day, hour, minute and second units but not bounded, because Graph documents defaults of one day for `Busy` and `DoNotDisturb` and seven days otherwise, rather than a range. + +### Changed + +- Refreshed two Rust dependencies: `uuid` 1.24.1 → 1.26.0 (the weekly rust-minor group) and `rand` 0.9.2 → 0.9.3, which clears RUSTSEC-2026-0097 (`rand::rng()` unsound with a custom logger; reached only through `reqwest`'s QUIC dependency) from `cargo audit`. ### Fixed diff --git a/CLAUDE.md b/CLAUDE.md index 8e7bca1..7ecd2a1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,7 +26,7 @@ Single-crate Rust binary. Key modules: - `channel.rs` — list, get, create, update, delete, members - `message.rs` — send, list, get, reply, delete, react, unreact, pin, unpin - `chat.rs` — list, get, create, hide, unhide, members - - `presence.rs` — get, set, clear, status, get-batch + - `presence.rs` — get, set, set-preferred, clear, clear-preferred, status, get-batch - `search.rs` — messages, users, teams - `tag.rs` — list, get, create, delete, add-member, remove-member - `meeting.rs` — list, get, create, delete, join-url, attendance diff --git a/Cargo.lock b/Cargo.lock index 2af595c..a96f4f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1534,9 +1534,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha", "rand_core", diff --git a/README.md b/README.md index 8292bc3..64968e4 100644 --- a/README.md +++ b/README.md @@ -460,6 +460,9 @@ teams presence get-batch --user-ids , teams presence set --availability Available --activity Available teams presence set --availability Busy --activity InACall --expiration PT1H teams presence clear +teams presence set-preferred --availability Offline # "Appear offline" +teams presence set-preferred --availability Busy --expiration PT8H +teams presence clear-preferred teams presence status --message "In deep focus" [--expiry ] ``` @@ -479,6 +482,21 @@ Graph accepts five `--availability`/`--activity` pairs: `Available`/`Available`, applies Graph's own five-minute default, so a presence set this way lapses on its own either way. +A preferred presence is a second layer above the sessions. Graph ranks it over +every session's state for as long as at least one session exists, which is how +the Teams client's "Appear offline" works: it is the preferred pair +`Offline`/`OffWork`, and it hides whatever `set` or a signed-in client reports +underneath. `set-preferred` takes one of the six availabilities Graph accepts, +`Available`, `Busy`, `DoNotDisturb`, `BeRightBack`, `Away` or `Offline`, and +sends the one activity Graph pairs with it, reporting both back. Its +`--expiration` is a positive ISO 8601 duration written in whole day, hour, +minute and second units, `P1D` included; left out, +Graph applies one day for `Busy` and `DoNotDisturb` and seven days for the +rest. `clear-preferred` removes the override so the sessions show through +again. With no session at all the user reads `Offline` whatever the preferred +presence says, so an account that is never signed into a Teams client needs +`set` as well as `set-preferred` to appear available. + ### Search ```bash diff --git a/docs/auth.md b/docs/auth.md index 522a3bc..4a820be 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -88,7 +88,7 @@ Presence.Read.All Presence.ReadWrite ``` -These permissions cover the current chat read/write, channel-send, team/channel discovery, user lookup, and presence reads and writes. `Presence.ReadWrite` is what `presence set`, `presence status` and `presence clear` require; Microsoft does not mark it admin-consent required. The default does not include `ChannelMessage.Read.All` because Microsoft marks that delegated Graph scope as admin-consent required. Add it explicitly when a workflow needs channel message reads: +These permissions cover the current chat read/write, channel-send, team/channel discovery, user lookup, and presence reads and writes. `Presence.ReadWrite` is what `presence set`, `presence set-preferred`, `presence status`, `presence clear` and `presence clear-preferred` require; Microsoft does not mark it admin-consent required. The default does not include `ChannelMessage.Read.All` because Microsoft marks that delegated Graph scope as admin-consent required. Add it explicitly when a workflow needs channel message reads: ```bash teams auth login --device-code --scopes "User.Read ChannelMessage.Read.All offline_access" diff --git a/docs/command-reference.md b/docs/command-reference.md index 64992ef..69461f6 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -188,8 +188,10 @@ teams presence get --user USER_ID teams presence get --users USER_ID,USER_ID teams presence get-batch --user-ids USER_ID,USER_ID teams presence set --availability AVAILABILITY --activity ACTIVITY [--expiration PT5M..PT4H] +teams presence set-preferred --availability Available|Busy|DoNotDisturb|BeRightBack|Away|Offline [--expiration WHOLE_UNIT_ISO8601_DURATION] teams presence status --message TEXT [--expiry ISO8601_DATETIME] teams presence clear +teams presence clear-preferred ``` ## Search diff --git a/docs/man/teams.1 b/docs/man/teams.1 index 2f1d3f6..91d3358 100644 --- a/docs/man/teams.1 +++ b/docs/man/teams.1 @@ -243,8 +243,10 @@ teams presence get --users USER_ID,USER_ID teams presence get --user-ids USER_ID,USER_ID teams presence get-batch --user-ids USER_ID,USER_ID teams presence set --availability AVAILABILITY --activity ACTIVITY [--expiration PT5M..PT4H] +teams presence set-preferred --availability Available|Busy|DoNotDisturb|BeRightBack|Away|Offline [--expiration WHOLE_UNIT_ISO8601_DURATION] teams presence status --message TEXT [--expiry ISO8601_DATETIME] teams presence clear +teams presence clear-preferred .fi .SH SEARCH COMMANDS .nf diff --git a/docs/teams-cli-prd.md b/docs/teams-cli-prd.md index c4eac82..13c365b 100644 --- a/docs/teams-cli-prd.md +++ b/docs/teams-cli-prd.md @@ -440,6 +440,12 @@ teams presence set --availability \ [--expiration ] +# Set or clear the preferred presence that overrides presence sessions +teams presence set-preferred \ + --availability \ + [--expiration ] +teams presence clear-preferred + # Set status message teams presence status --message "In deep focus until 3pm" \ [--expiry ] diff --git a/src/api/endpoints.rs b/src/api/endpoints.rs index fccaf0c..49e7b95 100644 --- a/src/api/endpoints.rs +++ b/src/api/endpoints.rs @@ -188,6 +188,14 @@ pub fn set_status_message() -> String { format!("{GRAPH_V1}/me/presence/setStatusMessage") } +pub fn set_user_preferred_presence() -> String { + format!("{GRAPH_V1}/me/presence/setUserPreferredPresence") +} + +pub fn clear_user_preferred_presence() -> String { + format!("{GRAPH_V1}/me/presence/clearUserPreferredPresence") +} + // --- Search --- pub fn search_query() -> String { format!("{GRAPH_V1}/search/query") @@ -411,6 +419,18 @@ pub fn shares_drive_item_content(sharing_token: &str) -> String { mod tests { use super::*; + #[test] + fn preferred_presence_write_urls_target_the_signed_in_user() { + assert_eq!( + set_user_preferred_presence(), + "https://graph.microsoft.com/v1.0/me/presence/setUserPreferredPresence" + ); + assert_eq!( + clear_user_preferred_presence(), + "https://graph.microsoft.com/v1.0/me/presence/clearUserPreferredPresence" + ); + } + #[test] fn sharing_url_token_matches_graph_convention() { // Expected value computed with: printf '%s' "$URL" | base64 | tr '+/' '-_' | tr -d '=' diff --git a/src/api/presence.rs b/src/api/presence.rs index f5dadd4..df39a49 100644 --- a/src/api/presence.rs +++ b/src/api/presence.rs @@ -1,8 +1,8 @@ use crate::error::{Result, TeamsError}; use crate::models::common::PageResponse; use crate::models::presence::{ - ClearPresenceRequest, GetPresenceBatchRequest, Presence, SetPresenceRequest, - SetStatusMessageRequest, + ClearPresenceRequest, ClearUserPreferredPresenceRequest, GetPresenceBatchRequest, Presence, + SetPresenceRequest, SetStatusMessageRequest, SetUserPreferredPresenceRequest, }; use super::client::GraphClient; @@ -63,6 +63,31 @@ pub async fn set_status_message(client: &GraphClient, req: &SetStatusMessageRequ .await } +pub async fn set_user_preferred_presence( + client: &GraphClient, + req: &SetUserPreferredPresenceRequest, +) -> Result<()> { + set_user_preferred_presence_at(client, &endpoints::set_user_preferred_presence(), req).await +} + +async fn set_user_preferred_presence_at( + client: &GraphClient, + url: &str, + req: &SetUserPreferredPresenceRequest, +) -> Result<()> { + client.post_no_content(url, req).await +} + +pub async fn clear_user_preferred_presence(client: &GraphClient) -> Result<()> { + clear_user_preferred_presence_at(client, &endpoints::clear_user_preferred_presence()).await +} + +async fn clear_user_preferred_presence_at(client: &GraphClient, url: &str) -> Result<()> { + client + .post_no_content(url, &ClearUserPreferredPresenceRequest {}) + .await +} + #[cfg(test)] mod tests { use super::*; @@ -248,4 +273,53 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn set_user_preferred_presence_sends_the_pair_and_expiration() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/me/presence/setUserPreferredPresence")) + .and(body_json(serde_json::json!({ + "availability": "Offline", + "activity": "OffWork", + "expirationDuration": "P1D" + }))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + set_user_preferred_presence_at( + &test_client(), + &format!("{}/me/presence/setUserPreferredPresence", server.uri()), + &SetUserPreferredPresenceRequest { + availability: "Offline".to_string(), + activity: "OffWork".to_string(), + expiration_duration: Some("P1D".to_string()), + }, + ) + .await + .unwrap(); + } + + /// Graph documents the body as `{}`. `body_json` compares parsed values, so a request that + /// went out as `null` or with no body would not match. + #[tokio::test] + async fn clear_user_preferred_presence_sends_an_empty_object() { + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(path("/me/presence/clearUserPreferredPresence")) + .and(body_json(serde_json::json!({}))) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + clear_user_preferred_presence_at( + &test_client(), + &format!("{}/me/presence/clearUserPreferredPresence", server.uri()), + ) + .await + .unwrap(); + } } diff --git a/src/cli/presence.rs b/src/cli/presence.rs index 3a9a801..78a052c 100644 --- a/src/cli/presence.rs +++ b/src/cli/presence.rs @@ -8,7 +8,7 @@ use crate::config::{self, ConfigFile}; use crate::error::{Result, TeamsError}; use crate::models::presence::{ ClearPresenceRequest, DateTimeTimeZone, SetPresenceRequest, SetStatusMessageBody, - SetStatusMessageRequest, StatusMessageContent, + SetStatusMessageRequest, SetUserPreferredPresenceRequest, StatusMessageContent, }; use crate::output::{self, OutputFormat}; @@ -46,6 +46,17 @@ pub enum PresenceCommand { #[arg(long, value_parser = parse_expiration)] expiration: Option, }, + /// Set your preferred presence, which overrides every session's status while one exists + SetPreferred { + /// Availability: Available, Busy, DoNotDisturb, BeRightBack, Away or Offline. Graph + /// pairs each with one activity, which is sent alongside it + #[arg(long, value_parser = parse_preferred_availability)] + availability: PreferredPresence, + /// Positive ISO 8601 duration in whole units, such as PT8H or P1D. Graph's default is + /// P1D for Busy and DoNotDisturb and P7D for the rest + #[arg(long, value_parser = parse_preferred_expiration)] + expiration: Option, + }, /// Set your status message Status { /// Status message text @@ -57,6 +68,61 @@ pub enum PresenceCommand { }, /// Clear your presence (revert to automatic) Clear, + /// Clear your preferred presence, so the sessions' status shows again + ClearPreferred, +} + +/// A preferred presence is set on the user rather than on an application's session, and Graph +/// ranks it above every session while at least one exists. It accepts six availabilities, each +/// paired with exactly one activity, so the activity is derived here rather than asked for: a +/// caller cannot then send a pair Graph rejects, and the derived value is reported back so the +/// request stays visible in full. +#[derive(Debug, Clone)] +pub struct PreferredPresence { + availability: &'static str, + activity: &'static str, +} + +const PREFERRED_PRESENCE_PAIRS: [(&str, &str); 6] = [ + ("Available", "Available"), + ("Busy", "Busy"), + ("DoNotDisturb", "DoNotDisturb"), + ("BeRightBack", "BeRightBack"), + ("Away", "Away"), + ("Offline", "OffWork"), +]; + +fn parse_preferred_availability(raw: &str) -> std::result::Result { + PREFERRED_PRESENCE_PAIRS + .iter() + .find(|(availability, _)| availability.eq_ignore_ascii_case(raw.trim())) + .map(|&(availability, activity)| PreferredPresence { + availability, + activity, + }) + .ok_or_else(|| { + let accepted: Vec<&str> = PREFERRED_PRESENCE_PAIRS + .iter() + .map(|(availability, _)| *availability) + .collect(); + format!( + "`{raw}` is not a preferred availability; Microsoft Graph accepts {}", + accepted.join(", ") + ) + }) +} + +/// Graph documents no bounds for a preferred presence expiration, only the defaults it applies +/// when none is sent, so the check here is that the value is a positive duration in the same +/// whole-unit form the CLI accepts for a session. Unlike a session expiration it may run to days, +/// which is why `parse_expiration` is not reused. +fn parse_preferred_expiration(raw: &str) -> std::result::Result { + match iso8601_duration_seconds(raw) { + Some(seconds) if seconds > 0 => Ok(raw.to_string()), + _ => Err(format!( + "`{raw}` is not an ISO 8601 duration in whole units; write it as PT8H, P1D or P7D" + )), + } } /// Graph accepts a presence expiration between five minutes and four hours, and applies a @@ -96,6 +162,7 @@ const EXPIRATION_MAX_SECONDS: u64 = 4 * 60 * 60; fn iso8601_duration_seconds(raw: &str) -> Option { let rest = raw.strip_prefix('P')?; let (date, time) = match rest.split_once('T') { + Some((_, "")) => return None, Some((date, time)) => (date, Some(time)), None => (rest, None), }; @@ -271,6 +338,37 @@ pub async fn run( output::print_success(format, &result, start); Ok(()) } + + PresenceCommand::SetPreferred { + availability, + expiration, + } => { + auth::require_delegated_token(&client.token, "Setting your preferred Teams presence")?; + let start = Instant::now(); + let req = SetUserPreferredPresenceRequest { + availability: availability.availability.to_string(), + activity: availability.activity.to_string(), + expiration_duration: expiration, + }; + api::presence::set_user_preferred_presence(&client, &req).await?; + let result = serde_json::json!({ + "status": "preferred_presence_set", + "availability": req.availability, + "activity": req.activity, + "expiration_duration": req.expiration_duration, + }); + output::print_success(format, &result, start); + Ok(()) + } + + PresenceCommand::ClearPreferred => { + auth::require_delegated_token(&client.token, "Clearing your preferred Teams presence")?; + let start = Instant::now(); + api::presence::clear_user_preferred_presence(&client).await?; + let result = serde_json::json!({ "status": "preferred_presence_cleared" }); + output::print_success(format, &result, start); + Ok(()) + } } } @@ -428,7 +526,7 @@ mod tests { #[test] fn values_that_are_not_durations_are_rejected_before_any_request() { for raw in [ - "", "1h", "P", "PT", "PTH", "PT1", "PT-1H", "PT1.5H", "1HPT", "PT1X", + "", "1h", "P", "PT", "P1DT", "PTH", "PT1", "PT-1H", "PT1.5H", "1HPT", "PT1X", ] { let err = parse_expiration(raw).unwrap_err(); assert!(err.contains("not an ISO 8601 duration"), "{raw}: {err}"); @@ -449,4 +547,52 @@ mod tests { assert!(parse_expiration("PT5M").is_ok()); assert!(parse_expiration("PT300S").is_ok()); } + + #[test] + fn a_preferred_availability_derives_the_activity_graph_pairs_with_it() { + for (availability, activity) in PREFERRED_PRESENCE_PAIRS { + let pair = parse_preferred_availability(availability).unwrap(); + assert_eq!(pair.availability, availability); + assert_eq!(pair.activity, activity); + } + assert_eq!( + parse_preferred_availability("Offline").unwrap().activity, + "OffWork" + ); + } + + #[test] + fn a_preferred_availability_is_matched_without_regard_to_case() { + let pair = parse_preferred_availability(" donotdisturb ").unwrap(); + assert_eq!(pair.availability, "DoNotDisturb"); + assert_eq!(pair.activity, "DoNotDisturb"); + } + + /// `set` accepts activities such as `InACall` that a preferred presence does not, so the + /// error names the six values rather than leaving the caller to try the other command's. + #[test] + fn a_value_that_is_not_a_preferred_availability_is_rejected_with_the_accepted_names() { + for raw in ["OffWork", "InACall", "Presenting", "", "banana"] { + let err = parse_preferred_availability(raw).unwrap_err(); + assert!( + err.contains("Available, Busy, DoNotDisturb, BeRightBack, Away, Offline"), + "{raw}: {err}" + ); + } + } + + #[test] + fn a_preferred_expiration_may_run_to_days() { + for raw in ["PT5M", "PT8H", "P1D", "P7D", "P1DT12H"] { + assert_eq!(parse_preferred_expiration(raw).as_deref(), Ok(raw), "{raw}"); + } + } + + #[test] + fn a_preferred_expiration_that_is_not_a_positive_duration_is_rejected() { + for raw in ["", "8h", "PT0S", "P0D", "P1DT", "PT1.5H", "PT1M1H"] { + let err = parse_preferred_expiration(raw).unwrap_err(); + assert!(err.contains("not an ISO 8601 duration"), "{raw}: {err}"); + } + } } diff --git a/src/help_json.rs b/src/help_json.rs index 7f0f551..2aaa7b7 100644 --- a/src/help_json.rs +++ b/src/help_json.rs @@ -280,6 +280,23 @@ mod tests { "{}", availability.description ); + + let set_preferred = find(&commands, &["presence", "set-preferred"]); + let preferred_availability = set_preferred + .flags + .iter() + .find(|f| f.name == "--availability") + .expect("--availability"); + assert!( + preferred_availability.required, + "preferred availability is a required option" + ); + assert!( + preferred_availability.description.contains("Offline"), + "{}", + preferred_availability.description + ); + find(&commands, &["presence", "clear-preferred"]); } #[test] diff --git a/src/models/presence.rs b/src/models/presence.rs index 7912405..8e77833 100644 --- a/src/models/presence.rs +++ b/src/models/presence.rs @@ -53,6 +53,22 @@ pub struct ClearPresenceRequest { pub session_id: String, } +/// Request body for POST /me/presence/setUserPreferredPresence. A preferred presence belongs to +/// the user rather than to an application's session, so there is no `sessionId`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetUserPreferredPresenceRequest { + pub availability: String, + pub activity: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub expiration_duration: Option, +} + +/// Request body for POST /me/presence/clearUserPreferredPresence, which Graph documents as an +/// empty JSON object. A braced struct serializes to `{}`; a unit struct would go out as `null`. +#[derive(Debug, Clone, Serialize)] +pub struct ClearUserPreferredPresenceRequest {} + /// Request body for POST /me/presence/setStatusMessage #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] @@ -217,4 +233,27 @@ mod tests { serde_json::json!({"sessionId": "app-id"}) ); } + + /// Graph applies its own default when no expiration is sent, so the key must be absent rather + /// than `null`. + #[test] + fn set_user_preferred_presence_request_omits_an_absent_expiration() { + let req = SetUserPreferredPresenceRequest { + availability: "Available".to_string(), + activity: "Available".to_string(), + expiration_duration: None, + }; + assert_eq!( + serde_json::to_value(&req).unwrap(), + serde_json::json!({"availability": "Available", "activity": "Available"}) + ); + } + + #[test] + fn clear_user_preferred_presence_request_is_an_empty_object() { + assert_eq!( + serde_json::to_value(ClearUserPreferredPresenceRequest {}).unwrap(), + serde_json::json!({}) + ); + } } diff --git a/tests/cli.rs b/tests/cli.rs index eca3c46..1d73860 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -63,6 +63,70 @@ fn presence_set_rejects_a_bad_expiration_before_it_needs_credentials() { .stderr(predicate::str::contains("PT5M to PT4H")); } +/// The activity is derived from the availability, so the only way to send a pair Graph rejects +/// is for the value parser to be dropped from the attribute; the checks below would notice. +#[test] +fn presence_set_preferred_rejects_bad_values_before_it_needs_credentials() { + teams() + .args(["presence", "set-preferred", "--availability", "InACall"]) + .assert() + .code(2) + .stderr(predicate::str::contains( + "Available, Busy, DoNotDisturb, BeRightBack, Away, Offline", + )); + + teams() + .args([ + "presence", + "set-preferred", + "--availability", + "Away", + "--expiration", + "8h", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("is not an ISO 8601 duration")); + + teams() + .args([ + "presence", + "set-preferred", + "--availability", + "Away", + "--expiration", + "P1DT", + ]) + .assert() + .code(2) + .stderr(predicate::str::contains("is not an ISO 8601 duration")); +} + +#[test] +fn preferred_presence_writes_reject_app_only_auth_before_graph() { + let payload = serde_json::json!({ "roles": ["Presence.ReadWrite.All"] }); + let token = format!( + "header.{}.signature", + URL_SAFE_NO_PAD.encode(payload.to_string()) + ); + + for args in [ + vec!["presence", "set-preferred", "--availability", "Away"], + vec!["presence", "clear-preferred"], + ] { + teams() + .args(args) + .env("TEAMS_CLI_ACCESS_TOKEN", &token) + .assert() + .code(4) + .stdout( + predicate::str::contains("\"code\": \"PERMISSION_DENIED\"").and( + predicate::str::contains("requires delegated Microsoft Graph auth"), + ), + ); + } +} + #[test] fn help_flag_works() { teams().arg("--help").assert().success().stdout( @@ -835,7 +899,9 @@ fn presence_help_shows_subcommands() { .stdout( predicate::str::contains("get") .and(predicate::str::contains("set")) + .and(predicate::str::contains("set-preferred")) .and(predicate::str::contains("clear")) + .and(predicate::str::contains("clear-preferred")) .and(predicate::str::contains("status")), ); }