pcs-external 0.2.0

Ppoppo Chat System (PCS) External API client -- gRPC client for the External Developer Platform
Documentation
//! `MemoryPcsExternal` — in-memory [`PcsExternalPort`](super::PcsExternalPort)
//! for boundary testing.
//!
//! Records every `send_alert` call as an inert tuple `(TemplateId,
//! RecipientList, Option<PollConfig>)`; canned outcomes / failures
//! are fed via builder methods. Any call to [`Self::raw_channel`]
//! panics deliberately — escape-hatch RPCs are out of scope for the
//! in-process fake (the RFC scopes them to "real PCS or a wiremock-grpc
//! setup").
//!
//! Available only when the `test-support` feature is enabled.

#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]

use std::sync::Mutex;

use super::failure::PcsFailure;
use super::port::{PcsExternalPort, RawPcsChannel};
use super::types::{
    PollConfig, RecipientList, SendOutcome, SendRequestId, SendRequestState, SendStatus,
    SendStatusTotals, TemplateId,
};

/// One recorded call to [`PcsExternalPort::send_alert`].
#[derive(Debug, Clone)]
pub struct RecordedSend {
    pub template: TemplateId,
    pub recipients: RecipientList,
    pub poll: Option<PollConfig>,
}

#[derive(Debug, Default)]
struct State {
    sent: Vec<RecordedSend>,
    next_send_outcome: Option<Result<SendOutcome, PcsFailure>>,
    next_status: Option<Result<SendStatus, PcsFailure>>,
}

/// In-memory PCS external port for tests. See module docs.
#[derive(Debug, Default)]
pub struct MemoryPcsExternal {
    state: Mutex<State>,
}

impl MemoryPcsExternal {
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Set the response that the next [`PcsExternalPort::send_alert`] call
    /// will produce. Consumes one outcome per call; if unset when called,
    /// a sensible default `SendOutcome { state: Queued }` is synthesized.
    #[must_use]
    pub fn with_send_outcome(self, outcome: Result<SendOutcome, PcsFailure>) -> Self {
        self.state.lock().unwrap().next_send_outcome = Some(outcome);
        self
    }

    /// Set the response that the next [`PcsExternalPort::get_send_status`]
    /// call will produce.
    #[must_use]
    pub fn with_status(self, status: Result<SendStatus, PcsFailure>) -> Self {
        self.state.lock().unwrap().next_status = Some(status);
        self
    }

    /// Snapshot of every recorded `send_alert` call in order.
    #[must_use]
    pub fn sent(&self) -> Vec<RecordedSend> {
        self.state.lock().unwrap().sent.clone()
    }

    /// Number of recorded `send_alert` calls.
    #[must_use]
    pub fn sent_count(&self) -> usize {
        self.state.lock().unwrap().sent.len()
    }
}

impl PcsExternalPort for MemoryPcsExternal {
    async fn send_alert(
        &self,
        template: &TemplateId,
        recipients: &RecipientList,
        poll: Option<&PollConfig>,
    ) -> Result<SendOutcome, PcsFailure> {
        let total = recipients.len();
        let mut state = self.state.lock().unwrap();
        state.sent.push(RecordedSend {
            template: template.clone(),
            recipients: recipients.clone(),
            poll: poll.cloned(),
        });
        match state.next_send_outcome.take() {
            Some(out) => out,
            None => Ok(SendOutcome {
                id: SendRequestId(format!("memory-send-{}", state.sent.len())),
                state: SendRequestState::Queued,
                total_recipients: u32::try_from(total).unwrap_or(u32::MAX),
            }),
        }
    }

    async fn get_send_status(&self, id: &SendRequestId) -> Result<SendStatus, PcsFailure> {
        let mut state = self.state.lock().unwrap();
        match state.next_status.take() {
            Some(s) => s,
            None => Ok(SendStatus {
                id: id.clone(),
                state: SendRequestState::Queued,
                totals: SendStatusTotals::default(),
            }),
        }
    }

    fn raw_channel(&self) -> RawPcsChannel<'_> {
        panic!(
            "MemoryPcsExternal does not implement raw_channel — use \
             GrpcPcsAdapter (or a wiremock-grpc setup) for advanced RPCs"
        );
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::pcs_port::types::{Ppnum, Recipient};

    fn p(s: &str) -> Ppnum {
        Ppnum::try_new(s).unwrap()
    }

    #[tokio::test]
    async fn records_send_calls_in_order() {
        let port = MemoryPcsExternal::new();
        let template = TemplateId::new("tmpl_attendance_v1");
        let r1 = RecipientList::from_ppnums(vec![p("12345678901")]).unwrap();
        let r2 = RecipientList::from_ppnums(vec![p("12345678902"), p("12345678903")]).unwrap();
        port.send_alert(&template, &r1, None).await.unwrap();
        port.send_alert(&template, &r2, None).await.unwrap();
        let sent = port.sent();
        assert_eq!(sent.len(), 2);
        assert_eq!(sent[0].recipients.len(), 1);
        assert_eq!(sent[1].recipients.len(), 2);
    }

    #[tokio::test]
    async fn default_outcome_reports_total_recipients() {
        let port = MemoryPcsExternal::new();
        let template = TemplateId::new("tmpl_x");
        let recipients =
            RecipientList::from_ppnums(vec![p("12345678901"), p("12345678902")]).unwrap();
        let outcome = port.send_alert(&template, &recipients, None).await.unwrap();
        assert_eq!(outcome.total_recipients, 2);
        assert_eq!(outcome.state, SendRequestState::Queued);
    }

    #[tokio::test]
    async fn canned_failure_is_returned_once() {
        let port = MemoryPcsExternal::new().with_send_outcome(Err(PcsFailure::Rejected {
            code: tonic::Code::ResourceExhausted,
            message: "rate limited".into(),
        }));
        let template = TemplateId::new("t");
        let recipients = RecipientList::from_ppnums(vec![p("12345678901")]).unwrap();
        let result = port.send_alert(&template, &recipients, None).await;
        assert!(matches!(result, Err(PcsFailure::Rejected { .. })));
        // Subsequent call falls back to the synthesized default.
        let result = port.send_alert(&template, &recipients, None).await;
        assert!(result.is_ok());
    }

    #[tokio::test]
    async fn poll_config_is_recorded() {
        let port = MemoryPcsExternal::new();
        let template = TemplateId::new("t");
        let mut r = Recipient::bare(p("12345678901"));
        r.vars.insert("absence_date".into(), "2026-05-01".into());
        let recipients = RecipientList::try_new(vec![r]).unwrap();
        let poll = PollConfig { expires_in_hours: Some(24), allow_multiple: false };
        port.send_alert(&template, &recipients, Some(&poll))
            .await
            .unwrap();
        let recorded = &port.sent()[0];
        assert!(recorded.poll.is_some());
        assert_eq!(
            recorded.poll.as_ref().unwrap().expires_in_hours,
            Some(24)
        );
    }

    #[tokio::test]
    #[should_panic(expected = "raw_channel")]
    async fn raw_channel_panics() {
        let port = MemoryPcsExternal::new();
        let _ = port.raw_channel();
    }
}