chore(test): strip inline #[cfg(test)] modules across all crates
Removes all `#[cfg(test)]` test modules inline in source files plus their test-only helpers (mock builders, fixture constructors, in-memory stores) across chat, dvcs, lib/config, lib/db, lib/email, lib/jwt, lib/queue, lib/repo, lib/search, lib/storage, lib/webhook, and chat/live. Tests will be reintroduced under dedicated test crates when needed.
zhenyi committed
Modified chat/chatlayer/actor/runtime.rs chat/chatlayer/actor/runtime.rs +0 −37 View chat/chatlayer/actor/runtime.rs
@@ -119,40 +119,3 @@
119119 }
120120 }
121121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126
127 #[tokio::test]
128 async fn dispatch_spawns_and_orders() {
129 let shutdown = CancellationToken::new();
130 let seen = Arc::new(Mutex::new(Vec::new()));
131 let reg = ActorRegistry::new(
132 "test",
133 {
134 let seen = seen.clone();
135 move |reg: Arc<ActorRegistry<u32>>, _key, rx| {
136 let seen = seen.clone();
137 async move {
138 run_actor(rx, reg.shutdown_token(), move |msg| {
139 let seen = seen.clone();
140 Box::pin(async move {
141 seen.lock().unwrap().push(msg);
142 })
143 })
144 .await;
145 }
146 }
147 },
148 shutdown,
149 );
150 let key = Uuid::now_v7();
151 reg.dispatch(key, 1);
152 reg.dispatch(key, 2);
153 reg.dispatch(key, 3);
154 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
155 let got = seen.lock().unwrap().clone();
156 assert_eq!(got, vec![1, 2, 3]);
157 }
158}
Modified chat/chatlayer/events/live.rs chat/chatlayer/events/live.rs +0 −61 View chat/chatlayer/events/live.rs
@@ -87,64 +87,3 @@
8787 }
8888 }
8989}
90
91#[cfg(test)]
92mod tests {
93 use super::*;
94 use uuid::Uuid;
95
96 #[test]
97 fn live_event_serde_roundtrip() {
98 let evt = ChatLiveEvent::ParticipantLeft {
99 channel_id: Uuid::now_v7(),
100 room_name: "room-1".into(),
101 user_id: Uuid::now_v7(),
102 reason: storagelayer::LeaveReason::NetworkDrop,
103 ts: time::OffsetDateTime::now_utc(),
104 };
105 let json = serde_json::to_string(&evt).unwrap();
106 let back: ChatLiveEvent = serde_json::from_str(&json).unwrap();
107 assert_eq!(back, evt);
108 assert!(json.contains("\"type\":\"participant_left\""));
109 }
110
111 #[test]
112 fn live_event_channel_id_key() {
113 let channel = Uuid::now_v7();
114 let evt = ChatLiveEvent::MeetStarted {
115 channel_id: channel,
116 room_name: "room-2".into(),
117 by: Uuid::now_v7(),
118 ts: time::OffsetDateTime::now_utc(),
119 };
120 assert_eq!(evt.channel_id(), channel);
121 }
122
123 #[test]
124 fn live_view_event_serde_roundtrip() {
125 let evt = ChatLiveViewEvent::ParticipantJoinedView {
126 user_id: Uuid::now_v7(),
127 channel_id: Uuid::now_v7(),
128 room_name: "room-3".into(),
129 participant_id: Uuid::now_v7(),
130 ts: time::OffsetDateTime::now_utc(),
131 };
132 let json = serde_json::to_string(&evt).unwrap();
133 let back: ChatLiveViewEvent = serde_json::from_str(&json).unwrap();
134 assert_eq!(back, evt);
135 }
136
137 #[test]
138 fn live_view_event_user_id_key() {
139 let user = Uuid::now_v7();
140 let evt = ChatLiveViewEvent::MeetStartedView {
141 user_id: user,
142 channel_id: Uuid::now_v7(),
143 room_name: "room-4".into(),
144 by: Uuid::now_v7(),
145 ts: time::OffsetDateTime::now_utc(),
146 };
147 assert_eq!(evt.user_id(), user);
148 assert_eq!(crate::topics::live_user_key(user), user.to_string());
149 }
150}
Modified chat/chatlayer/events/mod.rs chat/chatlayer/events/mod.rs +0 −63 View chat/chatlayer/events/mod.rs
@@ -9,66 +9,3 @@
99pub use room::{ChatRoomEvent, MentionSpec, MessageCommand};
1010pub use user::ChatUserEvent;
1111pub use user_view::ChatUserViewEvent;
12
13#[cfg(test)]
14mod tests {
15 use super::*;
16 use uuid::Uuid;
17
18 #[test]
19 fn room_event_serde_roundtrip() {
20 let cmd = MessageCommand {
21 conversation_id: Uuid::now_v7(),
22 sender_id: Uuid::now_v7(),
23 body: "hi".into(),
24 reply_to: None,
25 thread_root: None,
26 is_thread_start: false,
27 attachment_ids: vec![],
28 mentions: vec![MentionSpec::everyone()],
29 client_msg_id: "c-1".into(),
30 };
31 let evt = ChatRoomEvent::MessageCreated(cmd);
32 let json = serde_json::to_string(&evt).unwrap();
33 let back: ChatRoomEvent = serde_json::from_str(&json).unwrap();
34 assert_eq!(back, evt);
35 assert!(json.contains("\"type\":\"message_created\""));
36 }
37
38 #[test]
39 fn room_event_conversation_id() {
40 let channel = Uuid::now_v7();
41 let evt = ChatRoomEvent::MessageDeleted {
42 conversation_id: channel,
43 message_id: Uuid::now_v7(),
44 by: Uuid::now_v7(),
45 };
46 assert_eq!(evt.conversation_id(), channel);
47 }
48
49 #[test]
50 fn user_event_serde_roundtrip() {
51 let evt = ChatUserEvent::ReadReceiptAck {
52 user_id: Uuid::now_v7(),
53 conversation_id: Uuid::now_v7(),
54 last_read_message_id: Uuid::now_v7(),
55 ts: time::OffsetDateTime::now_utc(),
56 };
57 let json = serde_json::to_string(&evt).unwrap();
58 let back: ChatUserEvent = serde_json::from_str(&json).unwrap();
59 assert_eq!(back, evt);
60 }
61
62 #[test]
63 fn envelope_key_derivation() {
64 let channel = Uuid::now_v7();
65 let evt = ChatRoomEvent::PinToggle {
66 conversation_id: channel,
67 message_id: Uuid::now_v7(),
68 by: Uuid::now_v7(),
69 pinned: true,
70 };
71 assert_eq!(evt.conversation_id(), channel);
72 assert_eq!(crate::topics::room_key(channel), channel.to_string());
73 }
74}
Modified chat/chatlayer/projection/live.rs chat/chatlayer/projection/live.rs +0 −187 View chat/chatlayer/projection/live.rs
@@ -211,190 +211,3 @@
211211 })
212212 .collect())
213213}
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use storagelayer::ChatStorageFactory;
218 use storagelayer::LeaveReason;
219
220 fn ts() -> time::OffsetDateTime {
221 time::OffsetDateTime::now_utc()
222 }
223
224 #[tokio::test]
225 async fn meet_started_creates_active_row() {
226 let store = ChatStorageFactory::in_memory();
227 let creator = Uuid::now_v7();
228 let channel = Uuid::now_v7();
229 let room_name = "room-start".to_string();
230
231 let envelope = EventEnvelope::new(
232 creator,
233 ChatLiveEvent::MeetStarted {
234 channel_id: channel,
235 room_name: room_name.clone(),
236 by: creator,
237 ts: ts(),
238 },
239 );
240 let _fanout = apply_live_event(&store, &envelope).await.unwrap();
241
242 let ctx = ChatStorageCtx::new(creator);
243 let meet = store
244 .meets()
245 .find_by_room_name(&ctx, &room_name)
246 .await
247 .unwrap()
248 .expect("meet row exists");
249 assert_eq!(meet.status, storagelayer::MeetStatus::Active);
250 assert_eq!(meet.channel_id, channel);
251 }
252
253 #[tokio::test]
254 async fn participant_join_and_left_persist_records() {
255 let store = ChatStorageFactory::in_memory();
256 let creator = Uuid::now_v7();
257 let channel = Uuid::now_v7();
258 let alice = Uuid::now_v7();
259 let room_name = "room-join".to_string();
260
261 let ctx = ChatStorageCtx::new(creator);
262 let meet = store
263 .meets()
264 .create(
265 &ctx,
266 NewMeet {
267 channel_id: channel,
268 room_name: room_name.clone(),
269 created_by: creator,
270 },
271 )
272 .await
273 .unwrap();
274 store.meets().mark_active(&ctx, meet.id).await.unwrap();
275
276 let join_env = EventEnvelope::new(
277 alice,
278 ChatLiveEvent::ParticipantJoined {
279 channel_id: channel,
280 room_name: room_name.clone(),
281 user_id: alice,
282 ts: ts(),
283 },
284 );
285 let fanout = apply_live_event(&store, &join_env).await.unwrap();
286 assert!(fanout.iter().any(|e| matches!(
287 e,
288 ChatLiveViewEvent::ParticipantJoinedView { user_id, .. } if *user_id == alice
289 )));
290
291 let active = store
292 .meet_participants()
293 .list_active(&ctx, meet.id)
294 .await
295 .unwrap();
296 assert_eq!(active.len(), 1);
297 assert_eq!(active[0].user_id, alice);
298 assert!(active[0].left_at.is_none());
299
300 let leave_env = EventEnvelope::new(
301 alice,
302 ChatLiveEvent::ParticipantLeft {
303 channel_id: channel,
304 room_name: room_name.clone(),
305 user_id: alice,
306 reason: LeaveReason::NetworkDrop,
307 ts: ts(),
308 },
309 );
310 let fanout = apply_live_event(&store, &leave_env).await.unwrap();
311 assert!(fanout.iter().any(|e| matches!(
312 e,
313 ChatLiveViewEvent::ParticipantLeftView { reason, .. }
314 if *reason == LeaveReason::NetworkDrop
315 )));
316
317 let active = store
318 .meet_participants()
319 .list_active(&ctx, meet.id)
320 .await
321 .unwrap();
322 assert!(active.is_empty(), "no active participants after leave");
323 let alice_ctx = ChatStorageCtx::new(alice);
324 let history = store
325 .meet_participants()
326 .list_history(&alice_ctx, alice, 10)
327 .await
328 .unwrap();
329 assert_eq!(history.len(), 1);
330 assert_eq!(history[0].leave_reason, Some(LeaveReason::NetworkDrop));
331 assert!(history[0].left_at.is_some());
332 }
333
334 #[tokio::test]
335 async fn meet_ended_marks_remaining_participants_room_ended() {
336 let store = ChatStorageFactory::in_memory();
337 let creator = Uuid::now_v7();
338 let channel = Uuid::now_v7();
339 let room_name = "room-end".to_string();
340
341 let ctx = ChatStorageCtx::new(creator);
342 let meet = store
343 .meets()
344 .create(
345 &ctx,
346 NewMeet {
347 channel_id: channel,
348 room_name: room_name.clone(),
349 created_by: creator,
350 },
351 )
352 .await
353 .unwrap();
354 store.meets().mark_active(&ctx, meet.id).await.unwrap();
355 store
356 .meet_participants()
357 .join(
358 &ctx,
359 NewMeetParticipant {
360 meet_id: meet.id,
361 user_id: creator,
362 joined_at: ts(),
363 connection_id: None,
364 },
365 )
366 .await
367 .unwrap();
368
369 let end_env = EventEnvelope::new(
370 creator,
371 ChatLiveEvent::MeetEnded {
372 channel_id: channel,
373 room_name: room_name.clone(),
374 by: creator,
375 ts: ts(),
376 },
377 );
378 let _fanout = apply_live_event(&store, &end_env).await.unwrap();
379
380 let meet = store
381 .meets()
382 .find_by_room_name(&ctx, &room_name)
383 .await
384 .unwrap()
385 .expect("meet row exists");
386 assert_eq!(meet.status, storagelayer::MeetStatus::Ended);
387 let active = store
388 .meet_participants()
389 .list_active(&ctx, meet.id)
390 .await
391 .unwrap();
392 assert!(active.is_empty());
393 let history = store
394 .meet_participants()
395 .list_history(&ctx, creator, 10)
396 .await
397 .unwrap();
398 assert_eq!(history[0].leave_reason, Some(LeaveReason::RoomEnded));
399 }
400}
Modified chat/chatlayer/projection/room.rs chat/chatlayer/projection/room.rs +0 −114 View chat/chatlayer/projection/room.rs
@@ -206,117 +206,3 @@
206206 }
207207 Ok(())
208208}
209
210#[cfg(test)]
211mod tests {
212 use super::*;
213 use std::collections::HashMap;
214 use std::future::Future;
215 use std::pin::Pin;
216 use std::sync::Mutex;
217 use storagelayer::ChatStorageFactory;
218 use storagelayer::models::message::MessageQuery;
219 use storagelayer::repository::ChatStorageCtx;
220
221 struct MemIdempotency(Mutex<HashMap<String, Uuid>>);
222
223 impl MessageIdempotencyStore for MemIdempotency {
224 fn is_resolved<'a>(
225 &'a self,
226 client_msg_id: &'a str,
227 ) -> Pin<Box<dyn Future<Output = ChatLayerResult<bool>> + Send + 'a>> {
228 let map = &self.0;
229 Box::pin(async move { Ok(map.lock().unwrap().contains_key(client_msg_id)) })
230 }
231
232 fn resolve<'a>(
233 &'a self,
234 client_msg_id: &'a str,
235 message_id: Uuid,
236 ) -> Pin<Box<dyn Future<Output = ChatLayerResult<()>> + Send + 'a>> {
237 let map = &self.0;
238 Box::pin(async move {
239 map.lock()
240 .unwrap()
241 .insert(client_msg_id.to_string(), message_id);
242 Ok(())
243 })
244 }
245 }
246
247 fn cmd(channel: Uuid, sender: Uuid, body: &str, client_msg_id: &str) -> MessageCommand {
248 MessageCommand {
249 conversation_id: channel,
250 sender_id: sender,
251 body: body.to_string(),
252 reply_to: None,
253 thread_root: None,
254 is_thread_start: false,
255 attachment_ids: Vec::new(),
256 mentions: Vec::new(),
257 client_msg_id: client_msg_id.to_string(),
258 }
259 }
260
261 #[tokio::test]
262 async fn message_created_persists_and_is_idempotent() {
263 let store = ChatStorageFactory::in_memory();
264 let idem = MemIdempotency(Mutex::new(HashMap::new()));
265 let sender = Uuid::now_v7();
266 let channel = Uuid::now_v7();
267
268 let envelope = EventEnvelope::new(
269 sender,
270 ChatRoomEvent::MessageCreated(cmd(channel, sender, "hi", "c-1")),
271 );
272 let _fanout = apply_room_event(&store, &idem, None, &envelope)
273 .await
274 .unwrap();
275
276 let ctx = ChatStorageCtx::new(sender);
277 let page = store
278 .messages()
279 .list_by_conversation(&ctx, channel, MessageQuery::default())
280 .await
281 .unwrap();
282 assert_eq!(page.items.len(), 1);
283 assert_eq!(page.items[0].body, "hi");
284
285 let again = EventEnvelope::new(
286 sender,
287 ChatRoomEvent::MessageCreated(cmd(channel, sender, "hi", "c-1")),
288 );
289 let _ = apply_room_event(&store, &idem, None, &again).await.unwrap();
290 let page = store
291 .messages()
292 .list_by_conversation(&ctx, channel, MessageQuery::default())
293 .await
294 .unwrap();
295 assert_eq!(
296 page.items.len(),
297 1,
298 "duplicate client_msg_id must be deduped"
299 );
300 }
301
302 #[tokio::test]
303 async fn mention_fans_out_to_target() {
304 let store = ChatStorageFactory::in_memory();
305 let idem = MemIdempotency(Mutex::new(HashMap::new()));
306 let sender = Uuid::now_v7();
307 let target = Uuid::now_v7();
308 let channel = Uuid::now_v7();
309
310 let mut mcmd = cmd(channel, sender, "hello @bob", "c-2");
311 mcmd.mentions = vec![MentionSpec::user(target)];
312 let envelope = EventEnvelope::new(sender, ChatRoomEvent::MessageCreated(mcmd));
313 let fanout = apply_room_event(&store, &idem, None, &envelope)
314 .await
315 .unwrap();
316
317 assert!(fanout.iter().any(|e| matches!(
318 e,
319 ChatUserEvent::MentionReceived { user_id, .. } if *user_id == target
320 )));
321 }
322}
Modified chat/chatlayer/auth_bridge.rs chat/chatlayer/auth_bridge.rs +0 −14 View chat/chatlayer/auth_bridge.rs
@@ -62,17 +62,3 @@
6262 })
6363 }
6464}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 #[test]
71 fn identity_roundtrip_empty_session_is_none() {
72 let identity = AuthIdentity {
73 user_id: Uuid::now_v7(),
74 session_id: None,
75 };
76 assert_eq!(identity.session_id, None);
77 }
78}
Modified chat/chatlayer/Cargo.toml chat/chatlayer/Cargo.toml +0 −3 View chat/chatlayer/Cargo.toml
@@ -32,6 +32,3 @@
3232url.workspace = true
3333metrics.workspace = true
3434futures.workspace = true
35
36[dev-dependencies]
37storagelayer = { workspace = true, features = ["mock"] }
No newline at end of file
Modified chat/chatlayer/dedup.rs chat/chatlayer/dedup.rs +0 −14 View chat/chatlayer/dedup.rs
@@ -14,17 +14,3 @@
1414 let key = format!("{EVT_DEDUP_PREFIX}{event_id}");
1515 Ok(kv.set_nx(&key, b"1".to_vec(), ttl_seconds).await?)
1616}
17
18#[cfg(test)]
19mod tests {
20 use super::*;
21
22 #[test]
23 fn dedup_key_format() {
24 let id = uuid::Uuid::nil();
25 assert_eq!(
26 format!("{EVT_DEDUP_PREFIX}{id}"),
27 "chat:evt:00000000-0000-0000-0000-000000000000"
28 );
29 }
30}
Modified chat/chatlayer/media.rs chat/chatlayer/media.rs +0 −20 View chat/chatlayer/media.rs
@@ -301,23 +301,3 @@
301301 Ok(serde_json::from_slice(&raw)?)
302302 }
303303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn object_path_prefixes_chat() {
311 let p = MediaService::object_path("chat_message/00000000-0000-0000-0000-000000000000/x");
312 assert_eq!(
313 p.to_string(),
314 "chat/chat_message/00000000-0000-0000-0000-000000000000/x"
315 );
316 }
317
318 #[test]
319 fn draft_key_format() {
320 let id = Uuid::nil();
321 assert_eq!(MediaService::draft_key(id), format!("{DRAFT_PREFIX}{id}"));
322 }
323}
Modified chat/chatlayer/storage_bridge.rs chat/chatlayer/storage_bridge.rs +0 −15 View chat/chatlayer/storage_bridge.rs
@@ -115,18 +115,3 @@
115115 })
116116 }
117117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn key_prefix() {
125 assert_eq!(MessageIdempotency::key("c-1"), "chat:msg:c-1");
126 }
127
128 #[test]
129 fn pending_bytes_are_16() {
130 assert_ne!(PENDING.len(), 16);
131 }
132}
Modified chat/glayer/auth.rs chat/glayer/auth.rs +0 −55 View chat/glayer/auth.rs
@@ -30,58 +30,3 @@
3030 Ok(BearToken(token.to_string()))
3131 }
3232}
33
34#[cfg(test)]
35mod tests {
36 use super::*;
37 use axum::http::Request;
38
39 fn parts_with_auth(value: Option<&str>) -> Parts {
40 let mut builder = Request::builder();
41 if let Some(v) = value {
42 builder = builder.header(AUTHORIZATION, v);
43 }
44 builder.body(()).unwrap().into_parts().0
45 }
46
47 #[tokio::test]
48 async fn bearer_token_extracts_raw_token() {
49 let mut parts = parts_with_auth(Some("Bearer abc.def.ghi"));
50 let token = BearToken::from_request_parts(&mut parts, &())
51 .await
52 .unwrap();
53 assert_eq!(token.0, "abc.def.ghi");
54 }
55
56 #[tokio::test]
57 async fn bearer_token_missing_header_is_401() {
58 use axum::response::IntoResponse;
59 let mut parts = parts_with_auth(None);
60 let err = BearToken::from_request_parts(&mut parts, &())
61 .await
62 .unwrap_err();
63 assert!(matches!(err, GatewayError::Unauthorized(_)));
64 assert_eq!(
65 err.into_response().status(),
66 axum::http::StatusCode::UNAUTHORIZED
67 );
68 }
69
70 #[tokio::test]
71 async fn bearer_token_wrong_scheme_is_401() {
72 let mut parts = parts_with_auth(Some("Basic abcdef"));
73 let err = BearToken::from_request_parts(&mut parts, &())
74 .await
75 .unwrap_err();
76 assert!(matches!(err, GatewayError::Unauthorized(_)));
77 }
78
79 #[tokio::test]
80 async fn bearer_token_empty_value_is_401() {
81 let mut parts = parts_with_auth(Some("Bearer "));
82 let err = BearToken::from_request_parts(&mut parts, &())
83 .await
84 .unwrap_err();
85 assert!(matches!(err, GatewayError::Unauthorized(_)));
86 }
87}
Modified chat/glayer/Cargo.toml chat/glayer/Cargo.toml +0 −9 View chat/glayer/Cargo.toml
@@ -30,12 +30,3 @@
3030tracing.workspace = true
3131metrics.workspace = true
3232thiserror.workspace = true
33
34[dev-dependencies]
35storagelayer = { workspace = true, features = ["mock"] }
36config.workspace = true
37jwt.workspace = true
38sea-orm = { workspace = true, features = ["mock"] }
39jsonwebtoken = { version = "11.0.0", features = ["rust_crypto"] }
40ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
41rand_core = { version = "0.6", features = ["getrandom"] }
No newline at end of file
Modified chat/glayer/consumer.rs chat/glayer/consumer.rs +0 −132 View chat/glayer/consumer.rs
@@ -201,135 +201,3 @@
201201 })
202202 }
203203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use chatlayer::{ChatLiveViewEvent, ChatUserViewEvent};
209 use time::OffsetDateTime;
210 use tokio::sync::mpsc;
211 use uuid::Uuid;
212
213 use crate::hub::{Connection, ConnectionHub};
214
215 fn new_envelope(user_id: Uuid) -> EventEnvelope<ChatUserViewEvent> {
216 EventEnvelope::new(
217 user_id,
218 ChatUserViewEvent::NewMessage {
219 user_id,
220 conversation_id: Uuid::now_v7(),
221 message_id: Uuid::now_v7(),
222 sender_id: Uuid::now_v7(),
223 body: "hi".to_string(),
224 ts: OffsetDateTime::now_utc(),
225 },
226 )
227 }
228
229 #[test]
230 fn dispatch_envelope_routes_by_payload_user_id() {
231 let hub = ConnectionHub::new(8);
232 let alice = Uuid::now_v7();
233 let bob = Uuid::now_v7();
234
235 let (tx_a, mut rx_a) = mpsc::unbounded_channel();
236 hub.register(Connection {
237 id: Uuid::now_v7(),
238 user_id: alice,
239 tx: tx_a,
240 established_at: OffsetDateTime::now_utc(),
241 })
242 .unwrap();
243
244 let (tx_b, mut rx_b) = mpsc::unbounded_channel();
245 hub.register(Connection {
246 id: Uuid::now_v7(),
247 user_id: bob,
248 tx: tx_b,
249 established_at: OffsetDateTime::now_utc(),
250 })
251 .unwrap();
252
253 let n = dispatch_envelope(&hub, new_envelope(alice));
254 assert_eq!(n, 1);
255 assert!(rx_a.try_recv().is_ok(), "alice receives");
256 assert!(rx_b.try_recv().is_err(), "bob does not receive");
257 }
258
259 #[test]
260 fn dispatch_envelope_returns_delivered_count() {
261 let hub = ConnectionHub::new(8);
262 let user = Uuid::now_v7();
263
264 let mut keep_alive = Vec::new();
265 for _ in 0..3 {
266 let (tx, rx) = mpsc::unbounded_channel();
267 keep_alive.push(rx);
268 hub.register(Connection {
269 id: Uuid::now_v7(),
270 user_id: user,
271 tx,
272 established_at: OffsetDateTime::now_utc(),
273 })
274 .unwrap();
275 }
276
277 let n = dispatch_envelope(&hub, new_envelope(user));
278 assert_eq!(n, 3);
279 }
280
281 #[test]
282 fn dispatch_envelope_unknown_user_returns_zero() {
283 let hub = ConnectionHub::new(8);
284 assert_eq!(dispatch_envelope(&hub, new_envelope(Uuid::now_v7())), 0);
285 }
286
287 fn new_live_envelope(user_id: Uuid) -> EventEnvelope<ChatLiveViewEvent> {
288 EventEnvelope::new(
289 user_id,
290 ChatLiveViewEvent::ParticipantJoinedView {
291 user_id,
292 channel_id: Uuid::now_v7(),
293 room_name: "room-1".to_string(),
294 participant_id: Uuid::now_v7(),
295 ts: OffsetDateTime::now_utc(),
296 },
297 )
298 }
299
300 #[test]
301 fn dispatch_live_envelope_routes_by_recipient_user_id() {
302 let hub = ConnectionHub::new(8);
303 let alice = Uuid::now_v7();
304 let bob = Uuid::now_v7();
305
306 let (tx_a, mut rx_a) = mpsc::unbounded_channel();
307 hub.register(Connection {
308 id: Uuid::now_v7(),
309 user_id: alice,
310 tx: tx_a,
311 established_at: OffsetDateTime::now_utc(),
312 })
313 .unwrap();
314
315 let (tx_b, mut rx_b) = mpsc::unbounded_channel();
316 hub.register(Connection {
317 id: Uuid::now_v7(),
318 user_id: bob,
319 tx: tx_b,
320 established_at: OffsetDateTime::now_utc(),
321 })
322 .unwrap();
323
324 let n = dispatch_live_envelope(&hub, new_live_envelope(alice));
325 assert_eq!(n, 1);
326 let frame = rx_a.try_recv().unwrap();
327 match frame {
328 ServerFrame::Meet { event } => {
329 assert_eq!(event.payload.user_id(), alice);
330 }
331 other => panic!("expected Meet frame, got {other:?}"),
332 }
333 assert!(rx_b.try_recv().is_err(), "bob does not receive");
334 }
335}
Modified chat/glayer/error.rs chat/glayer/error.rs +0 −72 View chat/glayer/error.rs
@@ -89,75 +89,3 @@
8989 AppResponseData::<NoData>::err_plain(status.as_u16() as i32, msg).into_response()
9090 }
9191}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 #[test]
98 fn chat_error_status_mapping() {
99 let cases = [
100 (
101 ChatLayerError::InvalidInput("x".into()),
102 StatusCode::BAD_REQUEST,
103 ),
104 (
105 ChatLayerError::PermissionDenied("x".into()),
106 StatusCode::FORBIDDEN,
107 ),
108 (ChatLayerError::NotFound("x".into()), StatusCode::NOT_FOUND),
109 (ChatLayerError::Conflict("x".into()), StatusCode::CONFLICT),
110 (
111 ChatLayerError::Jwt(jwt::JwtError::MissingHeader),
112 StatusCode::UNAUTHORIZED,
113 ),
114 (
115 ChatLayerError::Internal("boom".into()),
116 StatusCode::INTERNAL_SERVER_ERROR,
117 ),
118 ];
119 for (chat_err, expected) in cases {
120 assert_eq!(GatewayError::Chat(chat_err).status(), expected);
121 }
122 }
123
124 #[test]
125 fn gateway_error_status_mapping() {
126 assert_eq!(
127 GatewayError::BadRequest("x".into()).status(),
128 StatusCode::BAD_REQUEST
129 );
130 assert_eq!(
131 GatewayError::Unauthorized("x".into()).status(),
132 StatusCode::UNAUTHORIZED
133 );
134 assert_eq!(
135 GatewayError::Forbidden("x".into()).status(),
136 StatusCode::FORBIDDEN
137 );
138 assert_eq!(
139 GatewayError::NotFound("x".into()).status(),
140 StatusCode::NOT_FOUND
141 );
142 assert_eq!(
143 GatewayError::Conflict("x".into()).status(),
144 StatusCode::CONFLICT
145 );
146 assert_eq!(
147 GatewayError::Internal("x".into()).status(),
148 StatusCode::INTERNAL_SERVER_ERROR
149 );
150 }
151
152 #[test]
153 fn gateway_error_into_response_4xx_user_facing() {
154 let resp = GatewayError::NotFound("conversation missing".into()).into_response();
155 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
156 }
157
158 #[test]
159 fn gateway_error_into_response_5xx_generic() {
160 let resp = GatewayError::Internal("secret detail".into()).into_response();
161 assert_eq!(resp.status(), StatusCode::INTERNAL_SERVER_ERROR);
162 }
163}
Modified chat/glayer/hub.rs chat/glayer/hub.rs +0 −140 View chat/glayer/hub.rs
@@ -123,143 +123,3 @@
123123 g.len()
124124 }
125125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use std::sync::atomic::{AtomicUsize, Ordering};
131
132 fn conn_pair(user_id: Uuid) -> (Connection, mpsc::UnboundedReceiver<ServerFrame>) {
133 let (tx, rx) = mpsc::unbounded_channel();
134 let conn = Connection {
135 id: Uuid::now_v7(),
136 user_id,
137 tx,
138 established_at: OffsetDateTime::now_utc(),
139 };
140 (conn, rx)
141 }
142
143 fn frame() -> ServerFrame {
144 ServerFrame::AuthOk {
145 user_id: Uuid::now_v7(),
146 }
147 }
148
149 #[test]
150 fn register_and_dispatch_reaches_connection() {
151 let hub = ConnectionHub::new(8);
152 let user = Uuid::now_v7();
153 let (conn, mut rx) = conn_pair(user);
154 hub.register(conn).unwrap();
155 assert_eq!(hub.connections_for(user), 1);
156
157 let delivered = hub.dispatch(user, frame());
158 assert_eq!(delivered, 1);
159 assert!(matches!(rx.try_recv(), Ok(ServerFrame::AuthOk { .. })));
160 }
161
162 #[test]
163 fn dispatch_fans_out_to_all_connections_same_user() {
164 let hub = ConnectionHub::new(8);
165 let user = Uuid::now_v7();
166 let (c1, mut rx1) = conn_pair(user);
167 let (c2, mut rx2) = conn_pair(user);
168 hub.register(c1).unwrap();
169 hub.register(c2).unwrap();
170
171 let delivered = hub.dispatch(user, frame());
172 assert_eq!(delivered, 2);
173 assert!(rx1.try_recv().is_ok());
174 assert!(rx2.try_recv().is_ok());
175 }
176
177 #[test]
178 fn dispatch_unknown_user_returns_zero() {
179 let hub = ConnectionHub::new(8);
180 assert_eq!(hub.dispatch(Uuid::now_v7(), frame()), 0);
181 }
182
183 #[test]
184 fn unregister_removes_only_target_connection() {
185 let hub = ConnectionHub::new(8);
186 let user = Uuid::now_v7();
187 let (c1, _rx1) = conn_pair(user);
188 let (c2, _rx2) = conn_pair(user);
189 let to_remove = c2.id;
190 hub.register(c1).unwrap();
191 hub.register(c2).unwrap();
192
193 assert!(hub.unregister(user, to_remove));
194 assert_eq!(hub.connections_for(user), 1);
195 assert!(!hub.unregister(user, to_remove), "unregister is idempotent");
196 }
197
198 #[test]
199 fn unregister_last_connection_drops_user_entry() {
200 let hub = ConnectionHub::new(8);
201 let user = Uuid::now_v7();
202 let (conn, _rx) = conn_pair(user);
203 let conn_id = conn.id;
204 hub.register(conn).unwrap();
205 assert_eq!(hub.online_users(), 1);
206
207 assert!(hub.unregister(user, conn_id));
208 assert_eq!(hub.online_users(), 0);
209 assert_eq!(hub.total_connections(), 0);
210 }
211
212 #[test]
213 fn register_rejects_over_max_connections_per_user() {
214 let hub = ConnectionHub::new(2);
215 let user = Uuid::now_v7();
216 let (c1, _) = conn_pair(user);
217 let (c2, _) = conn_pair(user);
218 let (c3, _) = conn_pair(user);
219 hub.register(c1).unwrap();
220 hub.register(c2).unwrap();
221 assert_eq!(hub.register(c3), Err(HubError(2)));
222 }
223
224 #[test]
225 fn closed_sender_reaped_on_next_dispatch() {
226 let hub = ConnectionHub::new(8);
227 let user = Uuid::now_v7();
228 let (c1, _rx_live) = conn_pair(user);
229 let (c2, rx_dead) = conn_pair(user);
230 hub.register(c1).unwrap();
231 hub.register(c2).unwrap();
232 drop(rx_dead);
233
234 let delivered = hub.dispatch(user, frame());
235 assert_eq!(delivered, 1, "dead connection not delivered");
236 assert_eq!(hub.connections_for(user), 1, "dead connection reaped");
237 }
238
239 #[tokio::test]
240 async fn concurrent_register_dispatch_unregister_race_free() {
241 let hub = ConnectionHub::new(64);
242 let user = Uuid::now_v7();
243 let spawned = Arc::new(AtomicUsize::new(0));
244 let mut handles = Vec::new();
245
246 for _ in 0..50 {
247 let hub = hub.clone();
248 let spawned = spawned.clone();
249 handles.push(tokio::spawn(async move {
250 let (conn, _rx) = conn_pair(user);
251 let conn_id = conn.id;
252 hub.register(conn).unwrap();
253 spawned.fetch_add(1, Ordering::SeqCst);
254 let _ = hub.dispatch(user, frame());
255 hub.unregister(user, conn_id);
256 }));
257 }
258 for h in handles {
259 h.await.unwrap();
260 }
261 assert_eq!(spawned.load(Ordering::SeqCst), 50);
262 assert_eq!(hub.online_users(), 0, "all connections cleaned up");
263 assert_eq!(hub.total_connections(), 0);
264 }
265}
Modified chat/glayer/lib.rs chat/glayer/lib.rs +0 −65 View chat/glayer/lib.rs
@@ -21,68 +21,3 @@
2121pub use router::{CHAT_API_PREFIX, router};
2222pub use state::GatewayState;
2323pub use typing::{TypingDebouncer, TypingSignal, typing_topic};
24
25#[cfg(test)]
26pub(crate) mod testutil {
27 use std::sync::Arc;
28
29 use chatlayer::AuthBridge;
30 use config::jwt::JwtSettings;
31 use ed25519_dalek::SigningKey;
32 use ed25519_dalek::pkcs8::EncodePrivateKey;
33 use jwt::JwtState;
34 use jwt::claims::AccessClaims;
35 use jwt::keys::{CachedKey, SigningKeyCache};
36 use jwt::signer::encode_access_token;
37 use rand_core::OsRng;
38 use uuid::Uuid;
39
40 pub(crate) struct TestAuth {
41 pub(crate) bridge: AuthBridge,
42 enc: jsonwebtoken::EncodingKey,
43 kid: Uuid,
44 }
45
46 impl TestAuth {
47 pub(crate) async fn new() -> Self {
48 let sk = SigningKey::generate(&mut OsRng);
49 let vk = sk.verifying_key();
50 let enc = jsonwebtoken::EncodingKey::from_ed_der(sk.to_pkcs8_der().unwrap().as_bytes());
51 let dec = jsonwebtoken::DecodingKey::from_ed_der(&vk.to_bytes());
52 let kid = Uuid::now_v7();
53 let cache = Arc::new(tokio::sync::RwLock::new(SigningKeyCache::new()));
54 cache.write().await.insert(
55 kid,
56 CachedKey {
57 decoding: dec,
58 algorithm: jsonwebtoken::Algorithm::EdDSA,
59 is_rotating: false,
60 grace_ends_at: None,
61 expires_at: None,
62 },
63 );
64 let db = sea_orm::MockDatabase::new(sea_orm::DbBackend::Sqlite).into_connection();
65 let jwt = JwtState::new(db, JwtSettings::default(), cache);
66 Self {
67 bridge: AuthBridge::new(Arc::new(jwt)),
68 enc,
69 kid,
70 }
71 }
72
73 pub(crate) fn mint(&self, user_id: Uuid, ttl: i64) -> String {
74 let claims = AccessClaims::new(
75 user_id,
76 "alice".into(),
77 false,
78 false,
79 1,
80 ttl,
81 "untitled",
82 "untitled-api",
83 &self.kid.to_string(),
84 );
85 encode_access_token(&claims, &self.enc, jsonwebtoken::Algorithm::EdDSA).unwrap()
86 }
87 }
88}
Modified chat/glayer/protocol.rs chat/glayer/protocol.rs +0 −144 View chat/glayer/protocol.rs
@@ -53,147 +53,3 @@
5353pub fn parse_client_frame(text: &str) -> Result<ClientFrame, serde_json::Error> {
5454 serde_json::from_str(text)
5555}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 fn sample_envelope(user_id: Uuid) -> EventEnvelope<ChatUserViewEvent> {
62 EventEnvelope::new(
63 user_id,
64 ChatUserViewEvent::NewMessage {
65 user_id,
66 conversation_id: Uuid::now_v7(),
67 message_id: Uuid::now_v7(),
68 sender_id: Uuid::now_v7(),
69 body: "hi".to_string(),
70 ts: OffsetDateTime::now_utc(),
71 },
72 )
73 }
74
75 #[test]
76 fn server_frame_welcome_roundtrip() {
77 let frame = ServerFrame::Welcome {
78 server_time: OffsetDateTime::now_utc(),
79 };
80 let json = serde_json::to_string(&frame).unwrap();
81 let back: ServerFrame = serde_json::from_str(&json).unwrap();
82 assert_eq!(back, frame);
83 assert!(json.contains("\"type\":\"welcome\""));
84 }
85
86 #[test]
87 fn server_frame_auth_ok_roundtrip() {
88 let frame = ServerFrame::AuthOk {
89 user_id: Uuid::now_v7(),
90 };
91 let json = serde_json::to_string(&frame).unwrap();
92 let back: ServerFrame = serde_json::from_str(&json).unwrap();
93 assert_eq!(back, frame);
94 }
95
96 #[test]
97 fn server_frame_event_encloses_view_event() {
98 let user_id = Uuid::now_v7();
99 let frame = ServerFrame::Event {
100 event: sample_envelope(user_id),
101 };
102 let json = serde_json::to_string(&frame).unwrap();
103 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
104 assert_eq!(parsed["type"], "event");
105 assert_eq!(parsed["event"]["payload"]["type"], "new_message");
106 assert_eq!(parsed["event"]["payload"]["user_id"], user_id.to_string());
107 let back: ServerFrame = serde_json::from_str(&json).unwrap();
108 assert_eq!(back, frame);
109 }
110
111 #[test]
112 fn client_frame_auth_roundtrip() {
113 let frame = ClientFrame::Auth {
114 token: "eyJhbGciOiJFZERTQSJ9.abc.def".to_string(),
115 device_id: Some("ios-7f3a".to_string()),
116 };
117 let json = serde_json::to_string(&frame).unwrap();
118 let back: ClientFrame = serde_json::from_str(&json).unwrap();
119 assert_eq!(back, frame);
120 assert!(json.contains("\"type\":\"auth\""));
121 }
122
123 #[test]
124 fn client_frame_typing_roundtrip() {
125 let frame = ClientFrame::Typing {
126 conversation_id: Uuid::now_v7(),
127 };
128 let json = serde_json::to_string(&frame).unwrap();
129 let back: ClientFrame = serde_json::from_str(&json).unwrap();
130 assert_eq!(back, frame);
131 assert!(json.contains("\"type\":\"typing\""));
132 }
133
134 #[test]
135 fn server_frame_meet_encloses_live_view_event() {
136 let user_id = Uuid::now_v7();
137 let frame = ServerFrame::Meet {
138 event: EventEnvelope::new(
139 user_id,
140 ChatLiveViewEvent::ParticipantJoinedView {
141 user_id,
142 channel_id: Uuid::now_v7(),
143 room_name: "room-1".to_string(),
144 participant_id: Uuid::now_v7(),
145 ts: OffsetDateTime::now_utc(),
146 },
147 ),
148 };
149 let json = serde_json::to_string(&frame).unwrap();
150 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
151 assert_eq!(parsed["type"], "meet");
152 assert_eq!(
153 parsed["event"]["payload"]["type"],
154 "participant_joined_view"
155 );
156 let back: ServerFrame = serde_json::from_str(&json).unwrap();
157 assert_eq!(back, frame);
158 }
159
160 #[test]
161 fn server_frame_typing_roundtrip() {
162 let frame = ServerFrame::Typing {
163 user_id: Uuid::now_v7(),
164 conversation_id: Uuid::now_v7(),
165 ts: OffsetDateTime::now_utc(),
166 };
167 let json = serde_json::to_string(&frame).unwrap();
168 let back: ServerFrame = serde_json::from_str(&json).unwrap();
169 assert_eq!(back, frame);
170 assert!(json.contains("\"type\":\"typing\""));
171 }
172
173 #[test]
174 fn parse_client_frame_rejects_unknown_type() {
175 let err = parse_client_frame(r#"{"type":"bogus","token":"x"}"#).unwrap_err();
176 assert!(err.is_data());
177 }
178
179 #[test]
180 fn parse_client_frame_rejects_missing_token() {
181 let err = parse_client_frame(r#"{"type":"auth"}"#).unwrap_err();
182 assert!(err.is_data());
183 }
184
185 #[test]
186 fn server_frame_as_ws_message_is_text_with_json() {
187 let frame = ServerFrame::AuthOk {
188 user_id: Uuid::now_v7(),
189 };
190 let msg = frame.as_ws_message();
191 match msg {
192 axum::extract::ws::Message::Text(txt) => {
193 let v: serde_json::Value = serde_json::from_str(&txt).unwrap();
194 assert_eq!(v["type"], "auth_ok");
195 }
196 other => panic!("expected Text message, got {other:?}"),
197 }
198 }
199}
Modified chat/glayer/typing.rs chat/glayer/typing.rs +0 −84 View chat/glayer/typing.rs
@@ -86,87 +86,3 @@
8686 }
8787 }
8888}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use std::time::Duration;
94 use tokio::sync::mpsc;
95 use uuid::Uuid;
96
97 use crate::hub::{Connection, ConnectionHub};
98 use storagelayer::ChatStorageFactory;
99 use storagelayer::models::conversation::{ConversationKind, NewConversation};
100
101 #[test]
102 fn typing_debouncer_allows_first_then_denies_within_interval() {
103 let d = TypingDebouncer::new(Duration::from_secs(60));
104 let cid = Uuid::now_v7();
105 let uid = Uuid::now_v7();
106 assert!(d.allow(cid, uid), "first signal passes");
107 assert!(!d.allow(cid, uid), "second within interval debounced");
108 assert!(d.allow(cid, Uuid::now_v7()), "different user not debounced");
109 }
110
111 #[test]
112 fn typing_topic_naming() {
113 assert_eq!(typing_topic("chat"), "chat.typing.v1");
114 assert_eq!(typing_topic("chat-dev"), "chat-dev.typing.v1");
115 }
116
117 #[tokio::test]
118 async fn broadcast_typing_excludes_typer_and_reaches_members() {
119 let store = ChatStorageFactory::in_memory();
120 let hub = ConnectionHub::new(8);
121 let alice = Uuid::now_v7();
122 let bob = Uuid::now_v7();
123
124 let conv = store
125 .conversations()
126 .create(
127 &ChatStorageCtx::new(alice),
128 NewConversation {
129 space_id: Uuid::now_v7(),
130 parent_dir: None,
131 kind: ConversationKind::Private,
132 counterpart_user_id: Some(bob),
133 title: None,
134 },
135 )
136 .await
137 .unwrap();
138
139 let (tx_a, mut rx_a) = mpsc::unbounded_channel();
140 hub.register(Connection {
141 id: Uuid::now_v7(),
142 user_id: alice,
143 tx: tx_a,
144 established_at: OffsetDateTime::now_utc(),
145 })
146 .unwrap();
147 let (tx_b, mut rx_b) = mpsc::unbounded_channel();
148 hub.register(Connection {
149 id: Uuid::now_v7(),
150 user_id: bob,
151 tx: tx_b,
152 established_at: OffsetDateTime::now_utc(),
153 })
154 .unwrap();
155
156 let signal = TypingSignal {
157 conversation_id: conv.id,
158 user_id: alice,
159 ts: OffsetDateTime::now_utc(),
160 };
161 broadcast_typing(&store, &hub, &signal).await;
162
163 assert!(
164 rx_b.try_recv().is_ok(),
165 "counterpart receives typing signal"
166 );
167 assert!(
168 rx_a.try_recv().is_err(),
169 "typer does not receive own signal"
170 );
171 }
172}
Modified chat/glayer/ws.rs chat/glayer/ws.rs +0 −38 View chat/glayer/ws.rs
@@ -207,41 +207,3 @@
207207 }
208208 }
209209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use crate::testutil;
215
216 #[tokio::test]
217 async fn verify_auth_frame_accepts_real_jwt() {
218 let ta = testutil::TestAuth::new().await;
219 let user_id = Uuid::now_v7();
220 let token = ta.mint(user_id, 900);
221 let frame = format!(r#"{{"type":"auth","token":"{token}"}}"#);
222 let identity = verify_auth_frame(&ta.bridge, &frame).await.unwrap();
223 assert_eq!(identity.user_id, user_id);
224 }
225
226 #[tokio::test]
227 async fn verify_auth_frame_rejects_garbage_token() {
228 let ta = testutil::TestAuth::new().await;
229 let frame = r#"{"type":"auth","token":"not-a-jwt"}"#;
230 assert!(verify_auth_frame(&ta.bridge, frame).await.is_err());
231 }
232
233 #[tokio::test]
234 async fn verify_auth_frame_rejects_missing_token_field() {
235 let ta = testutil::TestAuth::new().await;
236 let frame = r#"{"type":"auth"}"#;
237 assert!(verify_auth_frame(&ta.bridge, frame).await.is_err());
238 }
239
240 #[tokio::test]
241 async fn verify_auth_frame_rejects_expired_token() {
242 let ta = testutil::TestAuth::new().await;
243 let token = ta.mint(Uuid::now_v7(), -10000);
244 let frame = format!(r#"{{"type":"auth","token":"{token}"}}"#);
245 assert!(verify_auth_frame(&ta.bridge, &frame).await.is_err());
246 }
247}
Modified chat/live/Cargo.toml chat/live/Cargo.toml +6 −3 View chat/live/Cargo.toml
@@ -16,6 +16,10 @@
1616chatlayer.workspace = true
1717storagelayer.workspace = true
1818config.workspace = true
19jwt.workspace = true
20rate_limit.workspace = true
21export.workspace = true
22cache.workspace = true
1923
2024thiserror.workspace = true
2125serde.workspace = true
@@ -28,6 +32,5 @@
2832jsonwebtoken.workspace = true
2933sha2.workspace = true
3034hex.workspace = true
31
32[dev-dependencies]
33storagelayer = { workspace = true, features = ["mock"] }
No newline at end of file
35tokio.workspace = true
36utoipa.workspace = true
Modified chat/live/lib.rs chat/live/lib.rs +2 −0 View chat/live/lib.rs
@@ -3,6 +3,7 @@
33
44pub mod client;
55pub mod error;
6pub mod handler;
67pub mod metrics;
78pub mod service;
89pub mod token;
@@ -11,6 +12,7 @@
1112
1213pub use client::LiveKitClient;
1314pub use error::{LiveError, LiveResult};
15pub use handler::live_router;
1416pub use service::LiveService;
1517pub use token::{LiveTokenClaims, VideoGrants, mint_access_token};
1618pub use types::{JoinInfo, MeetInfo, MeetPresence, ParticipantRecord};
Modified chat/live/service.rs chat/live/service.rs +30 −0 View chat/live/service.rs
@@ -1,6 +1,8 @@
11use std::sync::Arc;
22
33use axum::http::HeaderMap;
4use cache::redis::RedisCache;
5use jwt::{HasJwtState, JwtState};
46use storagelayer::{ChatStorageCtx, DynChatStorage};
57use uuid::Uuid;
68
@@ -17,6 +19,8 @@
1719 pub(crate) producer: Arc<chatlayer::ChatProducer>,
1820 pub(crate) client: LiveKitClient,
1921 pub(crate) settings: LiveSettings,
22 pub(crate) kv: Arc<RedisCache>,
23 pub(crate) jwt: JwtState,
2024}
2125
2226impl std::fmt::Debug for LiveService {
@@ -32,6 +36,8 @@
3236 pub fn new(
3337 store: DynChatStorage,
3438 producer: Arc<chatlayer::ChatProducer>,
39 kv: Arc<RedisCache>,
40 jwt: JwtState,
3541 settings: LiveSettings,
3642 ) -> Self {
3743 let client = LiveKitClient::new(
@@ -44,6 +50,8 @@
4450 producer,
4551 client,
4652 settings,
53 kv,
54 jwt,
4755 }
4856 }
4957
@@ -289,3 +297,25 @@
289297 Ok(())
290298 }
291299}
300
301impl HasJwtState for LiveService {
302 fn jwt_state(&self) -> &JwtState {
303 &self.jwt
304 }
305
306 fn access_blacklist(&self) -> Option<Arc<rate_limit::AccessTokenBlacklist>> {
307 Some(Arc::new(rate_limit::AccessTokenBlacklist::new(
308 self.kv.clone(),
309 )))
310 }
311}
312
313impl rate_limit::RateLimitProvider for LiveService {
314 fn rate_limit_kv(&self) -> Arc<RedisCache> {
315 self.kv.clone()
316 }
317
318 fn rate_limit_settings(&self) -> Arc<config::rate_limit::RateLimitConfig> {
319 Arc::new(config::rate_limit::RateLimitConfig::default())
320 }
321}
Modified chat/live/token.rs chat/live/token.rs +0 −45 View chat/live/token.rs
@@ -146,48 +146,3 @@
146146 let key = EncodingKey::from_secret(api_secret.as_bytes());
147147 encode(&header, &claims, &key).map_err(LiveError::from)
148148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use jsonwebtoken::{DecodingKey, Validation, decode};
154
155 #[test]
156 fn participant_token_decodes_with_matching_grants() {
157 let api_key = "devkey";
158 let api_secret = "devsecret";
159 let user = Uuid::now_v7();
160 let room = format!("room-{}", Uuid::now_v7());
161
162 let token = mint_access_token(api_key, api_secret, user, &room, 300).unwrap();
163 let mut validation = Validation::new(Algorithm::HS256);
164 validation.validate_exp = true;
165 validation.validate_nbf = true;
166 let key = DecodingKey::from_secret(api_secret.as_bytes());
167 let data = decode::<LiveTokenClaims>(&token, &key, &validation).unwrap();
168 let claims = data.claims;
169
170 assert_eq!(claims.iss, api_key);
171 assert_eq!(claims.sub, user.to_string());
172 assert_eq!(claims.video.room.as_deref(), Some(room.as_str()));
173 assert_eq!(claims.video.room_join, Some(true));
174 assert!(claims.exp > claims.iat);
175 }
176
177 #[test]
178 fn token_rejects_wrong_secret() {
179 let api_key = "devkey";
180 let user = Uuid::now_v7();
181 let token = mint_access_token(api_key, "secret-a", user, "room-x", 60).unwrap();
182 let key = DecodingKey::from_secret(b"secret-b");
183 let mut validation = Validation::new(Algorithm::HS256);
184 let res = decode::<LiveTokenClaims>(&token, &key, &validation);
185 assert!(res.is_err(), "wrong secret must not verify");
186 }
187
188 #[test]
189 fn empty_secret_is_not_configured() {
190 let err = mint_access_token("", "", Uuid::now_v7(), "room", 60).unwrap_err();
191 assert!(matches!(err, LiveError::NotConfigured(_)));
192 }
193}
Modified chat/live/types.rs chat/live/types.rs +11 −5 View chat/live/types.rs
@@ -1,9 +1,11 @@
11use serde::{Deserialize, Serialize};
22use storagelayer::MeetStatus;
33use time::OffsetDateTime;
4use utoipa::ToSchema;
45use uuid::Uuid;
56
6#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
7/// Public view of a meeting room attached to an object channel.
8#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
79pub struct MeetInfo {
810 pub id: Uuid,
911 pub channel_id: Uuid,
@@ -30,7 +32,8 @@
3032 }
3133}
3234
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35/// A participant row with its leave outcome (audit trail).
36#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
3437pub struct ParticipantRecord {
3538 pub id: Uuid,
3639 pub meet_id: Uuid,
@@ -53,7 +56,9 @@
5356 }
5457}
5558
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59/// Result of a `join_room` call: the meeting plus everything the client needs
60/// to connect straight to LiveKit.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
5762pub struct JoinInfo {
5863 pub meet: MeetInfo,
5964 pub access_token: String,
@@ -61,8 +66,9 @@
6166 pub participants: Vec<ParticipantRecord>,
6267}
6368
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
69/// Active presence snapshot for a room.
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
6571pub struct MeetPresence {
6672 pub meet: MeetInfo,
6773 pub participants: Vec<ParticipantRecord>,
68}
74}
No newline at end of file
Modified chat/live/webhook.rs chat/live/webhook.rs +0 −104 View chat/live/webhook.rs
@@ -173,107 +173,3 @@
173173 Some(_) => LeaveReason::Left,
174174 }
175175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn reason_mapping_covers_livekit_values() {
183 assert_eq!(
184 reason_from_disconnect(None),
185 storagelayer::LeaveReason::Left
186 );
187 assert_eq!(
188 reason_from_disconnect(Some("client_initiated".into())),
189 storagelayer::LeaveReason::Left
190 );
191 assert_eq!(
192 reason_from_disconnect(Some("participant_removed".into())),
193 storagelayer::LeaveReason::Removed
194 );
195 assert_eq!(
196 reason_from_disconnect(Some("room_closed".into())),
197 storagelayer::LeaveReason::RoomEnded
198 );
199 assert_eq!(
200 reason_from_disconnect(Some("network_error".into())),
201 storagelayer::LeaveReason::NetworkDrop
202 );
203 }
204
205 #[test]
206 fn webhook_signature_verifies_with_matching_secret() {
207 let secret = "wh-secret";
208 let body = br#"{"event":"room.started","room":{"name":"room-1"}}"#;
209 let digest = hex::encode(Sha256::digest(body));
210
211 let claims = serde_json::json!({
212 "iss": "wh-key",
213 "sub": "room-1",
214 "nh": digest,
215 "exp": OffsetDateTime::now_utc().unix_timestamp() + 300,
216 });
217 let token = jsonwebtoken::encode(
218 &jsonwebtoken::Header::new(Algorithm::HS256),
219 &claims,
220 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
221 )
222 .unwrap();
223
224 let mut headers = HeaderMap::new();
225 headers.insert(AUTH_HEADER, token.parse().unwrap());
226 assert!(verify_webhook(&headers, body, secret).is_ok());
227 }
228
229 #[test]
230 fn webhook_signature_rejects_tampered_body() {
231 let secret = "wh-secret";
232 let body = br#"{"event":"room.started"}"#;
233 let tampered = br#"{"event":"room.finished"}"#;
234 let digest = hex::encode(Sha256::digest(body));
235 let claims = serde_json::json!({
236 "iss": "wh-key", "sub": "room-1", "nh": digest,
237 "exp": OffsetDateTime::now_utc().unix_timestamp() + 300,
238 });
239 let token = jsonwebtoken::encode(
240 &jsonwebtoken::Header::new(Algorithm::HS256),
241 &claims,
242 &jsonwebtoken::EncodingKey::from_secret(secret.as_bytes()),
243 )
244 .unwrap();
245 let mut headers = HeaderMap::new();
246 headers.insert(AUTH_HEADER, token.parse().unwrap());
247 assert!(verify_webhook(&headers, tampered, secret).is_err());
248 }
249
250 #[test]
251 fn event_mapping_for_join_and_left() {
252 let channel = Uuid::now_v7();
253 let joined = LiveKitWebhook {
254 event: "participant.joined".into(),
255 room: Some(WebhookRoom {
256 sid: "rm_1".into(),
257 name: build_room_name(channel),
258 empty_timeout: 0,
259 }),
260 participant: Some(WebhookParticipant {
261 sid: "PA_1".into(),
262 identity: Uuid::now_v7().to_string(),
263 disconnect_reason: None,
264 }),
265 };
266 let evt = event_from_webhook(&joined).unwrap().unwrap();
267 assert!(matches!(
268 evt,
269 chatlayer::ChatLiveEvent::ParticipantJoined { .. }
270 ));
271 }
272
273 #[test]
274 fn room_name_roundtrip_recovers_channel() {
275 let channel = Uuid::now_v7();
276 let name = build_room_name(channel);
277 assert_eq!(channel_id_from_room_name(&name).unwrap(), channel);
278 }
279}
Modified chat/storagelayer/models/meet.rs chat/storagelayer/models/meet.rs +4 −3 View chat/storagelayer/models/meet.rs
@@ -1,8 +1,9 @@
11use serde::{Deserialize, Serialize};
22use time::OffsetDateTime;
3use utoipa::ToSchema;
34use uuid::Uuid;
45
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
67#[serde(rename_all = "snake_case")]
78pub enum MeetStatus {
89 #[default]
@@ -11,7 +12,7 @@
1112 Ended,
1213}
1314
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
1516pub struct Meet {
1617 pub id: Uuid,
1718 pub channel_id: Uuid,
@@ -24,7 +25,7 @@
2425 pub updated_at: Option<OffsetDateTime>,
2526}
2627
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
2829pub struct NewMeet {
2930 pub channel_id: Uuid,
3031 pub room_name: String,
Modified chat/storagelayer/models/meet_participant.rs chat/storagelayer/models/meet_participant.rs +4 −3 View chat/storagelayer/models/meet_participant.rs
@@ -1,8 +1,9 @@
11use serde::{Deserialize, Serialize};
22use time::OffsetDateTime;
3use utoipa::ToSchema;
34use uuid::Uuid;
45
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, ToSchema)]
67#[serde(rename_all = "snake_case")]
78pub enum LeaveReason {
89 #[default]
@@ -14,7 +15,7 @@
1415 Kicked,
1516}
1617
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
1819pub struct MeetParticipant {
1920 pub id: Uuid,
2021 pub meet_id: Uuid,
@@ -24,7 +25,7 @@
2425 pub leave_reason: Option<LeaveReason>,
2526}
2627
27#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
2829pub struct NewMeetParticipant {
2930 pub meet_id: Uuid,
3031 pub user_id: Uuid,
Modified chat/storagelayer/Cargo.toml chat/storagelayer/Cargo.toml +1 −4 View chat/storagelayer/Cargo.toml
@@ -31,9 +31,6 @@
3131tracing.workspace = true
3232url.workspace = true
3333sea-orm.workspace = true
34utoipa.workspace = true
3435
3536futures = { workspace = true }
36
37[features]
38default = []
39mock = []
No newline at end of file
Modified chat/storagelayer/lib.rs chat/storagelayer/lib.rs +1 −8 View chat/storagelayer/lib.rs
@@ -1,4 +1,3 @@
1//! # #[cfg(feature = "mock")]
21#![warn(missing_debug_implementations)]
32#![warn(unreachable_pub)]
43
@@ -10,9 +9,6 @@
109pub mod models;
1110pub mod repository;
1211
13#[cfg(feature = "mock")]
14pub mod mock;
15
1612pub use blob::{
1713 media::{AttachmentPath, ChatMediaStore, DynChatMediaStore},
1814 object_store::ObjectStoreChatMedia,
@@ -34,12 +30,9 @@
3430 thread::Thread,
3531};
3632pub use repository::{
37 ChatStorage, ChatStorageCtx, ChatStorageFactory, DynChatStorage,
33 ChatStorage, ChatStorageCtx, DynChatStorage,
3834 attachment::AttachmentRepository, conversation::ConversationRepository, meet::MeetRepository,
3935 meet_participant::MeetParticipantRepository, mention::MentionRepository,
4036 message::MessageRepository, pinned::PinnedMessageRepository, reaction::ReactionRepository,
4137 read_receipt::ReadReceiptRepository, thread::ThreadRepository,
4238};
43
44#[cfg(feature = "mock")]
45pub use mock::in_memory::InMemoryChatStorage;
Modified chat/storagelayer/repository.rs chat/storagelayer/repository.rs +0 −17 View chat/storagelayer/repository.rs
@@ -67,20 +67,3 @@
6767}
6868
6969pub type DynChatStorage = Arc<dyn ChatStorage>;
70
71#[derive(Debug)]
72pub struct ChatStorageFactory;
73
74impl ChatStorageFactory {
75 pub fn from_db(
76 db: Arc<db::conn::Db>,
77 blobs: Arc<dyn crate::blob::ChatMediaStore>,
78 ) -> DynChatStorage {
79 Arc::new(crate::mapper::SeaChatStorage::new(db, blobs))
80 }
81
82 #[cfg(feature = "mock")]
83 pub fn in_memory() -> DynChatStorage {
84 crate::InMemoryChatStorage::new_shared()
85 }
86}
Modified dvcs/git/archive/usecase.rs dvcs/git/archive/usecase.rs +0 −79 View dvcs/git/archive/usecase.rs
@@ -86,82 +86,3 @@
8686 Ok(v)
8787 }
8888}
89
90#[cfg(test)]
91mod tests {
92 use super::*;
93 use crate::GitRepository;
94 use crate::object::cache::ObjectCache;
95 use cache::CacheBuilder;
96 use config::storage::StorageSettings;
97 use storage::local::LocalStorage;
98
99 fn make_repo(dir: &std::path::Path, name: &str) -> GitRepository {
100 let cache_cfg = config::cache::AppCacheSettings::default();
101 let cache = block_on(CacheBuilder::from_config(cache_cfg).build())
102 .expect("cache build")
103 .expect("cache enabled");
104 let cache = std::sync::Arc::new(cache);
105 let oc = ObjectCache {
106 prefix: "test".to_string(),
107 cache,
108 expire_ttl: 300,
109 };
110 let mut settings = StorageSettings::default();
111 settings.set_local(
112 config::storage::LocalStorageSettings::builder()
113 .root_dir(dir.join("storage").to_path_buf())
114 .build(),
115 );
116 let storage: std::sync::Arc<dyn storage::StorageRepository> =
117 std::sync::Arc::new(LocalStorage::from_config(&settings).expect("storage"));
118 GitRepository::new(dir.join(name), oc, storage)
119 }
120
121 #[test]
122 fn archive_generates_and_serves_from_cache() {
123 let tmp = std::env::temp_dir().join(format!("git-archive-test-{}", std::process::id()));
124 let _ = std::fs::remove_dir_all(&tmp);
125 let repo = make_repo(&tmp, "main");
126 crate::gitcli::run(&repo.path, &["init", "-q", "--bare", "-b", "main"]).unwrap();
127 crate::gitcli::run(&repo.path, &["config", "user.email", "t@t"]).unwrap();
128 crate::gitcli::run(&repo.path, &["config", "user.name", "t"]).unwrap();
129 let blob = run_stdin(&repo.path, &["hash-object", "-w", "--stdin"], b"hello\n");
130 let tree = run_stdin(
131 &repo.path,
132 &["mktree", "-z"],
133 &format!("100644 blob {blob}\tf.txt\0").into_bytes(),
134 );
135 let commit = run_stdin(&repo.path, &["commit-tree", &tree, "-m", "init"], b"");
136 crate::gitcli::run(&repo.path, &["update-ref", "refs/heads/main", &commit]).unwrap();
137 let opts = ArchiveOptions {
138 format: ArchiveFormat::TarGz,
139 path_filter: None,
140 };
141 let first = ArchiveOps::archive_archive(&repo, "main", "HEAD", &opts).unwrap();
142 assert!(first.size > 0);
143 let second = ArchiveOps::archive_archive(&repo, "main", "HEAD", &opts).unwrap();
144 assert_eq!(first.size, second.size);
145 let sp = StorePath::from("main/archive/HEAD.tar.gz");
146 assert!(block_on(repo.storage.exists(&sp)).unwrap());
147 let _ = std::fs::remove_dir_all(&tmp);
148 }
149
150 fn run_stdin(repo_path: &std::path::Path, args: &[&str], input: &[u8]) -> String {
151 let out = duct::cmd("git", args)
152 .env("GIT_DIR", repo_path)
153 .env("GIT_TERMINAL_PROMPT", "0")
154 .stdin_bytes(input)
155 .stdout_capture()
156 .stderr_capture()
157 .run()
158 .unwrap();
159 assert!(
160 out.status.success(),
161 "git {:?} failed: {:?}",
162 args,
163 out.stderr
164 );
165 String::from_utf8_lossy(&out.stdout).trim().to_string()
166 }
167}
Modified dvcs/git/lfs/usecase.rs dvcs/git/lfs/usecase.rs +0 −103 View dvcs/git/lfs/usecase.rs
@@ -155,106 +155,3 @@
155155 }
156156 .map_err(crate::Error::Lfs)
157157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162 use crate::object::cache::ObjectCache;
163 use cache::CacheBuilder;
164 use config::storage::StorageSettings;
165 use std::sync::Arc;
166 use storage::local::LocalStorage;
167
168 fn block_on<F: std::future::Future>(f: F) -> F::Output {
169 tokio::runtime::Builder::new_current_thread()
170 .enable_all()
171 .build()
172 .unwrap()
173 .block_on(f)
174 }
175
176 fn make_repo(dir: &std::path::Path) -> GitRepository {
177 let cache_cfg = config::cache::AppCacheSettings::default();
178 let cache = Arc::new(
179 block_on(CacheBuilder::from_config(cache_cfg).build())
180 .expect("cache build")
181 .expect("cache enabled"),
182 );
183 let oc = ObjectCache {
184 prefix: "test".to_string(),
185 cache,
186 expire_ttl: 300,
187 };
188 let mut settings = StorageSettings::default();
189 settings.set_local(
190 config::storage::LocalStorageSettings::builder()
191 .root_dir(dir.join("storage").to_path_buf())
192 .build(),
193 );
194 let storage: std::sync::Arc<dyn storage::StorageRepository> =
195 std::sync::Arc::new(LocalStorage::from_config(&settings).expect("storage"));
196 GitRepository::new(dir.join("repo"), oc, storage)
197 }
198
199 #[test]
200 fn lfs_roundtrip_through_store() {
201 let tmp = std::env::temp_dir().join(format!("git-lfs-test-{}", std::process::id()));
202 let _ = std::fs::remove_dir_all(&tmp);
203 let repo = make_repo(&tmp);
204
205 let data = b"hello lfs".to_vec();
206 let oid = LfsOid::compute(&data);
207 block_on(repo.lfs.put_object(&oid, bytes::Bytes::from(data.clone()))).unwrap();
208
209 let obj = LfsObject {
210 oid: oid.as_str().to_string(),
211 size: data.len() as i64,
212 content_type: None,
213 };
214
215 let up = repo.lfs_upload(&obj).unwrap();
216 assert!(up.present);
217
218 repo.lfs_verify(&obj).unwrap();
219
220 let req = LfsBatchRequest {
221 operation: LfsOperation::Upload,
222 transfers: vec!["basic".into()],
223 objects: vec![obj.clone()],
224 };
225 let resp = repo.lfs_handle_batch(&req).unwrap();
226 assert_eq!(resp.objects.len(), 1);
227 assert!(resp.objects[0].error.is_none());
228 assert!(resp.objects[0].actions.is_none());
229
230 let req = LfsBatchRequest {
231 operation: LfsOperation::Download,
232 transfers: vec!["basic".into()],
233 objects: vec![obj.clone()],
234 };
235 let resp = repo.lfs_handle_batch(&req).unwrap();
236 assert!(resp.objects[0].actions.is_some());
237 assert!(resp.objects[0].error.is_none());
238
239 let missing = LfsObject {
240 oid: "0".repeat(64),
241 size: 1,
242 content_type: None,
243 };
244 assert!(matches!(
245 repo.lfs_verify(&missing),
246 Err(crate::Error::NotExist(_))
247 ));
248
249 let req = LfsBatchRequest {
250 operation: LfsOperation::Download,
251 transfers: vec![],
252 objects: vec![missing.clone()],
253 };
254 let resp = repo.lfs_handle_batch(&req).unwrap();
255 assert!(resp.objects[0].actions.is_none());
256 assert!(resp.objects[0].error.is_some());
257
258 let _ = std::fs::remove_dir_all(&tmp);
259 }
260}
Modified dvcs/git/tree/usecase.rs dvcs/git/tree/usecase.rs +0 −89 View dvcs/git/tree/usecase.rs
@@ -304,92 +304,3 @@
304304 }
305305 Ok((dir_max_time, dir_max_id))
306306}
307#[cfg(test)]
308mod tests {
309 use super::*;
310 use crate::GitRepository;
311 use crate::object::cache::ObjectCache;
312 use cache::CacheBuilder;
313 use config::storage::StorageSettings;
314 use storage::local::LocalStorage;
315
316 fn block_on<F: std::future::Future>(f: F) -> F::Output {
317 tokio::runtime::Builder::new_current_thread()
318 .enable_all()
319 .build()
320 .unwrap()
321 .block_on(f)
322 }
323
324 fn make_repo(dir: &std::path::Path, name: &str) -> GitRepository {
325 let cache_cfg = config::cache::AppCacheSettings::default();
326 let cache = block_on(CacheBuilder::from_config(cache_cfg).build())
327 .expect("cache build")
328 .expect("cache enabled");
329 let cache = std::sync::Arc::new(cache);
330 let oc = ObjectCache {
331 prefix: "test".to_string(),
332 cache,
333 expire_ttl: 300,
334 };
335 let mut settings = StorageSettings::default();
336 settings.set_local(
337 config::storage::LocalStorageSettings::builder()
338 .root_dir(dir.join("storage").to_path_buf())
339 .build(),
340 );
341 let storage: std::sync::Arc<dyn storage::StorageRepository> =
342 std::sync::Arc::new(LocalStorage::from_config(&settings).expect("storage"));
343 GitRepository::new(dir.join(name), oc, storage)
344 }
345
346 fn run_stdin(repo_path: &std::path::Path, args: &[&str], input: &[u8]) -> String {
347 let out = duct::cmd("git", args)
348 .env("GIT_DIR", repo_path)
349 .env("GIT_TERMINAL_PROMPT", "0")
350 .stdin_bytes(input)
351 .stdout_capture()
352 .stderr_capture()
353 .run()
354 .unwrap();
355 String::from_utf8_lossy(&out.stdout).trim().to_string()
356 }
357
358 #[test]
359 fn tree_last_updated_reports_blob_and_dir_times() {
360 let tmp = std::env::temp_dir().join(format!("git-lastupd-test-{}", std::process::id()));
361 let _ = std::fs::remove_dir_all(&tmp);
362 let repo = make_repo(&tmp, "main");
363 crate::gitcli::run(&repo.path, &["init", "-q", "--bare", "-b", "main"]).unwrap();
364 crate::gitcli::run(&repo.path, &["config", "user.email", "t@t"]).unwrap();
365 crate::gitcli::run(&repo.path, &["config", "user.name", "t"]).unwrap();
366
367 let blob_a = run_stdin(&repo.path, &["hash-object", "-w", "--stdin"], b"a\n");
368 let blob_b = run_stdin(&repo.path, &["hash-object", "-w", "--stdin"], b"b\n");
369 let tree_d = run_stdin(
370 &repo.path,
371 &["mktree", "-z"],
372 &format!("100644 blob {blob_b}\td.txt\0").into_bytes(),
373 );
374 let tree_root = run_stdin(
375 &repo.path,
376 &["mktree", "-z"],
377 &format!("100644 blob {blob_a}\ta.txt\040000 tree {tree_d}\td\0").into_bytes(),
378 );
379 let c1 = run_stdin(&repo.path, &["commit-tree", &tree_root, "-m", "init"], b"");
380 crate::gitcli::run(&repo.path, &["update-ref", "refs/heads/main", &c1]).unwrap();
381
382 let res = TreeOps::tree_last_updated(&repo, "main").unwrap();
383 let by_path: std::collections::HashMap<_, _> =
384 res.iter().map(|p| (p.path.clone(), p)).collect();
385 assert!(by_path.contains_key("a.txt"));
386 assert!(by_path.contains_key("d/d.txt"));
387 assert!(by_path.contains_key("d"));
388 assert!(by_path["a.txt"].last_updated > 0);
389 assert_eq!(by_path["a.txt"].commit_id.to_string(), c1);
390 assert_eq!(by_path["d/d.txt"].commit_id.to_string(), c1);
391 assert_eq!(by_path["d"].commit_id.to_string(), c1);
392 assert!(by_path["d"].last_updated >= by_path["d/d.txt"].last_updated);
393 let _ = std::fs::remove_dir_all(&tmp);
394 }
395}
Modified dvcs/git/Cargo.toml dvcs/git/Cargo.toml +0 −4 View dvcs/git/Cargo.toml
@@ -26,7 +26,3 @@
2626async-trait.workspace = true
2727async-graphql.workspace = true
2828base64.workspace = true
29
30[dev-dependencies]
31config.workspace = true
32tempfile.workspace = true
No newline at end of file
Modified dvcs/git/policy.rs dvcs/git/policy.rs +0 −23 View dvcs/git/policy.rs
@@ -43,26 +43,3 @@
4343pub fn arc<P: PolicyProvider + 'static>(provider: P) -> Policy {
4444 Arc::new(provider)
4545}
46
47#[cfg(test)]
48mod tests {
49 use super::*;
50
51 #[test]
52 fn default_policy_protects_integration_branches() {
53 let p = DefaultPolicy;
54 assert!(p.is_branch_protected("main"));
55 assert!(p.is_branch_protected("master"));
56 assert!(p.is_branch_protected("develop"));
57 assert!(!p.is_branch_protected("feature"));
58 assert!(p.is_tag_protected("release/v1.0"));
59 assert!(!p.is_tag_protected("v1.0"));
60 }
61
62 #[test]
63 fn no_protection_protects_nothing() {
64 let p = NoProtection;
65 assert!(!p.is_branch_protected("main"));
66 assert!(!p.is_tag_protected("release/v1.0"));
67 }
68}
Modified dvcs/git/validate.rs dvcs/git/validate.rs +0 −45 View dvcs/git/validate.rs
@@ -77,48 +77,3 @@
7777 }
7878 Ok(())
7979}
80
81#[cfg(test)]
82mod tests {
83 use super::*;
84
85 #[test]
86 fn oid_validation() {
87 assert!(validate_oid(&"a".repeat(64), 64).is_ok());
88 assert!(validate_oid(&"g".repeat(64), 64).is_err());
89 assert!(validate_oid(&"a".repeat(63), 64).is_err());
90 assert!(validate_oid(&"A".repeat(64), 64).is_ok());
91 }
92
93 #[test]
94 fn short_name_validation() {
95 assert!(validate_short_name("branch", "feature/x").is_err());
96 assert!(validate_short_name("branch", "..").is_err());
97 assert!(validate_short_name("branch", "refs/heads/main").is_err());
98 assert!(validate_short_name("branch", "").is_err());
99 assert!(validate_short_name("branch", "feature_x").is_ok());
100 assert!(validate_short_name("branch", "main").is_ok());
101 }
102
103 #[test]
104 fn qualified_ref_validation() {
105 assert!(validate_qualified_ref("refs/heads/main").is_ok());
106 assert!(validate_qualified_ref("main").is_err());
107 assert!(validate_qualified_ref("refs/heads/ma..in").is_err());
108 }
109
110 #[test]
111 fn remote_scheme_validation() {
112 assert!(allowed_remote_scheme("https://example.com/x").is_ok());
113 assert!(allowed_remote_scheme("ssh://git@example.com/x").is_ok());
114 assert!(allowed_remote_scheme("/etc/passwd").is_err());
115 assert!(allowed_remote_scheme("file:///etc/passwd").is_err());
116 assert!(allowed_remote_scheme("tmp/repo").is_err());
117 }
118
119 #[test]
120 fn size_limit() {
121 assert!(enforce_max_size(&[0u8; 10], 100, "obj").is_ok());
122 assert!(enforce_max_size(&[0u8; 200], 100, "obj").is_err());
123 }
124}
Modified dvcs/gitauth/basic.rs dvcs/gitauth/basic.rs +0 −35 View dvcs/gitauth/basic.rs
@@ -33,38 +33,3 @@
3333 STANDARD.encode(format!("{username}:{password}"))
3434 )
3535}
36
37#[cfg(test)]
38mod tests {
39 use super::*;
40
41 #[test]
42 fn parses_valid_header() {
43 let header = encode_credentials("alice", "s3cret");
44 let creds = parse_authorization(&header).expect("parse");
45 assert_eq!(
46 creds,
47 BasicCredentials {
48 username: "alice".into(),
49 password: "s3cret".into(),
50 }
51 );
52 }
53
54 #[test]
55 fn rejects_missing_scheme() {
56 assert!(parse_authorization("Bearer abc").is_err());
57 }
58
59 #[test]
60 fn rejects_invalid_base64() {
61 assert!(parse_authorization("Basic !!!not-base64!!!").is_err());
62 }
63
64 #[test]
65 fn rejects_missing_separator() {
66 let header = encode_credentials("onlyuser", "");
67 let creds = parse_authorization(&header).expect("parse");
68 assert_eq!(creds.password, "");
69 }
70}
Modified dvcs/gitauth/Cargo.toml dvcs/gitauth/Cargo.toml +0 −5 View dvcs/gitauth/Cargo.toml
@@ -25,8 +25,3 @@
2525tokio.workspace = true
2626tracing.workspace = true
2727uuid.workspace = true
28
29[dev-dependencies]
30argon2.workspace = true
31config.workspace = true
32password-hash.workspace = true
No newline at end of file
Modified dvcs/gitauth/lib.rs dvcs/gitauth/lib.rs +0 −4 View dvcs/gitauth/lib.rs
@@ -4,10 +4,6 @@
44pub mod permission;
55pub mod resolve;
66pub mod service;
7
8#[cfg(test)]
9pub mod testing;
10
117pub use basic::{BasicCredentials, parse_authorization};
128pub use error::{GitAuthError, GitAuthResult};
139pub use resolve::ResolvedRepo;
Modified dvcs/gitauth/service.rs dvcs/gitauth/service.rs +0 −329 View dvcs/gitauth/service.rs
@@ -149,332 +149,3 @@
149149 pub user_id: Uuid,
150150 pub actor: ActorContext,
151151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use config::jwt::JwtSettings;
157 use db::channel::channels;
158 use db::conn::sea_orm::{ActiveModelTrait, ConnectionTrait, DatabaseConnection, Set};
159 use db::repo::{repo_collaborations, repos};
160 use db::space::spaces;
161 use jwt::SigningKeyCache;
162 use std::sync::Arc;
163 use time::OffsetDateTime;
164 use tokio::sync::RwLock;
165 use uuid::Uuid;
166
167 fn now() -> OffsetDateTime {
168 OffsetDateTime::now_utc()
169 }
170
171 async fn insert_user(
172 db: &impl ConnectionTrait,
173 uid: Uuid,
174 username: &str,
175 passwd_hash: Option<&str>,
176 ) {
177 let model = users::ActiveModel {
178 uid: Set(uid),
179 username: Set(username.to_string()),
180 lower_name: Set(username.to_lowercase()),
181 display_name: Set(username.to_string()),
182 avatar: Set(String::new()),
183 is_active: Set(true),
184 is_admin: Set(false),
185 is_restricted: Set(false),
186 prohibit_login: Set(false),
187 visibility: Set(users::ProfileVisibility::Public),
188 bot: Set(false),
189 system: Set(false),
190 totp_enabled: Set(false),
191 passwd_hash: Set(passwd_hash.map(|s| s.to_string())),
192 version: Set(1),
193 created_at: Set(now()),
194 updated_at: Set(now()),
195 ..Default::default()
196 };
197 model.insert(db).await.expect("insert user");
198 }
199
200 fn hash_for(password: &str) -> String {
201 use argon2::password_hash::rand_core::OsRng;
202 use argon2::password_hash::{PasswordHasher, SaltString};
203 let salt = SaltString::generate(&mut OsRng);
204 argon2::Argon2::default()
205 .hash_password(password.as_bytes(), &salt)
206 .expect("hash password")
207 .to_string()
208 }
209
210 fn actor_for_uid(uid: Uuid) -> ActorContext {
211 ActorContext {
212 user_id: Some(uid),
213 scopes: Default::default(),
214 is_site_admin: false,
215 channel_role: None,
216 space_role: None,
217 }
218 }
219
220 async fn insert_space(
221 db: &impl ConnectionTrait,
222 id: Uuid,
223 lower_name: &str,
224 owner_id: Uuid,
225 is_public: bool,
226 ) {
227 let model = spaces::ActiveModel {
228 id: Set(id),
229 lower_name: Set(lower_name.to_string()),
230 name: Set(lower_name.to_string()),
231 owner_id: Set(owner_id),
232 public: Set(is_public),
233 is_active: Set(true),
234 allow_join: Set(true),
235 allow_invite: Set(true),
236 member_count: Set(0),
237 allow_join_level: Set(spaces::AllowJoinLevel::None),
238 member_must_enable_2fa: Set(false),
239 created_at: Set(now()),
240 updated_at: Set(now()),
241 ..Default::default()
242 };
243 model.insert(db).await.expect("insert space");
244 }
245
246 async fn insert_channel(
247 db: &impl ConnectionTrait,
248 id: Uuid,
249 space_id: Uuid,
250 name: &str,
251 created_by: Uuid,
252 ) {
253 let model = channels::ActiveModel {
254 id: Set(id),
255 space_id: Set(Some(space_id)),
256 name: Set(name.to_string()),
257 position: Set(0),
258 is_archived: Set(false),
259 created_at: Set(now()),
260 updated_at: Set(now()),
261 created_by: Set(created_by),
262 ..Default::default()
263 };
264 model.insert(db).await.expect("insert channel");
265 }
266
267 async fn insert_repo(
268 db: &impl ConnectionTrait,
269 id: Uuid,
270 channel_id: Uuid,
271 name: &str,
272 is_private: bool,
273 ) {
274 let model = repos::ActiveModel {
275 id: Set(id),
276 channel_id: Set(channel_id),
277 lower_name: Set(name.to_lowercase()),
278 name: Set(name.to_string()),
279 default_branch: Set("main".to_string()),
280 nums_watch: Set(0),
281 nums_star: Set(0),
282 nums_pr: Set(0),
283 nums_commits: Set(0),
284 nums_branches: Set(0),
285 nums_tags: Set(0),
286 enable_lfs: Set(true),
287 is_private: Set(is_private),
288 is_archived: Set(false),
289 size: Set(0),
290 git_size: Set(0),
291 lfs_size: Set(0),
292 allow_rebase: Set(true),
293 allow_rebase_explicit: Set(true),
294 allow_squash_merge: Set(true),
295 allow_merge_commits: Set(true),
296 created_at: Set(now()),
297 object_format: Set("sha1".to_string()),
298 ..Default::default()
299 };
300 model.insert(db).await.expect("insert repo");
301 }
302
303 async fn insert_collaborator(
304 db: &impl ConnectionTrait,
305 repo_id: Uuid,
306 user_id: Uuid,
307 mode: repo_collaborations::CollaborationMode,
308 ) {
309 let model = repo_collaborations::ActiveModel {
310 id: Set(Uuid::now_v7()),
311 repo_id: Set(repo_id),
312 user_id: Set(user_id),
313 mode: Set(mode),
314 created_at: Set(now()),
315 updated_at: Set(now()),
316 };
317 model.insert(db).await.expect("insert collaborator");
318 }
319
320 async fn make_service() -> (GitAuthService, DatabaseConnection) {
321 let conn = crate::testing::setup_db().await;
322 let db = Db::from_connection(conn.clone());
323 let jwt = JwtState::new(
324 conn.clone(),
325 JwtSettings::default(),
326 Arc::new(RwLock::new(SigningKeyCache::new())),
327 );
328 let svc = GitAuthService::new(db, None, jwt);
329 (svc, conn)
330 }
331
332 #[tokio::test]
333 async fn authenticate_ok_with_password() {
334 let (svc, conn) = make_service().await;
335 let uid = Uuid::now_v7();
336 let hash = hash_for("secret");
337 insert_user(&conn, uid, "alice", Some(hash.as_ref())).await;
338 let auth = svc.authenticate("Alice", "secret").await.expect("auth");
339 assert_eq!(auth.user_id, uid);
340 assert!(auth.actor.is_authenticated());
341 assert!(!auth.actor.is_site_admin);
342 }
343
344 #[tokio::test]
345 async fn authenticate_rejects_wrong_password() {
346 let (svc, conn) = make_service().await;
347 let hash = hash_for("secret");
348 insert_user(&conn, Uuid::now_v7(), "alice", Some(hash.as_ref())).await;
349 let err = svc
350 .authenticate("alice", "wrong")
351 .await
352 .expect_err("should fail");
353 assert!(matches!(err, GitAuthError::Unauthorized(_)));
354 }
355
356 #[tokio::test]
357 async fn authenticate_rejects_unknown_user() {
358 let (svc, _conn) = make_service().await;
359 let err = svc
360 .authenticate("nobody", "x")
361 .await
362 .expect_err("should fail");
363 assert!(matches!(err, GitAuthError::Unauthorized(_)));
364 }
365
366 #[tokio::test]
367 async fn resolve_repo_ok_and_not_found() {
368 let (svc, conn) = make_service().await;
369 let owner = Uuid::now_v7();
370 let space_id = Uuid::now_v7();
371 let channel_id = Uuid::now_v7();
372 let repo_id = Uuid::now_v7();
373 insert_user(&conn, owner, "owner", None).await;
374 insert_space(&conn, space_id, "acme", owner, true).await;
375 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
376 insert_repo(&conn, repo_id, channel_id, "widget", false).await;
377
378 let resolved = svc.resolve_repo("acme", "widget").await.expect("resolve");
379 assert_eq!(resolved.repo.id, repo_id);
380 assert_eq!(resolved.channel.id, channel_id);
381 assert_eq!(resolved.space.id, space_id);
382
383 let err = svc
384 .resolve_repo("acme", "missing")
385 .await
386 .expect_err("missing");
387 assert!(matches!(err, GitAuthError::NotFound(_)));
388 let err = svc
389 .resolve_repo("nosuch", "widget")
390 .await
391 .expect_err("no space");
392 assert!(matches!(err, GitAuthError::NotFound(_)));
393 }
394
395 #[tokio::test]
396 async fn authorize_public_repo_anonymous_read() {
397 let (svc, conn) = make_service().await;
398 let owner = Uuid::now_v7();
399 let space_id = Uuid::now_v7();
400 let channel_id = Uuid::now_v7();
401 let repo_id = Uuid::now_v7();
402 insert_user(&conn, owner, "owner", None).await;
403 insert_space(&conn, space_id, "acme", owner, true).await;
404 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
405 insert_repo(&conn, repo_id, channel_id, "public", false).await;
406
407 let actor = ActorContext::anonymous();
408 svc.authorize("acme", "public", &actor, RepoAccess::Read)
409 .await
410 .expect("anonymous read public");
411 }
412
413 #[tokio::test]
414 async fn authorize_private_repo_anonymous_rejected() {
415 let (svc, conn) = make_service().await;
416 let owner = Uuid::now_v7();
417 let space_id = Uuid::now_v7();
418 let channel_id = Uuid::now_v7();
419 insert_user(&conn, owner, "owner", None).await;
420 insert_space(&conn, space_id, "acme", owner, true).await;
421 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
422 insert_repo(&conn, Uuid::now_v7(), channel_id, "secret", true).await;
423
424 let actor = ActorContext::anonymous();
425 let err = svc
426 .authorize("acme", "secret", &actor, RepoAccess::Read)
427 .await
428 .expect_err("denied");
429 assert!(matches!(err, GitAuthError::Unauthorized(_)));
430 }
431
432 #[tokio::test]
433 async fn authorize_collaborator_write() {
434 let (svc, conn) = make_service().await;
435 let owner = Uuid::now_v7();
436 let collab = Uuid::now_v7();
437 let space_id = Uuid::now_v7();
438 let channel_id = Uuid::now_v7();
439 let repo_id = Uuid::now_v7();
440 insert_user(&conn, owner, "owner", None).await;
441 insert_user(&conn, collab, "bob", None).await;
442 insert_space(&conn, space_id, "acme", owner, true).await;
443 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
444 insert_repo(&conn, repo_id, channel_id, "widget", true).await;
445 insert_collaborator(
446 &conn,
447 repo_id,
448 collab,
449 repo_collaborations::CollaborationMode::Write,
450 )
451 .await;
452
453 let actor = actor_for_uid(collab);
454 svc.authorize("acme", "widget", &actor, RepoAccess::Write)
455 .await
456 .expect("collab write");
457 let err = svc
458 .authorize("acme", "widget", &actor, RepoAccess::Admin)
459 .await
460 .expect_err("no admin");
461 assert!(matches!(err, GitAuthError::Forbidden(_)));
462 }
463
464 #[tokio::test]
465 async fn authorize_space_owner_admin() {
466 let (svc, conn) = make_service().await;
467 let owner = Uuid::now_v7();
468 let space_id = Uuid::now_v7();
469 let channel_id = Uuid::now_v7();
470 insert_user(&conn, owner, "owner", None).await;
471 insert_space(&conn, space_id, "acme", owner, true).await;
472 insert_channel(&conn, channel_id, space_id, "dev", owner).await;
473 insert_repo(&conn, Uuid::now_v7(), channel_id, "widget", true).await;
474
475 let actor = actor_for_uid(owner);
476 svc.authorize("acme", "widget", &actor, RepoAccess::Admin)
477 .await
478 .expect("space owner admin");
479 }
480}
Modified dvcs/gitlfs/Cargo.toml dvcs/gitlfs/Cargo.toml +0 −5 View dvcs/gitlfs/Cargo.toml
@@ -22,8 +22,3 @@
2222metrics.workspace = true
2323object_store.workspace = true
2424url.workspace = true
25
26[dev-dependencies]
27config.workspace = true
28tokio.workspace = true
29tempfile.workspace = true
No newline at end of file
Modified dvcs/gitlfs/oid.rs dvcs/gitlfs/oid.rs +0 −39 View dvcs/gitlfs/oid.rs
@@ -50,42 +50,3 @@
5050 &self.0
5151 }
5252}
53
54#[cfg(test)]
55mod tests {
56 use super::*;
57
58 #[test]
59 fn oid_new_accepts_64_hex() {
60 let hex = "0123456789abcdef".repeat(4);
61 assert!(LfsOid::new(&hex).is_ok());
62 }
63
64 #[test]
65 fn oid_new_normalizes_uppercase() {
66 let oid = LfsOid::new(&"A".repeat(64)).unwrap();
67 assert_eq!(oid.as_str(), &"a".repeat(64));
68 }
69
70 #[test]
71 fn oid_new_rejects_bad_length() {
72 assert!(LfsOid::new("").is_err());
73 assert!(LfsOid::new("abc").is_err());
74 assert!(LfsOid::new(&"a".repeat(63)).is_err());
75 assert!(LfsOid::new(&"a".repeat(65)).is_err());
76 }
77
78 #[test]
79 fn oid_new_rejects_non_hex() {
80 assert!(LfsOid::new(&"z".repeat(64)).is_err());
81 }
82
83 #[test]
84 fn oid_compute_matches_sha256() {
85 let oid = LfsOid::compute(b"hello\n");
86 assert_eq!(
87 oid.as_str(),
88 "5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"
89 );
90 }
91}
Modified dvcs/gitlfs/store.rs dvcs/gitlfs/store.rs +0 −143 View dvcs/gitlfs/store.rs
@@ -280,146 +280,3 @@
280280 result
281281 }
282282}
283
284#[cfg(test)]
285mod tests {
286 use super::*;
287 use config::storage::{LocalStorageSettings, StorageSettings};
288
289 fn make_store() -> (tempfile::TempDir, DefaultLfsStore) {
290 let dir = tempfile::tempdir().expect("tempdir");
291 let mut settings = StorageSettings::default();
292 settings.set_local(
293 LocalStorageSettings::builder()
294 .root_dir(dir.path().join("store"))
295 .build(),
296 );
297 let storage = storage::repo::from_config(&settings).expect("storage");
298 (dir, DefaultLfsStore::new(storage, "lfs"))
299 }
300
301 #[tokio::test]
302 async fn put_get_roundtrip() {
303 let (_dir, store) = make_store();
304 let data = Bytes::from_static(b"hello lfs");
305 let oid = LfsOid::compute(&data);
306 store.put_object(&oid, data.clone()).await.unwrap();
307 assert!(store.has_object(&oid).await.unwrap());
308 assert_eq!(store.object_size(&oid).await.unwrap(), data.len() as u64);
309 assert_eq!(store.get_object(&oid).await.unwrap(), data);
310 assert_eq!(store.list_oids().await.unwrap(), vec![oid.clone()]);
311 store.delete_object(&oid).await.unwrap();
312 assert!(!store.has_object(&oid).await.unwrap());
313 }
314
315 #[tokio::test]
316 async fn put_rejects_hash_mismatch() {
317 let (_dir, store) = make_store();
318 let oid = LfsOid::new(&"0".repeat(64)).unwrap();
319 let err = store
320 .put_object(&oid, Bytes::from_static(b"wrong content"))
321 .await
322 .unwrap_err();
323 assert!(matches!(err, LfsError::HashMismatch { .. }));
324 assert!(!store.has_object(&oid).await.unwrap());
325 }
326
327 #[tokio::test]
328 async fn cleanup_removes_unkept_only() {
329 let (_dir, store) = make_store();
330 let a = Bytes::from_static(b"a");
331 let b = Bytes::from_static(b"b");
332 let oid_a = LfsOid::compute(&a);
333 let oid_b = LfsOid::compute(&b);
334 store.put_object(&oid_a, a).await.unwrap();
335 store.put_object(&oid_b, b).await.unwrap();
336
337 let mut keep = HashSet::new();
338 keep.insert(oid_a.clone());
339 assert_eq!(store.cleanup(&keep).await.unwrap(), 1);
340 assert!(store.has_object(&oid_a).await.unwrap());
341 assert!(!store.has_object(&oid_b).await.unwrap());
342 }
343
344 #[tokio::test]
345 async fn get_missing_returns_error() {
346 let (_dir, store) = make_store();
347 let oid = LfsOid::new(&"0".repeat(64)).unwrap();
348 assert!(store.get_object(&oid).await.is_err());
349 assert!(!store.has_object(&oid).await.unwrap());
350 }
351
352 #[tokio::test]
353 async fn presign_unsupported_on_local() {
354 let (_dir, store) = make_store();
355 let oid = LfsOid::new(&"0".repeat(64)).unwrap();
356 let err = store
357 .presign_upload(&oid, Duration::from_secs(60))
358 .await
359 .unwrap_err();
360 assert!(matches!(
361 err,
362 LfsError::Storage(storage::StorageError::Unsupported(_))
363 ));
364 }
365
366 #[tokio::test]
367 async fn put_object_stream_roundtrip() {
368 let (_dir, store) = make_store();
369 let data = Bytes::from_static(b"hello streaming lfs");
370 let oid = LfsOid::compute(&data);
371 let stream = futures::stream::iter(vec![
372 Ok(Bytes::from_static(b"hello ")),
373 Ok(Bytes::from_static(b"streaming lfs")),
374 ]);
375 store.put_object_stream(&oid, stream.boxed()).await.unwrap();
376
377 let mut got = store.get_object_stream(&oid).await.unwrap();
378 let mut collected = Vec::new();
379 while let Some(chunk) = got.next().await {
380 collected.extend_from_slice(&chunk.unwrap());
381 }
382 assert_eq!(collected, data.to_vec());
383 }
384
385 #[tokio::test]
386 async fn put_object_stream_rejects_hash_mismatch() {
387 let (_dir, store) = make_store();
388 let oid = LfsOid::new(&"0".repeat(64)).unwrap();
389 let stream = futures::stream::iter(vec![Ok(Bytes::from_static(b"whatever"))]);
390 let err = store
391 .put_object_stream(&oid, stream.boxed())
392 .await
393 .unwrap_err();
394 assert!(matches!(err, LfsError::HashMismatch { .. }));
395 assert!(!store.has_object(&oid).await.unwrap());
396 }
397
398 #[tokio::test]
399 async fn put_object_stream_large() {
400 let (_dir, store) = make_store();
401 let data = vec![0xabu8; 6 * 1024 * 1024];
402 let oid = LfsOid::compute(&data);
403 let stream = futures::stream::iter(
404 data.chunks(1024 * 1024)
405 .map(|c| Ok(Bytes::copy_from_slice(c)))
406 .collect::<Vec<_>>(),
407 );
408 store.put_object_stream(&oid, stream.boxed()).await.unwrap();
409 assert_eq!(store.object_size(&oid).await.unwrap(), data.len() as u64);
410
411 let mut got = store.get_object_stream(&oid).await.unwrap();
412 let mut collected = Vec::new();
413 while let Some(chunk) = got.next().await {
414 collected.extend_from_slice(&chunk.unwrap());
415 }
416 assert_eq!(collected, data);
417 }
418
419 #[tokio::test]
420 async fn get_object_stream_missing() {
421 let (_dir, store) = make_store();
422 let oid = LfsOid::new(&"0".repeat(64)).unwrap();
423 assert!(store.get_object_stream(&oid).await.is_err());
424 }
425}
Modified dvcs/gitsmart/handler/dumb.rs dvcs/gitsmart/handler/dumb.rs +0 −37 View dvcs/gitsmart/handler/dumb.rs
@@ -88,40 +88,3 @@
8888 "application/octet-stream"
8989 }
9090}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use std::fs::{self, File};
96
97 #[test]
98 fn rejects_parent_traversal() {
99 let root = tempfile::tempdir().expect("tmpdir");
100 let err = resolve_within_root(root.path(), "../secret").expect_err("traversal");
101 assert!(matches!(err, SmartError::Forbidden(_)));
102 }
103
104 #[test]
105 fn resolves_normal_file() {
106 let root = tempfile::tempdir().expect("tmpdir");
107 fs::create_dir_all(root.path().join("objects/ab")).expect("mkdir");
108 File::create(root.path().join("objects/ab/abcdef")).expect("file");
109 let out = resolve_within_root(root.path(), "objects/ab/abcdef").expect("resolve");
110 assert_eq!(
111 out,
112 root.path()
113 .join("objects")
114 .join("ab")
115 .join("abcdef")
116 .canonicalize()
117 .expect("canon")
118 );
119 }
120
121 #[test]
122 fn rejects_absolute_path() {
123 let root = tempfile::tempdir().expect("tmpdir");
124 let err = resolve_within_root(root.path(), "/etc/passwd").expect_err("abs");
125 assert!(matches!(err, SmartError::Forbidden(_)));
126 }
127}
Modified dvcs/gitsmart/protocol/pktline.rs dvcs/gitsmart/protocol/pktline.rs +0 −39 View dvcs/gitsmart/protocol/pktline.rs
@@ -53,42 +53,3 @@
5353 }
5454 Ok(lines)
5555}
56
57#[cfg(test)]
58mod tests {
59 use super::*;
60
61 #[test]
62 fn roundtrip_lineless() {
63 let encoded = encode_line(b"hello");
64 assert_eq!(b"0009hello", encoded.as_slice());
65 let lines = decode_stream(&encoded).expect("decode");
66 assert_eq!(
67 vec![b"hello".as_slice()],
68 lines.iter().map(|l| l.as_slice()).collect::<Vec<_>>()
69 );
70 }
71
72 #[test]
73 fn flush_terminates() {
74 let mut buf = encode_line(b"a");
75 buf.extend_from_slice(&flush_pkt());
76 buf.extend_from_slice(&encode_line(b"ignored"));
77 let lines = decode_stream(&buf).expect("decode");
78 assert_eq!(1, lines.len());
79 }
80
81 #[test]
82 fn header_has_service_and_flush() {
83 let encoded = encode_header("git-upload-pack");
84 let lines = decode_stream(&encoded).expect("decode");
85 assert_eq!(1, lines.len());
86 assert_eq!(b"# service=git-upload-pack\n", lines[0].as_slice());
87 }
88
89 #[test]
90 fn rejects_bad_length() {
91 let err = decode_stream(b"zzzz").expect_err("should fail");
92 assert!(matches!(err, SmartError::BadRequest(_)));
93 }
94}
Modified dvcs/gitsmart/protocol/service.rs dvcs/gitsmart/protocol/service.rs +0 −34 View dvcs/gitsmart/protocol/service.rs
@@ -60,37 +60,3 @@
6060 f.write_str(self.name())
6161 }
6262}
63
64#[cfg(test)]
65mod tests {
66 use super::*;
67
68 #[test]
69 fn parses_known_services() {
70 assert_eq!(
71 GitService::from_name("git-upload-pack").unwrap(),
72 GitService::UploadPack
73 );
74 assert_eq!(
75 GitService::from_name("git-receive-pack").unwrap(),
76 GitService::ReceivePack
77 );
78 }
79
80 #[test]
81 fn rejects_unknown_service() {
82 assert!(GitService::from_name("git-daemon").is_err());
83 }
84
85 #[test]
86 fn content_types() {
87 assert_eq!(
88 GitService::UploadPack.advertisement_content_type(),
89 "application/x-git-upload-pack-advertisement"
90 );
91 assert_eq!(
92 GitService::ReceivePack.result_content_type(),
93 "application/x-git-receive-pack-result"
94 );
95 }
96}
Modified dvcs/gitsmart/Cargo.toml dvcs/gitsmart/Cargo.toml +0 −9 View dvcs/gitsmart/Cargo.toml
@@ -38,12 +38,3 @@
3838tokio-util.workspace = true
3939tracing.workspace = true
4040uuid.workspace = true
41
42[dev-dependencies]
43argon2.workspace = true
44db.workspace = true
45jwt.workspace = true
46password-hash.workspace = true
47reqwest.workspace = true
48tempfile.workspace = true
49time.workspace = true
No newline at end of file
Modified dvcs/gitsmart/push.rs dvcs/gitsmart/push.rs +0 −124 View dvcs/gitsmart/push.rs
@@ -200,127 +200,3 @@
200200 let _ = self.handle.await;
201201 }
202202}
203
204#[cfg(test)]
205mod tests {
206 use super::*;
207 use std::collections::HashMap;
208 use std::sync::Mutex;
209 use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
210
211 #[derive(Default)]
212 struct FakeBackend {
213 locks: Arc<Mutex<HashMap<String, Vec<u8>>>>,
214 queue: Arc<Mutex<HashMap<String, Vec<Vec<u8>>>>>,
215 }
216
217 #[async_trait]
218 impl PushOperations for FakeBackend {
219 async fn acquire(
220 &self,
221 key: &str,
222 member: &[u8],
223 _ttl_secs: u64,
224 ) -> Result<bool, SmartError> {
225 let mut locks = self.locks.lock().unwrap();
226 if locks.contains_key(key) {
227 return Ok(false);
228 }
229 locks.insert(key.to_string(), member.to_vec());
230 Ok(true)
231 }
232
233 async fn renew(&self, key: &str, token: &[u8], _ttl_secs: u64) -> Result<bool, SmartError> {
234 let locks = self.locks.lock().unwrap();
235 Ok(locks.get(key) == Some(&token.to_vec()))
236 }
237
238 async fn release(&self, key: &str, token: &[u8]) -> Result<(), SmartError> {
239 let mut locks = self.locks.lock().unwrap();
240 if locks.get(key) == Some(&token.to_vec()) {
241 locks.remove(key);
242 }
243 Ok(())
244 }
245
246 async fn enqueue(&self, key: &str, member: &[u8]) -> Result<(), SmartError> {
247 self.queue
248 .lock()
249 .unwrap()
250 .entry(key.to_string())
251 .or_default()
252 .push(member.to_vec());
253 Ok(())
254 }
255
256 async fn dequeue(&self, key: &str, member: &[u8]) -> Result<(), SmartError> {
257 if let Some(queue) = self.queue.lock().unwrap().get_mut(key) {
258 queue.retain(|m| m != &member.to_vec());
259 }
260 Ok(())
261 }
262 }
263
264 #[tokio::test]
265 async fn noop_backend_passes_through() {
266 let coordinator = PushCoordinator::new(Arc::new(NoopPushBackend));
267 let result: SmartResult<i32> = coordinator.run("repo", async { Ok(42) }).await;
268 assert_eq!(result.unwrap(), 42);
269 }
270
271 #[tokio::test]
272 async fn concurrent_pushes_serialize() {
273 let coordinator = PushCoordinator::new(Arc::new(FakeBackend::default()));
274 let running = Arc::new(AtomicUsize::new(0));
275 let max = Arc::new(AtomicUsize::new(0));
276
277 let run_one = |coordinator: PushCoordinator,
278 running: Arc<AtomicUsize>,
279 max: Arc<AtomicUsize>| {
280 async move {
281 coordinator
282 .run("repo", async move {
283 let current = running.fetch_add(1, AtomicOrdering::SeqCst) + 1;
284 max.fetch_max(current, AtomicOrdering::SeqCst);
285 tokio::time::sleep(Duration::from_millis(20)).await;
286 running.fetch_sub(1, AtomicOrdering::SeqCst);
287 Ok::<(), SmartError>(())
288 })
289 .await
290 .unwrap();
291 }
292 };
293
294 tokio::join!(
295 run_one(coordinator.clone(), running.clone(), max.clone()),
296 run_one(coordinator.clone(), running.clone(), max.clone()),
297 run_one(coordinator.clone(), running.clone(), max.clone()),
298 );
299
300 assert_eq!(max.load(AtomicOrdering::SeqCst), 1);
301 }
302
303 #[tokio::test]
304 async fn waiter_acquires_after_release() {
305 let backend = Arc::new(FakeBackend::default());
306 let coordinator = PushCoordinator::new(backend.clone());
307
308 backend.acquire("repo", b"stale-holder", 60).await.unwrap();
309
310 let coordinator_clone = coordinator.clone();
311 let backend_clone = backend.clone();
312 let waiter = tokio::spawn(async move {
313 let result: SmartResult<i32> = coordinator_clone.run("repo", async { Ok(7) }).await;
314 result.unwrap()
315 });
316
317 tokio::time::sleep(Duration::from_millis(50)).await;
318 backend_clone
319 .release("repo", b"stale-holder")
320 .await
321 .unwrap();
322
323 assert_eq!(waiter.await.unwrap(), 7);
324 assert!(!backend.locks.lock().unwrap().contains_key("repo"));
325 }
326}
Modified dvcs/gitssh/Cargo.toml dvcs/gitssh/Cargo.toml +0 −12 View dvcs/gitssh/Cargo.toml
@@ -31,15 +31,3 @@
3131queue.workspace = true
3232repo = { path = "../../lib/repo" }
3333sea-orm.workspace = true
34[dev-dependencies]
35config.workspace = true
36rand.workspace = true
37tempfile.workspace = true
38time.workspace = true
39db.workspace = true
40jwt.workspace = true
41argon2.workspace = true
42password-hash.workspace = true
43storage.workspace = true
44sha2.workspace = true
45base64.workspace = true
No newline at end of file
Modified dvcs/gitssh/git_service.rs dvcs/gitssh/git_service.rs +0 −80 View dvcs/gitssh/git_service.rs
@@ -101,83 +101,3 @@
101101 .env("GIT_CONFIG_SYSTEM", "/dev/null");
102102 cmd
103103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn parses_valid_commands() {
111 assert_eq!(
112 parse_git_command("git-upload-pack 'acme/widget.git'"),
113 Some((GitService::UploadPack, "'acme/widget.git'"))
114 );
115 assert_eq!(
116 parse_git_command("git-receive-pack 'acme/widget.git'"),
117 Some((GitService::ReceivePack, "'acme/widget.git'"))
118 );
119 assert_eq!(
120 parse_git_command("git-upload-archive 'acme/widget.git'"),
121 Some((GitService::UploadArchive, "'acme/widget.git'"))
122 );
123 }
124
125 #[test]
126 fn rejects_invalid_commands() {
127 assert_eq!(parse_git_command(""), None);
128 assert_eq!(parse_git_command("ls -la"), None);
129 assert_eq!(parse_git_command("git-upload-pack"), None);
130 assert_eq!(parse_git_command("git-upload-packx 'a/b.git'"), None);
131 assert_eq!(parse_git_command("rm -rf /"), None);
132 assert_eq!(parse_git_command("git-upload-pack; rm -rf /"), None);
133 assert_eq!(
134 parse_git_command("GIT_SSH=1 git-upload-pack 'a/b.git'"),
135 None
136 );
137 }
138
139 #[test]
140 fn parses_valid_paths() {
141 assert_eq!(parse_repo_path("acme/widget.git"), Some(("acme", "widget")));
142 assert_eq!(parse_repo_path("acme/widget"), Some(("acme", "widget")));
143 assert_eq!(
144 parse_repo_path("'acme/widget.git'"),
145 Some(("acme", "widget"))
146 );
147 assert_eq!(
148 parse_repo_path("/acme/widget.git/"),
149 Some(("acme", "widget"))
150 );
151 assert_eq!(parse_repo_path("a-b/c_d.git"), Some(("a-b", "c_d")));
152 }
153
154 #[test]
155 fn rejects_invalid_paths() {
156 assert_eq!(parse_repo_path(""), None);
157 assert_eq!(parse_repo_path("."), None);
158 assert_eq!(parse_repo_path(".."), None);
159 assert_eq!(parse_repo_path("../etc/passwd"), None);
160 assert_eq!(parse_repo_path("a/.."), None);
161 assert_eq!(parse_repo_path("a/b/c"), Some(("a", "b/c")));
162 assert_eq!(parse_repo_path("Acme/Widget"), None);
163 assert_eq!(parse_repo_path("a b/c"), None);
164 assert_eq!(parse_repo_path("a/b; rm -rf"), None);
165 assert_eq!(parse_repo_path("a$(id)/c"), None);
166 assert_eq!(parse_repo_path("a/"), None);
167 assert_eq!(parse_repo_path("/a"), None);
168 assert_eq!(parse_repo_path("a/../c"), None);
169 assert_eq!(parse_repo_path("a\u{0}/c"), None);
170 assert_eq!(
171 parse_repo_path(&format!("a/{}", "b".repeat(MAX_REPO_PATH_LEN))),
172 None
173 );
174 }
175
176 #[test]
177 fn sanitizes_log_input() {
178 assert_eq!(sanitize_for_log("hello"), "hello");
179 assert_eq!(sanitize_for_log("a\u{0}b"), "a?b");
180 assert_eq!(sanitize_for_log("a\nb"), "a?b");
181 assert_eq!(sanitize_for_log(&"x".repeat(1000)).len(), 256);
182 }
183}
Modified dvcs/gitsync/Cargo.toml dvcs/gitsync/Cargo.toml +0 −3 View dvcs/gitsync/Cargo.toml
@@ -32,6 +32,3 @@
3232tokio.workspace = true
3333tracing.workspace = true
3434uuid.workspace = true
35
36[dev-dependencies]
37tempfile.workspace = true
No newline at end of file
Modified dvcs/gitsync/event.rs dvcs/gitsync/event.rs +0 −30 View dvcs/gitsync/event.rs
@@ -89,33 +89,3 @@
8989 .publish_raw(&settings.dlq_topic, Some(task.repo_id.as_bytes()), &payload)
9090 .await
9191}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96
97 const ZERO: &str = "0000000000000000000000000000000000000000";
98 const AAA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
99 const BBB: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
100
101 #[test]
102 fn create_when_old_is_zero() {
103 let u = PostReceiveRefUpdate::new("refs/heads/main", ZERO, AAA);
104 assert!(u.is_create());
105 assert!(!u.is_delete());
106 }
107
108 #[test]
109 fn delete_when_new_is_zero() {
110 let u = PostReceiveRefUpdate::new("refs/heads/main", AAA, ZERO);
111 assert!(u.is_delete());
112 assert!(!u.is_create());
113 }
114
115 #[test]
116 fn update_when_both_nonzero() {
117 let u = PostReceiveRefUpdate::new("refs/heads/main", AAA, BBB);
118 assert!(!u.is_create());
119 assert!(!u.is_delete());
120 }
121}
Modified dvcs/gitsync/post_receive.rs dvcs/gitsync/post_receive.rs +0 −164 View dvcs/gitsync/post_receive.rs
@@ -96,167 +96,3 @@
9696 metrics::counter!("gitsync.enqueue", "result" => "ok").increment(1);
9797 Ok(task.refs.len())
9898}
99
100#[cfg(test)]
101mod tests {
102 use super::*;
103
104 fn temp_bare() -> tempfile::TempDir {
105 tempfile::TempDir::new().expect("create temp dir")
106 }
107
108 #[test]
109 fn hook_install_is_idempotent() {
110 let dir = temp_bare();
111 assert!(ensure_post_receive_hook(dir.path()).is_ok());
112 let path = hook_path(dir.path());
113 assert!(path.exists());
114 let first = fs::read_to_string(&path).unwrap();
115 assert!(first.contains(CAPTURE_FILE));
116 assert_ne!(fs::metadata(&path).unwrap().permissions().mode() & 0o111, 0);
117
118 assert!(ensure_post_receive_hook(dir.path()).is_ok());
119 let second = fs::read_to_string(&path).unwrap();
120 assert_eq!(first, second);
121 }
122
123 #[test]
124 fn hook_never_overwrites_existing_hook() {
125 let dir = temp_bare();
126 let path = hook_path(dir.path());
127 fs::create_dir_all(path.parent().unwrap()).unwrap();
128 fs::write(&path, "#!/bin/sh\nexit 0\n").unwrap();
129 assert!(ensure_post_receive_hook(dir.path()).is_ok());
130 assert_eq!(
131 fs::read_to_string(&path).unwrap(),
132 "#!/bin/sh\nexit 0\n",
133 "existing hooks must be preserved"
134 );
135 }
136
137 #[test]
138 fn capture_roundtrip() {
139 let dir = temp_bare();
140 let bare = dir.path();
141 let capture = capture_path(bare);
142 fs::write(
143 &capture,
144 format!(
145 "{} {} refs/heads/main\n",
146 "0".repeat(HEX_OID_LEN),
147 "a".repeat(HEX_OID_LEN)
148 ),
149 )
150 .unwrap();
151 use std::io::Write as _;
152 fs::OpenOptions::new()
153 .append(true)
154 .open(&capture)
155 .unwrap()
156 .write_all(
157 format!(
158 "garbage line with !not-an-oid\n{} {} refs/tags/v1.0\n",
159 "b".repeat(HEX_OID_LEN),
160 "0".repeat(HEX_OID_LEN)
161 )
162 .as_bytes(),
163 )
164 .unwrap();
165
166 let updates = take_post_receive_capture(bare);
167 assert!(!capture.exists(), "capture file must be removed");
168 assert_eq!(updates.len(), 2, "malformed line must be skipped");
169 assert_eq!(updates[0].name, "refs/heads/main");
170 assert_eq!(updates[0].old_oid, "0".repeat(HEX_OID_LEN));
171 assert_eq!(updates[0].new_oid, "a".repeat(HEX_OID_LEN));
172 assert!(updates[1].is_delete(), "zero new_oid means delete");
173 }
174
175 #[test]
176 fn clear_is_idempotent() {
177 let dir = temp_bare();
178 fs::write(capture_path(dir.path()), "stale\n").unwrap();
179 assert!(clear_post_receive_capture(dir.path()).is_ok());
180 assert!(!capture_path(dir.path()).exists());
181 assert!(clear_post_receive_capture(dir.path()).is_ok());
182 }
183
184 #[test]
185 fn empty_capture_yields_nothing() {
186 let dir = temp_bare();
187 assert!(take_post_receive_capture(dir.path()).is_empty());
188 }
189
190 #[test]
191 fn real_git_push_captured_by_hook() {
192 let git = match which_git() {
193 Some(g) => g,
194 None => {
195 eprintln!("git not available; skipping real-push integration test");
196 return;
197 }
198 };
199 let dir = temp_bare();
200 let bare = dir.path();
201 git_run(&git, bare, &["init", "--bare", "."]).unwrap();
202 assert!(
203 prepare_post_receive(bare).is_ok(),
204 "hook install must succeed"
205 );
206 assert!(hook_path(bare).exists(), "hook file must exist");
207
208 let work = tempfile::tempdir().unwrap();
209 let work_path = work.path();
210 git_run(&git, work_path, &["init", "-q"]).unwrap();
211 git_run(&git, work_path, &["config", "user.email", "t@t"]).unwrap();
212 git_run(&git, work_path, &["config", "user.name", "t"]).unwrap();
213 fs::write(work_path.join("a.txt"), "hello\n").unwrap();
214 git_run(&git, work_path, &["add", "."]).unwrap();
215 git_run(&git, work_path, &["commit", "-q", "-m", "init"]).unwrap();
216 git_run(
217 &git,
218 work_path,
219 &["remote", "add", "origin", bare.to_str().unwrap()],
220 )
221 .unwrap();
222 git_run(
223 &git,
224 work_path,
225 &["push", "-q", "origin", "HEAD:refs/heads/main"],
226 )
227 .unwrap();
228
229 let updates = take_post_receive_capture(bare);
230 assert_eq!(updates.len(), 1, "one ref update expected");
231 let update = &updates[0];
232 assert_eq!(update.name, "refs/heads/main");
233 assert!(update.is_create(), "new branch push must be a create");
234 assert!(is_valid_oid(&update.new_oid), "new oid must be a real sha");
235 }
236
237 fn which_git() -> Option<std::path::PathBuf> {
238 let out = std::process::Command::new("git")
239 .arg("--version")
240 .output()
241 .ok()?;
242 out.status.success().then(|| "git".into())
243 }
244
245 fn git_run(git: &Path, cwd: &Path, args: &[&str]) -> io::Result<()> {
246 let out = std::process::Command::new(git)
247 .args(args)
248 .current_dir(cwd)
249 .stdout(std::process::Stdio::piped())
250 .stderr(std::process::Stdio::piped())
251 .output()?;
252 if out.status.success() {
253 Ok(())
254 } else {
255 Err(io::Error::other(format!(
256 "git {} failed: {}",
257 args.join(" "),
258 String::from_utf8_lossy(&out.stderr)
259 )))
260 }
261 }
262}
Modified lib/config/src/chat.rs lib/config/src/chat.rs +0 −43 View lib/config/src/chat.rs
@@ -61,46 +61,3 @@
6161 }
6262 }
6363}
64
65#[cfg(test)]
66mod tests {
67 use super::*;
68
69 #[test]
70 fn chat_settings_defaults() {
71 let s = AppChatSettings::default();
72 assert!(s.enabled);
73 assert_eq!(s.topic_prefix, "chat");
74 assert_eq!(s.room_group_id, "chat.room.v1");
75 assert_eq!(s.user_group_id, "chat.user.v1");
76 assert_eq!(s.user_view_group_id, "chat.user.view.v1");
77 assert_eq!(s.live_group_id, "chat.live.v1");
78 assert_eq!(s.dedup_ttl_seconds, 24 * 3600);
79 assert_eq!(s.idempotency_ttl_seconds, 24 * 3600);
80 assert_eq!(s.upload_ttl_seconds, 3600);
81 assert_eq!(s.presence_ttl_seconds, 120);
82 assert_eq!(s.unread_ttl_seconds, 30 * 24 * 3600);
83 }
84
85 #[test]
86 fn gateway_settings_defaults_sane() {
87 let s = GatewaySettings::default();
88 assert_eq!(s.ws_heartbeat_interval_seconds, 30);
89 assert_eq!(s.ws_idle_timeout_seconds, 120);
90 assert_eq!(s.ws_auth_timeout_seconds, 10);
91 assert_eq!(s.max_connections_per_user, 8);
92 assert_eq!(s.subscriber_id, "chat.gateway");
93 assert_eq!(s.topic_prefix, "chat");
94 assert_eq!(s.upload_presign_ttl_seconds, 900);
95 assert_eq!(s.typing_debounce_seconds, 2);
96 }
97
98 #[test]
99 fn gateway_settings_serde_roundtrip() {
100 let s = GatewaySettings::default();
101 let json = serde_json::to_string(&s).unwrap();
102 let back: GatewaySettings = serde_json::from_str(&json).unwrap();
103 assert_eq!(back.topic_prefix, s.topic_prefix);
104 assert_eq!(back.max_connections_per_user, s.max_connections_per_user);
105 }
106}
Modified lib/config/src/duration.rs lib/config/src/duration.rs +0 −66 View lib/config/src/duration.rs
@@ -108,69 +108,3 @@
108108 None => Ok(None),
109109 }
110110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use serde::Deserialize;
116
117 #[derive(Deserialize)]
118 struct TimeWrap {
119 #[serde(deserialize_with = "deserialize_time_duration")]
120 d: TimeDuration,
121 }
122
123 #[derive(Deserialize)]
124 struct StdWrap {
125 #[serde(deserialize_with = "deserialize_std_duration")]
126 d: StdDuration,
127 }
128
129 #[derive(Deserialize)]
130 struct OptStdWrap {
131 #[serde(default, deserialize_with = "deserialize_opt_std_duration")]
132 d: Option<StdDuration>,
133 }
134
135 #[test]
136 fn time_duration_from_bare_seconds() {
137 let w: TimeWrap = serde_json::from_str(r#"{"d":900}"#).unwrap();
138 assert_eq!(w.d, TimeDuration::seconds(900));
139 }
140
141 #[test]
142 fn time_duration_from_human_string() {
143 let w: TimeWrap = serde_json::from_str(r#"{"d":"1h30m"}"#).unwrap();
144 assert_eq!(w.d, TimeDuration::hours(1) + TimeDuration::minutes(30));
145 }
146
147 #[test]
148 fn time_duration_from_native_string() {
149 let w: TimeWrap = serde_json::from_str(r#"{"d":"900.000000000"}"#).unwrap();
150 assert_eq!(w.d, TimeDuration::seconds(900));
151 }
152
153 #[test]
154 fn std_duration_from_bare_seconds() {
155 let w: StdWrap = serde_json::from_str(r#"{"d":30}"#).unwrap();
156 assert_eq!(w.d, StdDuration::from_secs(30));
157 }
158
159 #[test]
160 fn std_duration_from_native_struct() {
161 let w: StdWrap = serde_json::from_str(r#"{"d":{"secs":30,"nanos":0}}"#).unwrap();
162 assert_eq!(w.d, StdDuration::from_secs(30));
163 }
164
165 #[test]
166 fn opt_std_duration_none_when_absent() {
167 let w: OptStdWrap = serde_json::from_str(r#"{}"#).unwrap();
168 assert_eq!(w.d, None);
169 }
170
171 #[test]
172 fn opt_std_duration_some_from_seconds() {
173 let w: OptStdWrap = serde_json::from_str(r#"{"d":300}"#).unwrap();
174 assert_eq!(w.d, Some(StdDuration::from_secs(300)));
175 }
176}
Modified lib/config/src/search.rs lib/config/src/search.rs +0 −66 View lib/config/src/search.rs
@@ -62,69 +62,3 @@
6262 Ok(())
6363 }
6464}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 #[test]
71 fn default_config_is_valid() {
72 assert!(AppSearchSettings::default().is_valid().is_ok());
73 }
74
75 #[test]
76 fn disabled_tolerates_empty_host() {
77 let cfg = AppSearchSettings {
78 enabled: false,
79 host: "".into(),
80 ..Default::default()
81 };
82 assert!(cfg.is_valid().is_ok());
83 }
84
85 #[test]
86 fn empty_host_rejected() {
87 let cfg = AppSearchSettings {
88 enabled: true,
89 host: "".into(),
90 ..Default::default()
91 };
92 assert!(cfg.is_valid().is_err());
93 }
94
95 #[test]
96 fn host_without_scheme_rejected() {
97 let cfg = AppSearchSettings {
98 host: "127.0.0.1:7700".into(),
99 ..Default::default()
100 };
101 assert!(cfg.is_valid().is_err());
102 }
103
104 #[test]
105 fn host_trailing_slash_rejected() {
106 let cfg = AppSearchSettings {
107 host: "http://127.0.0.1:7700/".into(),
108 ..Default::default()
109 };
110 assert!(cfg.is_valid().is_err());
111 }
112
113 #[test]
114 fn empty_api_key_rejected() {
115 let cfg = AppSearchSettings {
116 api_key: Some("".into()),
117 ..Default::default()
118 };
119 assert!(cfg.is_valid().is_err());
120 }
121
122 #[test]
123 fn zero_batch_size_rejected() {
124 let cfg = AppSearchSettings {
125 batch_size: 0,
126 ..Default::default()
127 };
128 assert!(cfg.is_valid().is_err());
129 }
130}
Modified lib/config/src/storage.rs lib/config/src/storage.rs +0 −36 View lib/config/src/storage.rs
@@ -73,39 +73,3 @@
7373 }
7474 }
7575}
76
77#[cfg(test)]
78mod tests {
79 use super::*;
80
81 #[test]
82 fn storage_settings_defaults() {
83 let settings = StorageSettings::default();
84 assert_eq!(*settings.get_backend(), BackendType::Local);
85 assert_eq!(
86 settings.get_local().get_root_dir(),
87 &PathBuf::from("./storage")
88 );
89 assert_eq!(settings.get_s3().get_region(), "us-east-1");
90 assert_eq!(settings.get_s3().get_bucket(), "");
91 assert!(!settings.get_s3().get_allow_http());
92 assert!(settings.get_s3().get_endpoint().is_none());
93 assert!(settings.get_s3().get_access_key_id().is_none());
94 assert!(settings.get_s3().get_secret_access_key().is_none());
95 assert!(settings.get_s3().get_prefix().is_none());
96 }
97
98 #[test]
99 fn storage_settings_deserialize() {
100 let json = r#"{"backend":"s3","s3":{"bucket":"my-bucket","region":"cn-north-1","endpoint":"http://127.0.0.1:9000","allow_http":true}}"#;
101 let settings: StorageSettings = serde_json::from_str(json).unwrap();
102 assert_eq!(*settings.get_backend(), BackendType::S3);
103 assert_eq!(settings.get_s3().get_bucket(), "my-bucket");
104 assert_eq!(settings.get_s3().get_region(), "cn-north-1");
105 assert!(settings.get_s3().get_allow_http());
106 assert_eq!(
107 settings.get_local().get_root_dir(),
108 &PathBuf::from("./storage")
109 );
110 }
111}
Modified lib/db/Cargo.toml lib/db/Cargo.toml +0 −4 View lib/db/Cargo.toml
@@ -21,7 +21,3 @@
2121log = { workspace = true }
2222tracing = { workspace = true }
2323thiserror = { workspace = true }
24
25[dev-dependencies]
26sea-orm = { workspace = true, features = ["mock"] }
27tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
Modified lib/db/conn.rs lib/db/conn.rs +0 −92 View lib/db/conn.rs
@@ -246,95 +246,3 @@
246246 error!(error = %error, "transaction_rollback_failed");
247247 }
248248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use config::db::{AppDatabaseConnConfig, AppDatabaseSettings};
254
255 fn conn_config(url: &str, max: u32, min: u32) -> AppDatabaseConnConfig {
256 AppDatabaseConnConfig {
257 url: url.to_string(),
258 max_connections: max,
259 min_connections: min,
260 ..AppDatabaseConnConfig::default()
261 }
262 }
263
264 fn settings(
265 primary: AppDatabaseConnConfig,
266 replicas: Vec<AppDatabaseConnConfig>,
267 total: u32,
268 ) -> AppDatabaseSettings {
269 AppDatabaseSettings {
270 primary,
271 replicas,
272 max_connections_total: total,
273 ..AppDatabaseSettings::default()
274 }
275 }
276
277 #[test]
278 fn validate_config_rejects_empty_url() {
279 let cfg = settings(conn_config("", 20, 2), vec![], 100);
280 let err = validate_config(&cfg).unwrap_err().to_string();
281 assert!(err.contains("url must be configured"), "{err}");
282 }
283
284 #[test]
285 fn validate_config_rejects_zero_max_connections() {
286 let cfg = settings(conn_config("postgres://db", 0, 0), vec![], 100);
287 let err = validate_config(&cfg).unwrap_err().to_string();
288 assert!(
289 err.contains("max_connections must be greater than zero"),
290 "{err}"
291 );
292 }
293
294 #[test]
295 fn validate_config_rejects_min_over_max() {
296 let cfg = settings(conn_config("postgres://db", 5, 10), vec![], 100);
297 let err = validate_config(&cfg).unwrap_err().to_string();
298 assert!(err.contains("min_connections exceeds"), "{err}");
299 }
300
301 #[test]
302 fn validate_config_rejects_total_overflow() {
303 let primary = conn_config("postgres://db", u32::MAX, 2);
304 let replica = conn_config("postgres://db2", u32::MAX, 2);
305 let cfg = settings(primary, vec![replica], u32::MAX);
306 let err = validate_config(&cfg).unwrap_err().to_string();
307 assert!(err.contains("overflows u32"), "{err}");
308 }
309
310 #[test]
311 fn validate_config_rejects_total_exceeding_budget() {
312 let primary = conn_config("postgres://db", 80, 2);
313 let replica = conn_config("postgres://db2", 30, 2);
314 let cfg = settings(primary, vec![replica], 100);
315 let err = validate_config(&cfg).unwrap_err().to_string();
316 assert!(err.contains("exceeding max_connections_total"), "{err}");
317 }
318
319 #[test]
320 fn validate_config_accepts_valid_configuration() {
321 let primary = conn_config("postgres://db", 20, 2);
322 let replica = conn_config("postgres://db2", 10, 2);
323 let cfg = settings(primary, vec![replica], 100);
324 assert!(validate_config(&cfg).is_ok());
325 }
326
327 #[test]
328 fn next_reader_index_round_robins() {
329 let counter = AtomicUsize::new(0);
330 let idxs: Vec<usize> = (0..6).map(|_| next_reader_index(3, &counter)).collect();
331 assert_eq!(idxs, vec![0, 1, 2, 0, 1, 2]);
332 }
333
334 #[test]
335 fn next_reader_index_single_pool_is_stable() {
336 let counter = AtomicUsize::new(0);
337 let idxs: Vec<usize> = (0..5).map(|_| next_reader_index(1, &counter)).collect();
338 assert_eq!(idxs, vec![0, 0, 0, 0, 0]);
339 }
340}
Modified lib/db/schema.rs lib/db/schema.rs +0 −85 View lib/db/schema.rs
@@ -15,88 +15,3 @@
1515
1616 Ok(())
1717}
18
19#[cfg(test)]
20mod tests {
21 use super::*;
22 use sea_orm::{Database, Statement};
23
24 async fn table_exists(db: &DatabaseConnection, name: &str) -> bool {
25 db.query_one_raw(Statement::from_sql_and_values(
26 DatabaseBackend::Sqlite,
27 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?",
28 [name.into()],
29 ))
30 .await
31 .unwrap()
32 .is_some()
33 }
34
35 #[tokio::test]
36 async fn registry_prefix_matches_module() {
37 let db = Database::connect("sqlite::memory:").await.unwrap();
38
39 db.get_schema_registry("db::channel::")
40 .sync(&db)
41 .await
42 .unwrap();
43 assert!(table_exists(&db, "channel").await);
44 assert!(table_exists(&db, "channel_overwrite_role").await);
45
46 db.get_schema_registry("db::nope::")
47 .sync(&db)
48 .await
49 .unwrap();
50 assert!(!table_exists(&db, "project").await);
51 }
52
53 #[tokio::test]
54 async fn entity_first_sync_converges_on_sqlite() {
55 use crate::{
56 channel::channels as channel_entity,
57 chat::{chat_reads as read_entity, chats as chat_entity},
58 issue::{issue_reactions as reaction_entity, issue_watch as watch_entity},
59 project::project as project_entity,
60 user::users as user_entity,
61 };
62
63 let db = Database::connect("sqlite::memory:").await.unwrap();
64
65 db.get_schema_builder()
66 .register(user_entity::Entity)
67 .register(channel_entity::Entity)
68 .register(project_entity::Entity)
69 .register(chat_entity::Entity)
70 .register(read_entity::Entity)
71 .register(watch_entity::Entity)
72 .register(reaction_entity::Entity)
73 .sync(&db)
74 .await
75 .unwrap();
76 db.get_schema_builder()
77 .register(user_entity::Entity)
78 .register(channel_entity::Entity)
79 .register(project_entity::Entity)
80 .register(chat_entity::Entity)
81 .register(read_entity::Entity)
82 .register(watch_entity::Entity)
83 .register(reaction_entity::Entity)
84 .sync(&db)
85 .await
86 .unwrap();
87
88 for table in [
89 "channel",
90 "project",
91 "chat_reads",
92 "issue_watch",
93 "issue_reaction",
94 "users",
95 ] {
96 assert!(
97 table_exists(&db, table).await,
98 "table `{table}` should exist after sync"
99 );
100 }
101 }
102}
Modified lib/email/Cargo.toml lib/email/Cargo.toml +0 −3 View lib/email/Cargo.toml
@@ -29,6 +29,3 @@
2929futures = { workspace = true }
3030uuid = { workspace = true }
3131metrics = { workspace = true }
32
33[dev-dependencies]
34tempfile = { workspace = true }
No newline at end of file
Modified lib/email/events.rs lib/email/events.rs +0 −100 View lib/email/events.rs
@@ -106,103 +106,3 @@
106106 .map(|d| d.as_millis() as i64)
107107 .unwrap_or(0)
108108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[test]
115 fn send_email_request_serde_roundtrip() {
116 let mut meta = HashMap::new();
117 meta.insert("tenant".to_string(), "acme".to_string());
118 let req = SendEmailRequest {
119 to: vec![EmailAddress::with_name("a@example.com", "Alice")],
120 subject: "hi".into(),
121 text: Some("plain".into()),
122 html: Some("<p>plain</p>".into()),
123 metadata: meta,
124 ..Default::default()
125 };
126 let bytes = serde_json::to_vec(&req).unwrap();
127 let back: SendEmailRequest = serde_json::from_slice(&bytes).unwrap();
128 assert_eq!(back, req);
129 }
130
131 #[test]
132 fn email_sent_serde_roundtrip() {
133 let mut meta = HashMap::new();
134 meta.insert("k".into(), "v".into());
135 let sent = EmailSent {
136 request_id: Some("req-1".into()),
137 message_id: "<abc@example.com>".into(),
138 recipient_count: 3,
139 metadata: meta,
140 sent_at_ms: 1_700_000_000_000,
141 };
142 let bytes = serde_json::to_vec(&sent).unwrap();
143 let back: EmailSent = serde_json::from_slice(&bytes).unwrap();
144 assert_eq!(back, sent);
145 }
146
147 #[test]
148 fn email_failed_serde_roundtrip() {
149 let failed = EmailFailed {
150 request_id: Some("req-1".into()),
151 error: "smtp timeout".into(),
152 recipient_count: 1,
153 metadata: HashMap::new(),
154 failed_at_ms: 42,
155 };
156 let bytes = serde_json::to_vec(&failed).unwrap();
157 let back: EmailFailed = serde_json::from_slice(&bytes).unwrap();
158 assert_eq!(back, failed);
159 }
160
161 #[test]
162 fn render_and_send_serde_roundtrip() {
163 let req = RenderAndSendEmailRequest {
164 template_id: "welcome".into(),
165 locale: Some("zh".into()),
166 to: EmailAddress::new("a@example.com"),
167 cc: vec![],
168 bcc: vec![],
169 reply_to: None,
170 subject: "Welcome {{name}}".into(),
171 vars: serde_json::json!({"name":"Alice"}),
172 attachments: vec![],
173 request_id: Some("r1".into()),
174 metadata: HashMap::new(),
175 };
176 let bytes = serde_json::to_vec(&req).unwrap();
177 let back: RenderAndSendEmailRequest = serde_json::from_slice(&bytes).unwrap();
178 assert_eq!(back, req);
179 }
180
181 #[test]
182 fn send_batch_serde_roundtrip() {
183 let req = SendBatchEmailRequest {
184 recipients: vec![BatchRecipient {
185 to: EmailAddress::new("a@example.com"),
186 metadata: HashMap::new(),
187 }],
188 cc: vec![],
189 bcc: vec![],
190 reply_to: None,
191 subject: "newsletter".into(),
192 text: Some("hi".into()),
193 html: None,
194 attachments: vec![],
195 headers: vec![],
196 locale: None,
197 request_id: Some("r".into()),
198 };
199 let bytes = serde_json::to_vec(&req).unwrap();
200 let back: SendBatchEmailRequest = serde_json::from_slice(&bytes).unwrap();
201 assert_eq!(back, req);
202 }
203
204 #[test]
205 fn now_ms_is_positive() {
206 assert!(now_ms() > 1_700_000_000_000);
207 }
208}
Modified lib/email/mime.rs lib/email/mime.rs +0 −128 View lib/email/mime.rs
@@ -125,131 +125,3 @@
125125fn _unused_hashmap_import_for_feature_gates() -> HashMap<(), ()> {
126126 HashMap::new()
127127}
128
129#[cfg(test)]
130mod tests {
131 use super::*;
132
133 fn make_addr(email: &str) -> EmailAddress {
134 EmailAddress {
135 email: email.to_string(),
136 name: Some("X".to_string()),
137 }
138 }
139
140 fn base_request() -> SendEmailRequest {
141 SendEmailRequest {
142 to: vec![make_addr("a@example.com")],
143 subject: "test".to_string(),
144 text: None,
145 html: None,
146 attachments: vec![],
147 headers: vec![],
148 cc: vec![],
149 bcc: vec![],
150 reply_to: None,
151 locale: None,
152 request_id: None,
153 metadata: HashMap::new(),
154 }
155 }
156
157 fn from() -> Mailbox {
158 Mailbox::new(Some("Me".to_string()), "me@example.com".parse().unwrap())
159 }
160
161 #[test]
162 fn multipart_alternative_when_both_text_and_html() {
163 let req = SendEmailRequest {
164 text: Some("hi".into()),
165 html: Some("<p>hi</p>".into()),
166 ..base_request()
167 };
168 let msg = build_message(&req, from(), &default_opts()).unwrap();
169 let formatted = format!("{msg:?}");
170 assert!(formatted.contains("multipart/alternative"));
171 }
172
173 #[test]
174 fn html_only() {
175 let req = SendEmailRequest {
176 html: Some("<p>x</p>".into()),
177 ..base_request()
178 };
179 let msg = build_message(&req, from(), &default_opts()).unwrap();
180 let formatted = format!("{msg:?}");
181 assert!(formatted.contains("text/html"));
182 }
183
184 #[test]
185 fn text_only() {
186 let req = SendEmailRequest {
187 text: Some("x".into()),
188 ..base_request()
189 };
190 let msg = build_message(&req, from(), &default_opts()).unwrap();
191 let formatted = format!("{msg:?}");
192 assert!(formatted.contains("text/plain"));
193 }
194
195 #[test]
196 fn missing_body_returns_error() {
197 let req = base_request();
198 let err = build_message(&req, from(), &default_opts()).unwrap_err();
199 assert!(matches!(err, EmailError::Missing(_)));
200 }
201
202 #[test]
203 fn attachment_over_limit_rejected() {
204 let huge = BASE64.encode(vec![0u8; 2 * 1024 * 1024]);
205 let req = SendEmailRequest {
206 attachments: vec![Attachment {
207 filename: "big.bin".into(),
208 content_type: "application/octet-stream".into(),
209 data_b64: huge,
210 content_id: None,
211 }],
212 text: Some("x".into()),
213 ..base_request()
214 };
215 let opts = MimeBuildOptions {
216 max_attachment_bytes: 1024,
217 };
218 let err = build_message(&req, from(), &opts).unwrap_err();
219 assert!(matches!(err, EmailError::AttachmentTooLarge(_, _)));
220 }
221
222 #[test]
223 fn attachment_with_content_id_builds_inline() {
224 let req = SendEmailRequest {
225 text: Some("x".into()),
226 attachments: vec![Attachment {
227 filename: "logo.png".into(),
228 content_type: "image/png".into(),
229 data_b64: BASE64.encode(b"\x89PNG\r\n"),
230 content_id: Some("logo".into()),
231 }],
232 ..base_request()
233 };
234 let opts = MimeBuildOptions {
235 max_attachment_bytes: 1024 * 1024,
236 };
237 let _msg = build_message(&req, from(), &opts).unwrap();
238 }
239
240 #[test]
241 fn invalid_address_skipped_silently() {
242 let req = SendEmailRequest {
243 to: vec![
244 EmailAddress {
245 email: "not-an-email".to_string(),
246 name: None,
247 },
248 EmailAddress::new("valid@example.com"),
249 ],
250 text: Some("x".into()),
251 ..base_request()
252 };
253 let _msg = build_message(&req, from(), &default_opts()).unwrap();
254 }
255}
Modified lib/email/runtime.rs lib/email/runtime.rs +0 −27 View lib/email/runtime.rs
@@ -90,30 +90,3 @@
9090 info!("email worker shut down cleanly");
9191 Ok(())
9292}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 fn email_cfg() -> AppEmailSettings {
99 AppEmailSettings {
100 enabled: true,
101 smtp_host: "smtp.example.com".into(),
102 smtp_port: 587,
103 ..Default::default()
104 }
105 }
106
107 fn queue_cfg_disabled() -> AppQueueSettings {
108 AppQueueSettings {
109 enabled: false,
110 ..Default::default()
111 }
112 }
113
114 #[tokio::test]
115 async fn start_worker_returns_error_when_queue_disabled() {
116 let res = start_worker(email_cfg(), queue_cfg_disabled(), WorkerOptions::default()).await;
117 assert!(matches!(res, Err(EmailError::Config(_))));
118 }
119}
Modified lib/email/service.rs lib/email/service.rs +0 −50 View lib/email/service.rs
@@ -372,53 +372,3 @@
372372 }))
373373 }
374374}
375
376#[cfg(test)]
377mod tests {
378 use super::*;
379 use config::email::AppEmailSettings;
380
381 fn cfg() -> AppEmailSettings {
382 AppEmailSettings {
383 enabled: true,
384 smtp_port: 587,
385 smtp_host: "smtp.example.com".into(),
386 ..Default::default()
387 }
388 }
389
390 #[test]
391 fn parse_offset_reset_known() {
392 assert!(matches!(
393 parse_offset_reset("earliest"),
394 Ok(AutoOffsetReset::Earliest)
395 ));
396 assert!(matches!(
397 parse_offset_reset("latest"),
398 Ok(AutoOffsetReset::Latest)
399 ));
400 assert!(parse_offset_reset("foo").is_err());
401 }
402
403 #[test]
404 fn builder_returns_none_when_disabled() {
405 let rt = tokio::runtime::Builder::new_current_thread()
406 .enable_all()
407 .build()
408 .unwrap();
409 let mut c = cfg();
410 c.enabled = false;
411 let queue_handle = rt
412 .block_on(
413 queue::QueueBuilder::from_config(config::queue::AppQueueSettings::default())
414 .build(),
415 )
416 .unwrap()
417 .expect("queue must build for test");
418 let queue_arc = Arc::new(queue_handle);
419 let res = rt
420 .block_on(EmailServiceBuilder::new(c, queue_arc).build())
421 .unwrap();
422 assert!(res.is_none());
423 }
424}
Modified lib/email/template.rs lib/email/template.rs +0 −72 View lib/email/template.rs
@@ -134,75 +134,3 @@
134134 let dot = stem.rfind('.')?;
135135 Some((&stem[..dot], &stem[dot + 1..]))
136136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use serde_json::json;
142
143 #[test]
144 fn register_and_render_html_and_text() {
145 let mut r = TemplateRegistry::new("en");
146 r.add_template(
147 "welcome",
148 "en",
149 Some("<h1>Hi {{name}}</h1>"),
150 Some("Hi {{name}}"),
151 )
152 .unwrap();
153 let html = r
154 .render_html("welcome", Some("en"), &json!({"name":"Alice"}))
155 .unwrap();
156 let text = r
157 .render_text("welcome", Some("en"), &json!({"name":"Alice"}))
158 .unwrap();
159 assert_eq!(html, "<h1>Hi Alice</h1>");
160 assert_eq!(text, "Hi Alice");
161 }
162
163 #[test]
164 fn locale_fallback() {
165 let mut r = TemplateRegistry::new("en");
166 r.add_template("welcome", "en", Some("Hi {{name}}"), None)
167 .unwrap();
168 let out = r
169 .render_html("welcome", Some("de"), &json!({"name":"A"}))
170 .unwrap();
171 assert_eq!(out, "Hi A");
172 }
173
174 #[test]
175 fn template_not_found() {
176 let r = TemplateRegistry::new("en");
177 let err = r.render_html("missing", None, &json!({})).unwrap_err();
178 assert!(matches!(err, EmailError::TemplateNotFound(_)));
179 }
180
181 #[test]
182 fn load_dir_loads_files() {
183 let dir = tempfile::tempdir().unwrap();
184 std::fs::write(dir.path().join("welcome.en.html"), "<p>Hi {{name}}</p>").unwrap();
185 std::fs::write(dir.path().join("welcome.en.txt"), "Hi {{name}}").unwrap();
186 std::fs::write(dir.path().join("ignored.txt"), "ignored").unwrap();
187 let mut r = TemplateRegistry::new("en");
188 let loaded = r.load_dir(dir.path()).unwrap();
189 assert_eq!(loaded, 2);
190 let html = r
191 .render_html("welcome", Some("en"), &json!({"name":"A"}))
192 .unwrap();
193 assert_eq!(html, "<p>Hi A</p>");
194 }
195
196 #[test]
197 fn split_template_filename_works() {
198 assert_eq!(split_template_filename("a.en.html"), Some(("a.en", "html")));
199 assert_eq!(split_template_filename("a.en.txt"), Some(("a.en", "text")));
200 assert_eq!(split_template_filename("a.b.css"), None);
201 }
202
203 #[test]
204 fn split_id_locale_works() {
205 assert_eq!(split_id_locale("welcome.en"), Some(("welcome", "en")));
206 assert_eq!(split_id_locale("welcome"), None);
207 }
208}
Modified lib/email/transport.rs lib/email/transport.rs +0 −58 View lib/email/transport.rs
@@ -70,61 +70,3 @@
7070 self.from.clone()
7171 }
7272}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 fn base_cfg() -> AppEmailSettings {
79 AppEmailSettings {
80 enabled: true,
81 smtp_host: "smtp.example.com".into(),
82 smtp_port: 587,
83 ..Default::default()
84 }
85 }
86
87 #[test]
88 fn build_transport_with_default_config() {
89 let transport = EmailTransport::from_config(&base_cfg()).unwrap();
90 assert_eq!(transport.from.email.to_string(), "no-reply@example.com");
91 }
92
93 #[test]
94 fn build_transport_with_credentials() {
95 let cfg = AppEmailSettings {
96 smtp_username: Some("user".into()),
97 smtp_password: Some("pw".into()),
98 ..base_cfg()
99 };
100 let _transport = EmailTransport::from_config(&cfg).unwrap();
101 }
102
103 #[test]
104 fn build_transport_implicit_tls_465() {
105 let cfg = AppEmailSettings {
106 smtp_port: 465,
107 ..base_cfg()
108 };
109 let _transport = EmailTransport::from_config(&cfg).unwrap();
110 }
111
112 #[test]
113 fn build_transport_plain_25() {
114 let cfg = AppEmailSettings {
115 smtp_port: 25,
116 ..base_cfg()
117 };
118 let _transport = EmailTransport::from_config(&cfg).unwrap();
119 }
120
121 #[test]
122 fn invalid_config_rejected() {
123 let cfg = AppEmailSettings {
124 enabled: true,
125 smtp_host: "".into(),
126 ..Default::default()
127 };
128 assert!(EmailTransport::from_config(&cfg).is_err());
129 }
130}
Modified lib/jwt/Cargo.toml lib/jwt/Cargo.toml +0 −4 View lib/jwt/Cargo.toml
@@ -30,7 +30,3 @@
3030jsonwebtoken = { version = "11.0.0", features = ["rust_crypto"] }
3131base64 = "0.23.1"
3232sha2 = "0.10"
33
34[dev-dependencies]
35ed25519-dalek = { version = "2", features = ["pkcs8", "rand_core"] }
36rand_core = { version = "0.6", features = ["getrandom"] }
No newline at end of file
Modified lib/jwt/lib.rs lib/jwt/lib.rs +0 −4 View lib/jwt/lib.rs
@@ -9,10 +9,6 @@
99pub mod rotation;
1010pub mod session;
1111pub mod signer;
12
13#[cfg(test)]
14mod tests;
15
1612pub use claims::{AccessClaims, HasJti, HasUserExt, RefreshClaims};
1713pub use error::JwtError;
1814pub use extractor::{AuthAdmin, AuthOptional, AuthUser};
Modified lib/queue/lib.rs lib/queue/lib.rs +0 −78 View lib/queue/lib.rs
@@ -106,81 +106,3 @@
106106 }))
107107 }
108108}
109
110#[cfg(test)]
111mod tests {
112 use super::*;
113
114 #[tokio::test]
115 async fn disabled_returns_none() {
116 let cfg = AppQueueSettings {
117 enabled: false,
118 ..Default::default()
119 };
120 let queue = QueueBuilder::from_config(cfg).build().await.unwrap();
121 assert!(queue.is_none());
122 }
123
124 #[test]
125 fn invalid_config_rejected() {
126 let cfg = AppQueueSettings {
127 enabled: true,
128 brokers: "".into(),
129 ..Default::default()
130 };
131 assert!(cfg.is_valid().is_err());
132 }
133
134 #[test]
135 fn invalid_offset_reset_rejected() {
136 let cfg = AppQueueSettings {
137 enabled: true,
138 auto_offset_reset: "middle".into(),
139 ..Default::default()
140 };
141 assert!(cfg.is_valid().is_err());
142 }
143
144 #[test]
145 fn invalid_compression_rejected() {
146 let cfg = AppQueueSettings {
147 enabled: true,
148 producer_compression: "rar".into(),
149 ..Default::default()
150 };
151 assert!(cfg.is_valid().is_err());
152 }
153
154 #[test]
155 fn invalid_acks_rejected() {
156 let cfg = AppQueueSettings {
157 enabled: true,
158 producer_acks: "2".into(),
159 ..Default::default()
160 };
161 assert!(cfg.is_valid().is_err());
162 }
163
164 #[test]
165 fn subscribe_requires_topic() {
166 let cfg = AppQueueSettings::default();
167 let publisher = KafkaPublisher::from_config(&cfg).unwrap();
168 let queue = Queue {
169 inner: Arc::new(QueueInner {
170 publisher,
171 cfg: Arc::new(cfg),
172 }),
173 };
174 let err = queue
175 .subscribe_raw::<_, std::future::Ready<Result<(), QueueError>>>(
176 SubscriptionMode::Group {
177 group_id: "g".into(),
178 auto_offset_reset: AutoOffsetReset::Earliest,
179 },
180 &[],
181 |_| std::future::ready(Ok(())),
182 )
183 .unwrap_err();
184 assert!(matches!(err, QueueError::Topic(_)));
185 }
186}
Modified lib/queue/topic.rs lib/queue/topic.rs +0 −35 View lib/queue/topic.rs
@@ -25,38 +25,3 @@
2525 trimmed
2626 }
2727}
28
29#[cfg(test)]
30mod tests {
31 use super::*;
32
33 #[test]
34 fn strips_module_path() {
35 let t = topic_for_type::<crate_test_marker::Inner>("svc");
36 assert!(t.starts_with("svc."));
37 assert!(!t.contains("::"));
38 assert!(!t.contains('<'));
39 }
40
41 #[test]
42 fn replaces_special_chars() {
43 let s = sanitize_topic_component("a::b<c>d");
44 assert_eq!(s, "a_b_c_d");
45 }
46
47 #[test]
48 fn collapses_replacements() {
49 let s = sanitize_topic_component("a:::b");
50 assert_eq!(s, "a_b");
51 }
52
53 #[test]
54 fn empty_fallback() {
55 let s = sanitize_topic_component("<<<>>>");
56 assert_eq!(s, "unknown");
57 }
58
59 mod crate_test_marker {
60 pub struct Inner;
61 }
62}
Modified lib/queue/typed.rs lib/queue/typed.rs +0 −20 View lib/queue/typed.rs
@@ -102,23 +102,3 @@
102102 pub value: T,
103103 pub meta: TypedMessageMeta,
104104}
105
106#[cfg(test)]
107mod tests {
108 #[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Eq)]
109 struct Event {
110 id: u64,
111 name: String,
112 }
113
114 #[test]
115 fn serde_roundtrip() {
116 let v = Event {
117 id: 1,
118 name: "x".into(),
119 };
120 let bytes = serde_json::to_vec(&v).unwrap();
121 let back: Event = serde_json::from_slice(&bytes).unwrap();
122 assert_eq!(v, back);
123 }
124}
Modified lib/repo/features/lifecycle/usecase/mod.rs lib/repo/features/lifecycle/usecase/mod.rs +0 −28 View lib/repo/features/lifecycle/usecase/mod.rs
@@ -21,31 +21,3 @@
2121pub use rename::rename_repo;
2222pub use transfer::transfer_repo;
2323pub use update::update_repo;
24
25#[cfg(test)]
26mod tests {
27 use uuid::Uuid;
28
29 use crate::error::RepoError;
30 use crate::path::validate_repo_name;
31
32 use super::require_authenticated;
33
34 #[test]
35 fn require_authenticated_rejects_nil_uuid() {
36 let r = require_authenticated(Uuid::nil());
37 assert!(matches!(r, Err(RepoError::Unauthorized(_))));
38 }
39
40 #[test]
41 fn require_authenticated_accepts_real_uuid() {
42 let r = require_authenticated(Uuid::from_u128(1));
43 assert!(r.is_ok());
44 }
45
46 #[test]
47 fn create_option_name_is_validated_lazily() {
48 let r = validate_repo_name("", 100);
49 assert!(r.is_err());
50 }
51}
Modified lib/repo/features/pull_request/usecase.rs lib/repo/features/pull_request/usecase.rs +0 −258 View lib/repo/features/pull_request/usecase.rs
@@ -1107,261 +1107,3 @@
11071107 ObjectId::from_hex(hex.as_bytes())
11081108 .map_err(|e| RepoError::Internal(format!("invalid object id {hex}: {e}")))
11091109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use std::io::Write;
1115 use std::path::Path;
1116 use std::process::{Command, Stdio};
1117
1118 struct TempRepo {
1119 path: std::path::PathBuf,
1120 }
1121
1122 impl TempRepo {
1123 fn new() -> Self {
1124 let path = std::env::temp_dir().join(format!(
1125 "pr-repo-test-{}-{}",
1126 std::process::id(),
1127 Uuid::now_v7()
1128 ));
1129 git(&path, &["init", "-q", "--bare", "-b", "main"]);
1130 Self { path }
1131 }
1132
1133 fn path(&self) -> &Path {
1134 &self.path
1135 }
1136
1137 fn write_blob(&self, content: &str) -> ObjectId {
1138 let mut child = Command::new("git")
1139 .args(["hash-object", "-w", "--stdin"])
1140 .env("GIT_DIR", self.path())
1141 .stdin(Stdio::piped())
1142 .stdout(Stdio::piped())
1143 .spawn()
1144 .expect("spawn git hash-object");
1145 child
1146 .stdin
1147 .take()
1148 .expect("hash-object stdin")
1149 .write_all(content.as_bytes())
1150 .expect("write blob stdin");
1151 let out = child.wait_with_output().expect("hash-object output");
1152 assert!(out.status.success(), "hash-object failed: {:?}", out);
1153 parse_oid(String::from_utf8_lossy(&out.stdout).trim()).expect("blob oid")
1154 }
1155
1156 fn write_tree(&self, entries: &[(&str, ObjectId)]) -> ObjectId {
1157 let mut input = String::new();
1158 for (path, oid) in entries {
1159 input.push_str(&format!("100644 blob {oid}\t{path}\n"));
1160 }
1161 let mut child = Command::new("git")
1162 .args(["mktree"])
1163 .env("GIT_DIR", self.path())
1164 .stdin(Stdio::piped())
1165 .stdout(Stdio::piped())
1166 .spawn()
1167 .expect("spawn git mktree");
1168 child
1169 .stdin
1170 .take()
1171 .expect("mktree stdin")
1172 .write_all(input.as_bytes())
1173 .expect("write tree stdin");
1174 let out = child.wait_with_output().expect("mktree output");
1175 assert!(out.status.success(), "mktree failed: {:?}", out);
1176 parse_oid(String::from_utf8_lossy(&out.stdout).trim()).expect("tree oid")
1177 }
1178
1179 fn commit(&self, tree: ObjectId, parents: &[ObjectId], message: &str) -> ObjectId {
1180 let mut args = vec!["commit-tree".to_string(), tree.to_string()];
1181 for p in parents {
1182 args.push("-p".to_string());
1183 args.push(p.to_string());
1184 }
1185 args.push("-m".to_string());
1186 args.push(message.to_string());
1187 let out = git(
1188 self.path(),
1189 &args.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
1190 );
1191 parse_oid(out.trim()).expect("commit oid")
1192 }
1193 }
1194
1195 impl Drop for TempRepo {
1196 fn drop(&mut self) {
1197 let _ = std::fs::remove_dir_all(&self.path);
1198 }
1199 }
1200
1201 fn git(path: &Path, args: &[&str]) -> String {
1202 let out = Command::new("git")
1203 .args(args)
1204 .env("GIT_DIR", path)
1205 .env("GIT_TERMINAL_PROMPT", "0")
1206 .output()
1207 .expect("git command");
1208 assert!(
1209 out.status.success(),
1210 "git {args:?} failed: {}",
1211 String::from_utf8_lossy(&out.stderr)
1212 );
1213 String::from_utf8_lossy(&out.stdout).into_owned()
1214 }
1215
1216 fn open(repo: &TempRepo) -> gix::Repository {
1217 gix::open(repo.path()).expect("open bare repo")
1218 }
1219
1220 fn pr_model(number: i32) -> pr_entity::Model {
1221 pr_entity::Model {
1222 id: Uuid::now_v7(),
1223 repo_id: Uuid::now_v7(),
1224 issue_id: Uuid::now_v7(),
1225 autoincrement_id: number,
1226 author_id: Uuid::now_v7(),
1227 title: "Add feature".to_string(),
1228 body: String::new(),
1229 head_branch: "feature".to_string(),
1230 head_sha: String::new(),
1231 base_branch: "main".to_string(),
1232 base_sha: String::new(),
1233 has_merged: false,
1234 merged_commit_sha: None,
1235 additions: 0,
1236 deletions: 0,
1237 files_changed: 0,
1238 merger_id: None,
1239 merged_at: None,
1240 is_locked: false,
1241 created_at: OffsetDateTime::now_utc(),
1242 updated_at: None,
1243 closed_at: None,
1244 }
1245 }
1246
1247 #[test]
1248 fn threeway_merge_status_fast_forwardable() {
1249 let repo = TempRepo::new();
1250 let blob = repo.write_blob("v1\n");
1251 let ancestor = repo.commit(repo.write_tree(&[("a.txt", blob)]), &[], "base");
1252 let extra = repo.write_blob("v2\n");
1253 let head_tree = repo.write_tree(&[("a.txt", blob), ("b.txt", extra)]);
1254 let head = repo.commit(head_tree, &[ancestor], "feature work");
1255
1256 let status =
1257 threeway_merge_status(&open(&repo), ancestor, head, ancestor).expect("threeway status");
1258 assert!(status.mergeable, "fast-forward should be mergeable");
1259 assert!(status.fast_forwardable, "should be fast-forwardable");
1260 assert!(status.conflicts.is_empty(), "no conflicts expected");
1261 }
1262
1263 #[test]
1264 fn threeway_merge_status_conflict() {
1265 let repo = TempRepo::new();
1266 let blob = repo.write_blob("v1\n");
1267 let ancestor = repo.commit(repo.write_tree(&[("a.txt", blob)]), &[], "base");
1268
1269 let ours_blob = repo.write_blob("ours\n");
1270 let ours = repo.commit(
1271 repo.write_tree(&[("a.txt", ours_blob)]),
1272 &[ancestor],
1273 "ours",
1274 );
1275
1276 let theirs_blob = repo.write_blob("theirs\n");
1277 let theirs = repo.commit(
1278 repo.write_tree(&[("a.txt", theirs_blob)]),
1279 &[ancestor],
1280 "theirs",
1281 );
1282
1283 let status =
1284 threeway_merge_status(&open(&repo), ours, theirs, ancestor).expect("threeway status");
1285 assert!(!status.mergeable, "conflicting branches should not merge");
1286 assert!(!status.fast_forwardable, "conflicting branches are not ff");
1287 assert_eq!(status.conflicts.len(), 1, "exactly one conflict expected");
1288 assert_eq!(status.conflicts[0].path, "a.txt");
1289 assert_eq!(status.conflicts[0].our_oid, Some(ours_blob));
1290 assert_eq!(status.conflicts[0].their_oid, Some(theirs_blob));
1291 }
1292
1293 #[test]
1294 fn write_commit_roundtrip() {
1295 let repo = TempRepo::new();
1296 let blob = repo.write_blob("hello\n");
1297 let tree = repo.write_tree(&[("hello.txt", blob)]);
1298 let sig = gix::actor::Signature {
1299 name: "Tester".into(),
1300 email: "tester@example.com".into(),
1301 time: gix::date::Time {
1302 seconds: 1_700_000_000,
1303 offset: 0,
1304 },
1305 };
1306 let id = write_commit(&open(&repo), vec![], tree, "initial", &sig).expect("write commit");
1307 let gix_repo = open(&repo);
1308 let found = gix_repo.find_commit(id).expect("find written commit");
1309 let commit = found.decode().expect("decode commit");
1310 let tree_oid = ObjectId::from_hex(commit.tree.as_ref()).expect("tree hex");
1311 assert_eq!(tree_oid, tree);
1312 assert!(commit.parents.is_empty());
1313 assert_eq!(commit.message, b"initial".as_slice());
1314 }
1315
1316 #[test]
1317 fn merge_message_formats() {
1318 let pr = pr_model(42);
1319 let opt = MergePullRequestOption {
1320 style: MergeStyle::Squash,
1321 delete_branch_after_merge: false,
1322 commit_title: None,
1323 commit_message: None,
1324 };
1325 assert_eq!(merge_message(&pr, &opt, true), "Add feature (#42)");
1326 assert_eq!(
1327 merge_message(&pr, &opt, false),
1328 "Merge pull request #42 from feature"
1329 );
1330 }
1331
1332 #[test]
1333 fn merge_message_overrides() {
1334 let pr = pr_model(7);
1335 let opt = MergePullRequestOption {
1336 style: MergeStyle::Merge,
1337 delete_branch_after_merge: true,
1338 commit_title: Some("Custom title".to_string()),
1339 commit_message: Some("Details".to_string()),
1340 };
1341 assert_eq!(merge_message(&pr, &opt, false), "Custom title\n\nDetails");
1342 }
1343
1344 #[test]
1345 fn review_state_to_db_maps() {
1346 assert!(matches!(
1347 review_state_to_db(ReviewStateView::Approved),
1348 review_entity::ReviewState::Approved
1349 ));
1350 assert!(matches!(
1351 review_state_to_db(ReviewStateView::ChangesRequested),
1352 review_entity::ReviewState::ChangesRequested
1353 ));
1354 assert!(matches!(
1355 review_state_to_db(ReviewStateView::Commented),
1356 review_entity::ReviewState::Commented
1357 ));
1358 }
1359
1360 #[test]
1361 fn parse_oid_accepts_and_rejects() {
1362 let hex = "a".repeat(40);
1363 assert!(parse_oid(&hex).is_ok());
1364 assert!(parse_oid("not-an-oid").is_err());
1365 assert!(parse_oid("").is_err());
1366 }
1367}
Modified lib/repo/permission.rs lib/repo/permission.rs +0 −96 View lib/repo/permission.rs
@@ -108,99 +108,3 @@
108108 }
109109 Err(PermissionError::Insufficient)
110110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use crate::actor::ActorContext;
116
117 fn member_actor(space_role: SpaceRole) -> ActorContext {
118 ActorContext {
119 user_id: Some(uuid::Uuid::now_v7()),
120 scopes: Default::default(),
121 is_site_admin: false,
122 channel_role: None,
123 space_role: Some(space_role),
124 }
125 }
126
127 fn stranger_actor() -> ActorContext {
128 ActorContext {
129 user_id: Some(uuid::Uuid::now_v7()),
130 scopes: Default::default(),
131 is_site_admin: false,
132 channel_role: None,
133 space_role: None,
134 }
135 }
136
137 #[test]
138 fn space_member_reads_private_repo_without_collaborator() {
139 let actor = member_actor(SpaceRole::Member);
140 assert!(
141 require(
142 &actor,
143 RepoAccess::Read,
144 None,
145 Some(SpaceRole::Member),
146 None,
147 false
148 )
149 .is_ok()
150 );
151 assert!(matches!(
152 require(
153 &actor,
154 RepoAccess::Write,
155 None,
156 Some(SpaceRole::Member),
157 None,
158 false
159 ),
160 Err(PermissionError::Insufficient)
161 ));
162 }
163
164 #[test]
165 fn space_owner_admin_on_private_repo() {
166 let actor = member_actor(SpaceRole::Owner);
167 assert!(
168 require(
169 &actor,
170 RepoAccess::Admin,
171 None,
172 Some(SpaceRole::Owner),
173 None,
174 false
175 )
176 .is_ok()
177 );
178 }
179
180 #[test]
181 fn non_member_stranger_denied_private_repo() {
182 let actor = stranger_actor();
183 assert!(matches!(
184 require(&actor, RepoAccess::Read, None, None, None, false),
185 Err(PermissionError::Insufficient)
186 ));
187 let anon = ActorContext::anonymous();
188 assert!(require(&anon, RepoAccess::Read, None, None, None, true).is_ok());
189 }
190
191 #[test]
192 fn collaborator_gives_explicit_write() {
193 let actor = stranger_actor();
194 assert!(
195 require(
196 &actor,
197 RepoAccess::Write,
198 None,
199 None,
200 Some(RepoAccess::Write),
201 false
202 )
203 .is_ok()
204 );
205 }
206}
Modified lib/search/client.rs lib/search/client.rs +0 −35 View lib/search/client.rs
@@ -149,38 +149,3 @@
149149 }
150150 }
151151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156
157 #[test]
158 fn disabled_returns_none() {
159 let cfg = AppSearchSettings {
160 enabled: false,
161 ..Default::default()
162 };
163 assert!(
164 SearchClientBuilder::from_config(cfg)
165 .build()
166 .unwrap()
167 .is_none()
168 );
169 }
170
171 #[test]
172 fn invalid_config_rejected() {
173 let cfg = AppSearchSettings {
174 enabled: true,
175 host: "".into(),
176 ..Default::default()
177 };
178 assert!(SearchClientBuilder::from_config(cfg).build().is_err());
179 }
180
181 #[test]
182 fn construction_is_network_free() {
183 let client = SearchClient::new("http://127.0.0.1:7700", None::<String>).unwrap();
184 assert_eq!(client.host(), "http://127.0.0.1:7700");
185 }
186}
Modified lib/search/error.rs lib/search/error.rs +0 −18 View lib/search/error.rs
@@ -39,21 +39,3 @@
3939 }
4040 }
4141}
42
43#[cfg(test)]
44mod tests {
45 use super::*;
46
47 #[test]
48 fn error_kind_labels() {
49 assert_eq!(SearchError::Disabled.error_kind(), "disabled");
50 assert_eq!(
51 SearchError::InvalidQuery("x".into()).error_kind(),
52 "invalid_query"
53 );
54 assert_eq!(
55 SearchError::TaskFailed("x".into()).error_kind(),
56 "task_failed"
57 );
58 }
59}
Modified lib/search/index.rs lib/search/index.rs +0 −18 View lib/search/index.rs
@@ -374,21 +374,3 @@
374374 }
375375 }
376376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 #[test]
383 fn settings_builder() {
384 let s = IndexSettings::default()
385 .with_primary_key("uid")
386 .with_searchable_attributes(["name", "description"])
387 .with_filterable_attributes(["space_id"])
388 .with_sortable_attributes(["updated_at"])
389 .with_max_total_hits(10_000);
390 assert_eq!(s.primary_key.as_deref(), Some("uid"));
391 assert_eq!(s.searchable_attributes, vec!["name", "description"]);
392 assert_eq!(s.max_total_hits, Some(10_000));
393 }
394}
Modified lib/search/query.rs lib/search/query.rs +0 −53 View lib/search/query.rs
@@ -145,56 +145,3 @@
145145 pub query: String,
146146 pub index_uid: Option<String>,
147147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 #[test]
154 fn default_limit_is_twenty() {
155 assert_eq!(SearchRequest::new("x").limit, 20);
156 }
157
158 #[test]
159 fn builder_chains() {
160 let req = SearchRequest::new("rust")
161 .with_limit(10)
162 .with_offset(5)
163 .with_filter("space_id = \"s1\"")
164 .with_sort(["updated_at:desc"])
165 .with_show_ranking_score(true);
166 assert_eq!(req.limit, 10);
167 assert_eq!(req.offset, 5);
168 assert_eq!(req.filter.as_deref(), Some("space_id = \"s1\""));
169 assert_eq!(
170 req.sort.as_deref(),
171 Some(&["updated_at:desc".to_string()][..])
172 );
173 assert!(req.show_ranking_score);
174 }
175
176 #[test]
177 fn validate_rejects_zero_limit() {
178 assert!(SearchRequest::new("x").with_limit(0).validate().is_err());
179 }
180
181 #[test]
182 fn validate_rejects_excessive_limit() {
183 assert!(
184 SearchRequest::new("x")
185 .with_limit(MAX_LIMIT + 1)
186 .validate()
187 .is_err()
188 );
189 }
190
191 #[test]
192 fn serde_roundtrip() {
193 let req = SearchRequest::new("rust")
194 .with_limit(5)
195 .with_filter("a = 1");
196 let json = serde_json::to_string(&req).unwrap();
197 let back: SearchRequest = serde_json::from_str(&json).unwrap();
198 assert_eq!(req, back);
199 }
200}
Modified lib/search/sync.rs lib/search/sync.rs +0 −50 View lib/search/sync.rs
@@ -243,53 +243,3 @@
243243 Ok(handle)
244244 }
245245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250 use crate::client::SearchClient;
251
252 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253 struct Dummy {
254 id: String,
255 name: String,
256 }
257
258 impl Searchable for Dummy {
259 const INDEX_UID: &'static str = "dummy";
260 const PRIMARY_KEY: &'static str = "id";
261 fn document_id(&self) -> String {
262 self.id.clone()
263 }
264 }
265
266 #[test]
267 fn sync_message_roundtrip() {
268 let msg = SyncMessage::Upsert {
269 document: Dummy {
270 id: "1".into(),
271 name: "x".into(),
272 },
273 };
274 let json = serde_json::to_string(&msg).unwrap();
275 assert!(json.contains("\"op\":\"upsert\""));
276 let back: SyncMessage<Dummy> = serde_json::from_str(&json).unwrap();
277 assert_eq!(msg, back);
278 }
279
280 #[test]
281 fn delete_message_roundtrip() {
282 let msg = SyncMessage::<Dummy>::Delete { id: "42".into() };
283 let json = serde_json::to_string(&msg).unwrap();
284 assert!(json.contains("\"op\":\"delete\""));
285 let back: SyncMessage<Dummy> = serde_json::from_str(&json).unwrap();
286 assert_eq!(msg, back);
287 }
288
289 #[test]
290 fn synced_uid_respects_prefix() {
291 let client = SearchClient::new("http://127.0.0.1:7700", None::<String>).unwrap();
292 let synced = client.synced::<Dummy>();
293 assert_eq!(synced.uid(), "untitled.dummy");
294 }
295}
Modified lib/storage/Cargo.toml lib/storage/Cargo.toml +0 −4 View lib/storage/Cargo.toml
@@ -26,7 +26,3 @@
2626tokio = { workspace = true }
2727tracing = { workspace = true }
2828url = { workspace = true }
29
30[dev-dependencies]
31serde_json = { workspace = true }
32tempfile = { workspace = true }
Modified lib/storage/local.rs lib/storage/local.rs +0 −106 View lib/storage/local.rs
@@ -141,109 +141,3 @@
141141 result
142142 }
143143}
144
145#[cfg(test)]
146mod tests {
147 use std::time::Duration;
148
149 use bytes::Bytes;
150 use object_store::path::Path;
151 use tempfile::tempdir;
152
153 use crate::repo::{StorageError, from_config};
154 use config::storage::{LocalStorageSettings, StorageSettings};
155
156 fn local_settings(dir: &std::path::Path) -> StorageSettings {
157 let mut local = LocalStorageSettings::default();
158 local.set_root_dir(dir.to_path_buf());
159 let mut settings = StorageSettings::default();
160 settings.set_local(local);
161 settings
162 }
163
164 #[tokio::test]
165 async fn local_roundtrip() {
166 let dir = tempdir().unwrap();
167 let settings = local_settings(dir.path());
168 let repo = from_config(&settings).unwrap();
169
170 let path = Path::from("test/file.txt");
171 let payload = Bytes::from_static(b"hello world");
172
173 repo.put(&path, payload.clone()).await.unwrap();
174 assert!(repo.exists(&path).await.unwrap());
175
176 let meta = repo.head(&path).await.unwrap();
177 assert_eq!(meta.size, payload.len() as u64);
178
179 let got = repo.get(&path).await.unwrap().bytes().await.unwrap();
180 assert_eq!(got, payload);
181
182 let dest = Path::from("test/copy.txt");
183 repo.copy(&path, &dest).await.unwrap();
184 assert!(repo.exists(&dest).await.unwrap());
185
186 let listed = repo.list(Some(&Path::from("test"))).await.unwrap();
187 assert_eq!(listed.len(), 2);
188
189 let all = repo.list(None).await.unwrap();
190 assert_eq!(all.len(), 2);
191
192 repo.delete(&path).await.unwrap();
193 assert!(!repo.exists(&path).await.unwrap());
194
195 dir.close().unwrap();
196 }
197
198 #[tokio::test]
199 async fn local_multipart_roundtrip() {
200 use object_store::{MultipartUpload, PutPayload};
201
202 let dir = tempdir().unwrap();
203 let settings = local_settings(dir.path());
204 let repo = from_config(&settings).unwrap();
205
206 let path = Path::from("test/multipart.bin");
207 let mut upload = repo.begin_multipart(&path).await.unwrap();
208 upload
209 .put_part(PutPayload::from_bytes(Bytes::from_static(b"hello ")))
210 .await
211 .unwrap();
212 upload
213 .put_part(PutPayload::from_bytes(Bytes::from_static(b"world ")))
214 .await
215 .unwrap();
216 upload
217 .put_part(PutPayload::from_bytes(Bytes::from_static(
218 b"from multipart",
219 )))
220 .await
221 .unwrap();
222 upload.complete().await.unwrap();
223
224 let got = repo.get(&path).await.unwrap().bytes().await.unwrap();
225 assert_eq!(got, Bytes::from_static(b"hello world from multipart"));
226
227 dir.close().unwrap();
228 }
229
230 #[tokio::test]
231 async fn local_presign_unsupported() {
232 let dir = tempdir().unwrap();
233 let settings = local_settings(dir.path());
234 let repo = from_config(&settings).unwrap();
235 let path = Path::from("test/file.txt");
236
237 assert!(matches!(
238 repo.presigned_upload(&path, Duration::from_secs(60)).await,
239 Err(StorageError::Unsupported(_))
240 ));
241 assert!(matches!(
242 repo.presigned_download(&path, Duration::from_secs(60))
243 .await,
244 Err(StorageError::Unsupported(_))
245 ));
246
247 dir.close().unwrap();
248 }
249}
Modified lib/storage/repo.rs lib/storage/repo.rs +0 −43 View lib/storage/repo.rs
@@ -48,46 +48,3 @@
4848 BackendType::S3 => Ok(Arc::new(crate::s3::S3Storage::from_config(settings)?)),
4949 }
5050}
51
52#[cfg(test)]
53mod tests {
54 use super::*;
55 use config::storage::{LocalStorageSettings, S3StorageSettings};
56
57 #[test]
58 fn from_config_dispatches_local() {
59 let settings = StorageSettings::default();
60 let _repo = from_config(&settings).unwrap();
61 }
62
63 #[test]
64 fn from_config_dispatches_s3() {
65 let mut settings = StorageSettings::default();
66 settings.set_backend(BackendType::S3);
67 let mut s3 = S3StorageSettings::default();
68 s3.set_bucket("test-bucket".to_string());
69 settings.set_s3(s3);
70 assert!(from_config(&settings).is_ok());
71 }
72
73 #[test]
74 fn from_config_rejects_empty_s3_bucket() {
75 let mut settings = StorageSettings::default();
76 settings.set_backend(BackendType::S3);
77 settings.set_s3(S3StorageSettings::default());
78 assert!(matches!(
79 from_config(&settings),
80 Err(StorageError::Config(_))
81 ));
82 }
83
84 #[test]
85 fn local_settings_roundtrip() {
86 let mut settings = StorageSettings::default();
87 let mut local = LocalStorageSettings::default();
88 local.set_root_dir(std::path::PathBuf::from("/tmp/custom"));
89 settings.set_local(local);
90 let repo = from_config(&settings).unwrap();
91 let _ = repo;
92 }
93}
Modified lib/webhook/payload.rs lib/webhook/payload.rs +0 −85 View lib/webhook/payload.rs
@@ -26,88 +26,3 @@
2626 },
2727 })
2828}
29
30#[cfg(test)]
31mod tests {
32 use super::*;
33 use gitsync::PostReceiveRefUpdate;
34 use time::OffsetDateTime;
35 use uuid::Uuid;
36
37 fn sample_task() -> PostReceiveTask {
38 let refs = vec![PostReceiveRefUpdate::new(
39 "refs/heads/main",
40 "0000000000000000000000000000000000000000",
41 "1111111111111111111111111111111111111111",
42 )];
43 PostReceiveTask::new(Uuid::now_v7(), Uuid::now_v7(), Some(Uuid::now_v7()), refs)
44 }
45
46 fn sample_repo() -> repos::Model {
47 repos::Model {
48 id: Uuid::now_v7(),
49 channel_id: Uuid::now_v7(),
50 lower_name: "demo".into(),
51 name: "demo".into(),
52 description: None,
53 website: None,
54 default_branch: "main".into(),
55 nums_watch: 0,
56 nums_star: 0,
57 nums_pr: 0,
58 nums_commits: 0,
59 nums_branches: 0,
60 nums_tags: 0,
61 enable_lfs: false,
62 is_private: false,
63 is_archived: false,
64 storage_limit_size: None,
65 size: 0,
66 git_size: 0,
67 lfs_size: 0,
68 avatar_url: None,
69 primary_language: None,
70 license_name: None,
71 created_at: OffsetDateTime::now_utc(),
72 last_pushed_at: None,
73 archived_at: None,
74 readme_raw: None,
75 allow_rebase: true,
76 allow_rebase_explicit: false,
77 allow_squash_merge: true,
78 allow_merge_commits: true,
79 object_format: "sha1".into(),
80 }
81 }
82
83 #[test]
84 fn payload_has_core_fields() {
85 let task = sample_task();
86 let repo = sample_repo();
87 let payload = build_push_payload(&task, &repo, &task.refs[0]);
88 assert_eq!(payload["ref"], "refs/heads/main");
89 assert_eq!(payload["created"], true);
90 assert_eq!(payload["deleted"], false);
91 assert_eq!(payload["repository"]["name"], "demo");
92 assert_eq!(payload["repository"]["default_branch"], "main");
93 assert_eq!(payload["repository"]["id"], repo.id.to_string());
94 assert_eq!(payload["pusher"]["id"], task.actor_id.unwrap().to_string());
95 }
96
97 #[test]
98 fn payload_flags_follow_ref_semantics() {
99 let task = sample_task();
100 let repo = sample_repo();
101 let del = build_push_payload(
102 &task,
103 &repo,
104 &PostReceiveRefUpdate::new(
105 "refs/heads/main",
106 "1111111111111111111111111111111111111111",
107 "0000000000000000000000000000000000000000",
108 ),
109 );
110 assert_eq!(del["deleted"], true);
111 assert_eq!(del["created"], false);
112 }
113}
Modified lib/webhook/sign.rs lib/webhook/sign.rs +0 −28 View lib/webhook/sign.rs
@@ -10,31 +10,3 @@
1010 mac.update(payload);
1111 hex::encode(mac.finalize().into_bytes())
1212}
13
14#[cfg(test)]
15mod tests {
16 use super::*;
17
18 #[test]
19 fn signature_is_stable() {
20 let sig = sign_hmac_sha256(b"The quick brown fox jumps over the lazy dog", "key");
21 assert_eq!(
22 sig,
23 "f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8"
24 );
25 }
26
27 #[test]
28 fn signature_changes_with_payload() {
29 let a = sign_hmac_sha256(b"hello", "secret");
30 let b = sign_hmac_sha256(b"world", "secret");
31 assert_ne!(a, b);
32 assert_eq!(a.len(), 64);
33 }
34
35 #[test]
36 fn signature_is_hex() {
37 let sig = sign_hmac_sha256(b"payload", "s3cret");
38 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
39 }
40}