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