1use nextcloud_ocs::OcsClient;
9use nextcloud_sdk_core::{ApiPath, AuthProvider, Error, Transport};
10
11use crate::activity::Activity;
12use crate::query::ActivityFilter;
13
14const ACTIVITY_API_PATH: &str = "apps/activity/api/v2/activity";
16
17#[derive(Clone, Copy, Debug)]
26pub struct ActivityClient<'a, T: Transport, A: AuthProvider> {
27 ocs: &'a OcsClient<T, A>,
28}
29
30impl<'a, T: Transport, A: AuthProvider> ActivityClient<'a, T, A> {
31 pub const fn new(ocs: &'a OcsClient<T, A>) -> Self {
33 Self { ocs }
34 }
35
36 pub const fn ocs(&self) -> &'a OcsClient<T, A> {
38 self.ocs
39 }
40
41 pub async fn list(&self, filter: &ActivityFilter) -> Result<Vec<Activity>, Error> {
48 tracing::debug!("Listing activity stream");
49 let path = ApiPath::ocs(ACTIVITY_API_PATH)?;
50 self.ocs.get(path, filter.to_query()).await
51 }
52
53 pub async fn list_for_object(
62 &self,
63 object_type: &str,
64 object_id: u64,
65 ) -> Result<Vec<Activity>, Error> {
66 let filter = ActivityFilter::new().object(object_type, object_id);
67 self.list(&filter).await
68 }
69}
70
71#[cfg(test)]
72mod tests {
73 use std::future::Future;
74 use std::sync::{Arc, Mutex};
75 use std::task::{Context, Poll, Waker};
76
77 use nextcloud_ocs::OcsClient;
78 use nextcloud_sdk_core::{
79 AuthProvider, Error, Headers, Request, Response, StatusCode, Transport,
80 };
81
82 use super::ActivityClient;
83 use crate::query::ActivityFilter;
84
85 fn block_on<F: Future>(future: F) -> F::Output {
86 let mut future = std::pin::pin!(future);
87 let mut context = Context::from_waker(Waker::noop());
88 loop {
89 match future.as_mut().poll(&mut context) {
90 Poll::Ready(value) => return value,
91 Poll::Pending => std::thread::yield_now(),
92 }
93 }
94 }
95
96 #[derive(Clone, Debug)]
97 struct FakeTransport {
98 response: Response,
99 seen: Arc<Mutex<Option<Request>>>,
100 }
101
102 impl FakeTransport {
103 fn new(status: u16, body: &str) -> Self {
104 Self {
105 response: Response::new(StatusCode::new(status), Headers::new(), body.as_bytes()),
106 seen: Arc::new(Mutex::new(None)),
107 }
108 }
109
110 fn request(&self) -> Option<Request> {
111 self.seen.lock().ok().and_then(|seen| seen.clone())
112 }
113 }
114
115 impl Transport for FakeTransport {
116 async fn execute(&self, request: Request) -> Result<Response, Error> {
117 if let Ok(mut seen) = self.seen.lock() {
118 *seen = Some(request);
119 }
120 Ok(self.response.clone())
121 }
122 }
123
124 #[derive(Clone, Debug)]
125 struct FakeAuth;
126
127 impl AuthProvider for FakeAuth {
128 fn authenticate(&self, headers: &mut Headers) {
129 headers.set("Authorization", "Bearer test-activity-token");
130 }
131 }
132
133 const ACTIVITY_LIST_RESPONSE: &str = r#"{
134 "ocs": {
135 "meta": {
136 "status": "ok",
137 "statuscode": 200,
138 "message": "OK"
139 },
140 "data": [
141 {
142 "activity_id": 1,
143 "datetime": "2026-10-24T12:00:00+00:00",
144 "app": "files",
145 "type": "file_created",
146 "user": "alice",
147 "subject": "alice created document.odt",
148 "message": null,
149 "link": "https://example.com/apps/files/?dir=/test",
150 "object_type": "files",
151 "object_id": 10,
152 "object_name": "/test/document.odt"
153 },
154 {
155 "activity_id": 2,
156 "datetime": "2026-10-24T12:05:00+00:00",
157 "app": "comments",
158 "type": "comment_added",
159 "user": "bob",
160 "subject": "bob commented on document.odt",
161 "message": "Looks great!",
162 "link": null,
163 "object_type": "files",
164 "object_id": 10,
165 "object_name": "/test/document.odt"
166 }
167 ]
168 }
169 }"#;
170
171 const EMPTY_LIST_RESPONSE: &str = r#"{
172 "ocs": {
173 "meta": {
174 "status": "ok",
175 "statuscode": 200,
176 "message": "OK"
177 },
178 "data": []
179 }
180 }"#;
181
182 const OCS_FAILURE_RESPONSE: &str = r#"{
183 "ocs": {
184 "meta": {
185 "status": "failure",
186 "statuscode": 997,
187 "message": "App not installed"
188 },
189 "data": []
190 }
191 }"#;
192
193 #[test]
194 fn list_fetches_and_decodes_activities() {
195 let transport = FakeTransport::new(200, ACTIVITY_LIST_RESPONSE);
196 let ocs = OcsClient::new(transport.clone(), FakeAuth);
197 let client = ActivityClient::new(&ocs);
198
199 let filter = ActivityFilter::new().since(0).limit(10).sort("desc");
200 let activities = block_on(client.list(&filter)).expect("list call succeeds");
201
202 assert_eq!(activities.len(), 2);
203 assert_eq!(activities[0].activity_id, 1);
204 assert_eq!(activities[0].app, "files");
205 assert_eq!(activities[0].activity_type, "file_created");
206 assert_eq!(activities[0].user, "alice");
207 assert_eq!(activities[0].subject, "alice created document.odt");
208 assert_eq!(activities[0].message, None);
209 assert_eq!(activities[0].object_id, Some(10));
210
211 assert_eq!(activities[1].activity_id, 2);
212 assert_eq!(activities[1].app, "comments");
213 assert_eq!(activities[1].activity_type, "comment_added");
214 assert_eq!(activities[1].user, "bob");
215 assert_eq!(activities[1].message, Some("Looks great!".to_string()));
216
217 let req = transport.request().expect("request was recorded");
218 assert_eq!(
219 req.path().as_str(),
220 "/ocs/v2.php/apps/activity/api/v2/activity?format=json&since=0&limit=10&sort=desc"
221 );
222 assert_eq!(req.headers().get("OCS-APIRequest"), Some("true"));
223 assert_eq!(req.headers().get("Accept"), Some("application/json"));
224 assert_eq!(
225 req.headers().get("Authorization"),
226 Some("Bearer test-activity-token")
227 );
228 }
229
230 #[test]
231 fn list_for_object_encodes_object_parameters() {
232 let transport = FakeTransport::new(200, ACTIVITY_LIST_RESPONSE);
233 let ocs = OcsClient::new(transport.clone(), FakeAuth);
234 let client = ActivityClient::new(&ocs);
235
236 let activities =
237 block_on(client.list_for_object("files", 10)).expect("list_for_object succeeds");
238 assert_eq!(activities.len(), 2);
239
240 let req = transport.request().expect("request was recorded");
241 assert_eq!(
242 req.path().as_str(),
243 "/ocs/v2.php/apps/activity/api/v2/activity?format=json&object_type=files&object_id=10"
244 );
245 }
246
247 #[test]
248 fn empty_activity_list_handled_gracefully() {
249 let transport = FakeTransport::new(200, EMPTY_LIST_RESPONSE);
250 let ocs = OcsClient::new(transport, FakeAuth);
251 let client = ActivityClient::new(&ocs);
252
253 let activities =
254 block_on(client.list(&ActivityFilter::new())).expect("empty list succeeds");
255 assert!(activities.is_empty());
256 }
257
258 #[test]
259 fn ocs_failure_returns_status_error() {
260 let transport = FakeTransport::new(200, OCS_FAILURE_RESPONSE);
261 let ocs = OcsClient::new(transport, FakeAuth);
262 let client = ActivityClient::new(&ocs);
263
264 let result = block_on(client.list(&ActivityFilter::new()));
265 assert!(result.is_err());
266 match result {
267 Err(Error::Status(err)) => {
268 assert_eq!(err.status(), StatusCode::new(200));
269 }
270 other => panic!("expected Error::Status, got {other:?}"),
271 }
272 }
273}