#![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,
};
#[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>>,
}
#[derive(Debug, Default)]
pub struct MemoryPcsExternal {
state: Mutex<State>,
}
impl MemoryPcsExternal {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_send_outcome(self, outcome: Result<SendOutcome, PcsFailure>) -> Self {
self.state.lock().unwrap().next_send_outcome = Some(outcome);
self
}
#[must_use]
pub fn with_status(self, status: Result<SendStatus, PcsFailure>) -> Self {
self.state.lock().unwrap().next_status = Some(status);
self
}
#[must_use]
pub fn sent(&self) -> Vec<RecordedSend> {
self.state.lock().unwrap().sent.clone()
}
#[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 { .. })));
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();
}
}