1#![allow(missing_docs, clippy::missing_docs_in_private_items)]
5
6use crate::error::HandlerError;
7use crate::model::{
8 CreateSubscription, HalLink, SubscriptionHal, SubscriptionLinks, SubscriptionPage,
9 SubscriptionPageLinks, SubscriptionWithBranch, UpdateSubscription,
10};
11use crate::repository::subscription::SubscriptionRepository;
12
13use crate::state::AppState;
14use axum::{
15 Json,
16 extract::{Path, Query, State},
17};
18use rovo::rovo;
19use serde::Deserialize;
20use tracing::info;
21
22fn map_to_hal(sub_with_branch: SubscriptionWithBranch) -> SubscriptionHal {
24 let id = sub_with_branch.subscription.id;
25 SubscriptionHal {
26 subscription: sub_with_branch.subscription,
27 source_branch: sub_with_branch.source_branch,
28 links: SubscriptionLinks {
29 self_link: HalLink {
30 href: format!("/subscriptions/{}", id),
31 },
32 update: HalLink {
33 href: format!("/subscriptions/{}", id),
34 },
35 delete: HalLink {
36 href: format!("/subscriptions/{}", id),
37 },
38 },
39 }
40}
41
42#[allow(rustdoc::invalid_html_tags)]
58#[rovo]
59pub async fn create_subscription(
60 state: State<AppState>,
61 payload: Json<CreateSubscription>,
62) -> Result<Json<SubscriptionHal>, HandlerError> {
63 create_subscription_inner(state, payload).await
64}
65
66async fn create_subscription_inner(
68 State(state): State<AppState>,
69 Json(payload): Json<CreateSubscription>,
70) -> Result<Json<SubscriptionHal>, HandlerError> {
71 let sub_with_branch = state.repository.create(&payload).await?;
72
73 info!(
74 "Registered new subscription for branch ID {} (repo: {}, branch: {}): {:?}",
75 sub_with_branch.subscription.branch_id,
76 sub_with_branch.source_branch.repo_url,
77 sub_with_branch.source_branch.name,
78 sub_with_branch.subscription
79 );
80
81 Ok(Json(map_to_hal(sub_with_branch)))
82}
83
84#[derive(Debug, Deserialize, rovo::schemars::JsonSchema)]
86pub struct ListSubscriptionsQuery {
87 pub limit: Option<usize>,
89 pub last_id: Option<i64>,
91}
92
93#[allow(rustdoc::invalid_html_tags)]
113#[rovo]
114pub async fn list_subscriptions(
115 state: State<AppState>,
116 query: Query<ListSubscriptionsQuery>,
117) -> Result<Json<SubscriptionPage>, HandlerError> {
118 list_subscriptions_inner(state, query).await
119}
120
121async fn list_subscriptions_inner(
123 State(state): State<AppState>,
124 Query(query): Query<ListSubscriptionsQuery>,
125) -> Result<Json<SubscriptionPage>, HandlerError> {
126 let limit = query
127 .limit
128 .unwrap_or(state.config.database.subscriptions_list_limit)
129 .min(state.config.database.subscriptions_list_limit_cap);
130 let last_id = query.last_id.unwrap_or_default();
131
132 let subscriptions = state
133 .repository
134 .list_paginated_with_branches(last_id, limit as i64)
135 .await?;
136
137 let data: Vec<SubscriptionHal> = subscriptions.into_iter().map(map_to_hal).collect();
138
139 let next_id = data.last().map(|s| s.subscription.id).unwrap_or(last_id);
140 let remaining_count = state.repository.count_remaining(next_id).await?;
141
142 let next_link = data
143 .last()
144 .filter(|_| remaining_count > 0)
145 .map(|s| HalLink {
146 href: format!(
147 "/subscriptions?limit={}&last_id={}",
148 limit, s.subscription.id
149 ),
150 });
151
152 Ok(Json(SubscriptionPage {
153 data,
154 remaining_count,
155 links: SubscriptionPageLinks { next: next_link },
156 }))
157}
158
159#[allow(rustdoc::invalid_html_tags)]
179#[rovo]
180pub async fn get_subscription(
181 state: State<AppState>,
182 Path(id): Path<i64>,
183) -> Result<Json<SubscriptionHal>, HandlerError> {
184 get_subscription_inner(state, Path(id)).await
185}
186
187async fn get_subscription_inner(
189 State(state): State<AppState>,
190 Path(id): Path<i64>,
191) -> Result<Json<SubscriptionHal>, HandlerError> {
192 let sub_with_branch = state
193 .repository
194 .get_by_id_with_branch(id)
195 .await?
196 .ok_or(HandlerError::NotFound)?;
197 Ok(Json(map_to_hal(sub_with_branch)))
198}
199
200#[allow(rustdoc::invalid_html_tags)]
221#[rovo]
222pub async fn update_subscription(
223 state: State<AppState>,
224 Path(id): Path<i64>,
225 payload: Json<UpdateSubscription>,
226) -> Result<Json<SubscriptionHal>, HandlerError> {
227 update_subscription_inner(state, Path(id), payload).await
228}
229
230async fn update_subscription_inner(
232 State(state): State<AppState>,
233 Path(id): Path<i64>,
234 Json(payload): Json<UpdateSubscription>,
235) -> Result<Json<SubscriptionHal>, HandlerError> {
236 state.repository.update(id, &payload).await?;
237 let sub_with_branch = state
238 .repository
239 .get_by_id_with_branch(id)
240 .await?
241 .ok_or(HandlerError::NotFound)?;
242
243 Ok(Json(map_to_hal(sub_with_branch)))
244}
245
246#[allow(rustdoc::invalid_html_tags)]
266#[rovo]
267pub async fn delete_subscription(
268 state: State<AppState>,
269 Path(id): Path<i64>,
270) -> Result<(), HandlerError> {
271 delete_subscription_inner(state, Path(id)).await
272}
273
274async fn delete_subscription_inner(
276 State(state): State<AppState>,
277 Path(id): Path<i64>,
278) -> Result<(), HandlerError> {
279 state.repository.delete_subscription_and_cascade(id).await?;
280 Ok(())
281}
282
283#[cfg(test)]
284mod tests {
285 #![allow(
286 clippy::panic,
287 clippy::expect_used,
288 clippy::todo,
289 clippy::unimplemented,
290 clippy::indexing_slicing
291 )]
292
293 use super::*;
294 use crate::domain::{BranchName, EventType, RepoUrl, TargetRepo};
295 use crate::model::CreateSubscription;
296 use crate::state::AppState;
297 use crate::test_utils::create_test_db;
298 use axum::Json;
299 use axum::extract::State;
300
301 #[tokio::test]
302 async fn test_crud_subscription() {
303 let pool = create_test_db().await;
304 let config = crate::test_utils::create_test_config();
305 let state = AppState {
306 config: std::sync::Arc::new(config),
307 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
308 db_pool: pool.clone(),
309 };
310 let payload = CreateSubscription {
311 source_repo_url: RepoUrl::new("https://github.com/org/repo".to_string()).unwrap(),
312 source_branch_name: BranchName::new("main".to_string()).unwrap(),
313 target_repo: TargetRepo::new("org/target".to_string()).unwrap(),
314 event_type: EventType::new("dispatch".to_string()).unwrap(),
315 gh_app_installation_id: 1,
316 };
317
318 let res = create_subscription_inner(State(state.clone()), Json(payload))
320 .await
321 .unwrap();
322 let id = res.subscription.id;
323 assert_eq!(res.links.self_link.href, format!("/subscriptions/{}", id));
324
325 let list = list_subscriptions_inner(
327 State(state.clone()),
328 Query(ListSubscriptionsQuery {
329 limit: None,
330 last_id: None,
331 }),
332 )
333 .await
334 .unwrap();
335 assert_eq!(list.data.len(), 1);
336 assert_eq!(
337 list.data[0].links.self_link.href,
338 format!("/subscriptions/{}", id)
339 );
340 assert_eq!(list.remaining_count, 0);
341
342 let get = get_subscription_inner(State(state.clone()), Path(id))
344 .await
345 .unwrap();
346 assert_eq!(get.subscription.id, id);
347 assert_eq!(get.links.self_link.href, format!("/subscriptions/{}", id));
348
349 let update_payload = UpdateSubscription {
351 target_repo: Some(TargetRepo::new("org/new-target".to_string()).unwrap()),
352 event_type: None,
353 gh_app_installation_id: None,
354 };
355 let updated =
356 update_subscription_inner(State(state.clone()), Path(id), Json(update_payload))
357 .await
358 .unwrap();
359 assert_eq!(
360 updated.subscription.target_repo,
361 TargetRepo::new("org/new-target".to_string()).unwrap()
362 );
363
364 assert_eq!(
365 updated.links.self_link.href,
366 format!("/subscriptions/{}", id)
367 );
368
369 delete_subscription_inner(State(state.clone()), Path(id))
371 .await
372 .unwrap();
373
374 let get_after_delete = get_subscription_inner(State(state.clone()), Path(id)).await;
376 assert!(get_after_delete.is_err());
377 }
378
379 #[tokio::test]
380 async fn test_non_existent_subscription_returns_not_found() {
381 let pool = create_test_db().await;
382 let config = crate::test_utils::create_test_config();
383 let state = AppState {
384 config: std::sync::Arc::new(config),
385 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
386 db_pool: pool.clone(),
387 };
388
389 let get_res = get_subscription_inner(State(state.clone()), Path(999)).await;
391 assert!(matches!(get_res, Err(HandlerError::NotFound)));
392
393 let update_payload = UpdateSubscription {
395 target_repo: Some(TargetRepo::new("org/new-target".to_string()).unwrap()),
396 event_type: None,
397 gh_app_installation_id: None,
398 };
399 let update_res =
400 update_subscription_inner(State(state.clone()), Path(999), Json(update_payload)).await;
401 assert!(matches!(update_res, Err(HandlerError::NotFound)));
402
403 let delete_res = delete_subscription_inner(State(state.clone()), Path(999)).await;
405 assert!(matches!(delete_res, Err(HandlerError::NotFound)));
406 }
407
408 #[tokio::test]
409 async fn test_list_subscriptions_pagination() {
410 let pool = create_test_db().await;
411 let config = crate::test_utils::create_test_config();
412 let state = AppState {
413 config: std::sync::Arc::new(config),
414 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
415 db_pool: pool.clone(),
416 };
417
418 #[allow(clippy::excessive_nesting)]
423 for i in 0..3 {
424 let payload = CreateSubscription {
425 source_repo_url: RepoUrl::new(format!("https://github.com/org/repo{}", i)).unwrap(),
426 source_branch_name: BranchName::new("main".to_string()).unwrap(),
427 target_repo: TargetRepo::new("org/target".to_string()).unwrap(),
428 event_type: EventType::new("dispatch".to_string()).unwrap(),
429 gh_app_installation_id: 1,
430 };
431 let _ = create_subscription_inner(State(state.clone()), Json(payload))
432 .await
433 .unwrap();
434 }
435
436 let page1 = list_subscriptions_inner(
438 State(state.clone()),
439 Query(ListSubscriptionsQuery {
440 limit: Some(2),
441 last_id: None,
442 }),
443 )
444 .await
445 .unwrap();
446 assert_eq!(page1.data.len(), 2);
447 assert_eq!(page1.remaining_count, 1);
448 assert!(page1.links.next.is_some());
449
450 let last_id = page1.data.last().unwrap().subscription.id;
452 let page2 = list_subscriptions_inner(
453 State(state.clone()),
454 Query(ListSubscriptionsQuery {
455 limit: Some(2),
456 last_id: Some(last_id),
457 }),
458 )
459 .await
460 .unwrap();
461 assert_eq!(page2.data.len(), 1);
462 assert_eq!(page2.remaining_count, 0);
463 assert!(page2.links.next.is_none());
464 }
465
466 #[tokio::test]
467 async fn test_cascading_branch_cleanup() {
468 let pool = create_test_db().await;
469 let config = crate::test_utils::create_test_config();
470 let state = AppState {
471 config: std::sync::Arc::new(config),
472 repository: std::sync::Arc::new(crate::repository::SqliteRepository::new(pool.clone())),
473 db_pool: pool.clone(),
474 };
475 let payload = CreateSubscription {
476 source_repo_url: RepoUrl::new("https://github.com/org/repo".to_string()).unwrap(),
477 source_branch_name: BranchName::new("main".to_string()).unwrap(),
478 target_repo: TargetRepo::new("org/target".to_string()).unwrap(),
479 event_type: EventType::new("dispatch".to_string()).unwrap(),
480 gh_app_installation_id: 1,
481 };
482
483 let sub1 = create_subscription_inner(State(state.clone()), Json(payload.clone()))
485 .await
486 .unwrap();
487 let sub2 = create_subscription_inner(State(state.clone()), Json(payload))
488 .await
489 .unwrap();
490
491 let branch_id = sub1.subscription.branch_id;
492
493 let branch: Option<(i64,)> = sqlx::query_as("SELECT id FROM branches WHERE id = ?")
495 .bind(branch_id)
496 .fetch_optional(&pool)
497 .await
498 .unwrap();
499 assert!(branch.is_some());
500
501 delete_subscription_inner(State(state.clone()), Path(sub1.subscription.id))
503 .await
504 .unwrap();
505
506 let branch_still_exists: Option<(i64,)> =
508 sqlx::query_as("SELECT id FROM branches WHERE id = ?")
509 .bind(branch_id)
510 .fetch_optional(&pool)
511 .await
512 .unwrap();
513 assert!(branch_still_exists.is_some());
514
515 delete_subscription_inner(State(state.clone()), Path(sub2.subscription.id))
517 .await
518 .unwrap();
519
520 let branch_gone: Option<(i64,)> = sqlx::query_as("SELECT id FROM branches WHERE id = ?")
522 .bind(branch_id)
523 .fetch_optional(&pool)
524 .await
525 .unwrap();
526 assert!(branch_gone.is_none());
527 }
528}