44
55use axum:: http:: StatusCode ;
66use leanspec_http:: { create_router, AppState , ServerConfig } ;
7+ use serde_json:: Value ;
78use std:: fs;
89use tempfile:: TempDir ;
910
@@ -96,6 +97,35 @@ This spec is complete.
9697 . unwrap ( ) ;
9798}
9899
100+ /// Create a project with an invalid spec for validation tests
101+ fn create_invalid_project ( dir : & std:: path:: Path ) {
102+ let specs_dir = dir. join ( "specs" ) ;
103+ fs:: create_dir_all ( & specs_dir) . unwrap ( ) ;
104+
105+ let spec_dir = specs_dir. join ( "004-invalid-spec" ) ;
106+ fs:: create_dir_all ( & spec_dir) . unwrap ( ) ;
107+ fs:: write (
108+ spec_dir. join ( "README.md" ) ,
109+ r#"---
110+ status: planned
111+ created: '2025-02-28'
112+ priority: medium
113+ tags:
114+ - invalid
115+ depends_on:
116+ - "" # intentionally empty to trigger validation error
117+ ---
118+
119+ # Invalid Spec
120+
121+ ## Overview
122+
123+ Empty dependency should surface validation errors.
124+ "# ,
125+ )
126+ . unwrap ( ) ;
127+ }
128+
99129/// Create a test state with a project (async version)
100130async fn create_test_state ( temp_dir : & TempDir ) -> AppState {
101131 create_test_project ( temp_dir. path ( ) ) ;
@@ -192,6 +222,75 @@ async fn test_list_projects() {
192222 assert ! ( body. contains( "projects" ) ) ;
193223}
194224
225+ #[ tokio:: test]
226+ async fn test_add_project_and_get_detail ( ) {
227+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
228+ create_test_project ( temp_dir. path ( ) ) ;
229+
230+ let config = ServerConfig :: default ( ) ;
231+ let registry = leanspec_http:: ProjectRegistry :: default ( ) ;
232+ let state = AppState :: with_registry ( config, registry) ;
233+ let app = create_router ( state) ;
234+
235+ let ( status, body) = make_json_request (
236+ app. clone ( ) ,
237+ "POST" ,
238+ "/api/projects" ,
239+ & serde_json:: json!( { "path" : temp_dir. path( ) . to_string_lossy( ) } ) . to_string ( ) ,
240+ )
241+ . await ;
242+
243+ assert_eq ! ( status, StatusCode :: OK ) ;
244+ let project: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
245+ let project_id = project[ "id" ] . as_str ( ) . unwrap ( ) ;
246+ assert ! ( project. get( "specsDir" ) . is_some( ) ) ;
247+ assert ! ( project. get( "lastAccessed" ) . is_some( ) ) ;
248+ assert ! ( project. get( "addedAt" ) . is_some( ) ) ;
249+
250+ let ( status, body) = make_request ( app. clone ( ) , "GET" , "/api/projects" ) . await ;
251+ assert_eq ! ( status, StatusCode :: OK ) ;
252+ let projects: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
253+ assert_eq ! ( projects[ "projects" ] . as_array( ) . unwrap( ) . len( ) , 1 ) ;
254+ assert_eq ! ( projects[ "currentProjectId" ] . as_str( ) , Some ( project_id) ) ;
255+ }
256+
257+ #[ tokio:: test]
258+ async fn test_update_project_and_toggle_favorite ( ) {
259+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
260+ let state = create_test_state ( & temp_dir) . await ;
261+ let app = create_router ( state) ;
262+
263+ let ( status, body) = make_request ( app. clone ( ) , "GET" , "/api/projects" ) . await ;
264+ assert_eq ! ( status, StatusCode :: OK ) ;
265+ let projects: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
266+ let project_id = projects[ "projects" ] [ 0 ] [ "id" ] . as_str ( ) . unwrap ( ) ;
267+
268+ let ( status, body) = make_json_request (
269+ app. clone ( ) ,
270+ "PATCH" ,
271+ & format ! ( "/api/projects/{project_id}" ) ,
272+ & serde_json:: json!( {
273+ "name" : "Updated Project" ,
274+ "favorite" : true ,
275+ "color" : "#ffcc00"
276+ } )
277+ . to_string ( ) ,
278+ )
279+ . await ;
280+
281+ assert_eq ! ( status, StatusCode :: OK ) ;
282+ let updated: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
283+ assert_eq ! ( updated[ "name" ] , "Updated Project" ) ;
284+ assert_eq ! ( updated[ "favorite" ] , true ) ;
285+ assert_eq ! ( updated[ "color" ] , "#ffcc00" ) ;
286+
287+ let ( status, body) =
288+ make_request ( app. clone ( ) , "POST" , & format ! ( "/api/projects/{project_id}/favorite" ) ) . await ;
289+ assert_eq ! ( status, StatusCode :: OK ) ;
290+ let toggled: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
291+ assert_eq ! ( toggled[ "favorite" ] , false ) ;
292+ }
293+
195294#[ tokio:: test]
196295async fn test_specs_without_project_selected ( ) {
197296 let config = ServerConfig :: default ( ) ;
@@ -220,6 +319,25 @@ async fn test_list_specs_with_project() {
220319 assert ! ( body. contains( "003-complete-spec" ) ) ;
221320}
222321
322+ #[ tokio:: test]
323+ async fn test_list_specs_filters_and_camelcase ( ) {
324+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
325+ let state = create_test_state ( & temp_dir) . await ;
326+ let app = create_router ( state) ;
327+
328+ let ( status, body) = make_request ( app. clone ( ) , "GET" , "/api/specs?status=in-progress" ) . await ;
329+
330+ assert_eq ! ( status, StatusCode :: OK ) ;
331+ let specs: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
332+ assert_eq ! ( specs[ "total" ] , 1 ) ;
333+ let spec = & specs[ "specs" ] [ 0 ] ;
334+ assert_eq ! ( spec[ "status" ] , "in-progress" ) ;
335+ assert ! ( spec. get( "specNumber" ) . is_some( ) ) ;
336+ assert ! ( spec. get( "specName" ) . is_some( ) ) ;
337+ assert ! ( spec. get( "filePath" ) . is_some( ) ) ;
338+ assert ! ( spec. get( "spec_name" ) . is_none( ) ) ;
339+ }
340+
223341#[ tokio:: test]
224342async fn test_get_spec_detail ( ) {
225343 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
@@ -234,6 +352,66 @@ async fn test_get_spec_detail() {
234352 assert ! ( body. contains( "contentMd" ) ) ;
235353}
236354
355+ #[ tokio:: test]
356+ async fn test_switch_project_and_refresh_cleanup ( ) {
357+ let first_project = TempDir :: new ( ) . unwrap ( ) ;
358+ let second_project = TempDir :: new ( ) . unwrap ( ) ;
359+ create_test_project ( first_project. path ( ) ) ;
360+ create_test_project ( second_project. path ( ) ) ;
361+
362+ let config = ServerConfig :: default ( ) ;
363+ let registry = leanspec_http:: ProjectRegistry :: default ( ) ;
364+ let state = AppState :: with_registry ( config, registry) ;
365+ let app = create_router ( state) ;
366+
367+ let ( _, body) = make_json_request (
368+ app. clone ( ) ,
369+ "POST" ,
370+ "/api/projects" ,
371+ & serde_json:: json!( { "path" : first_project. path( ) . to_string_lossy( ) } ) . to_string ( ) ,
372+ )
373+ . await ;
374+ let first_id = serde_json:: from_str :: < Value > ( & body) . unwrap ( ) [ "id" ]
375+ . as_str ( )
376+ . unwrap ( )
377+ . to_string ( ) ;
378+
379+ let ( _, body) = make_json_request (
380+ app. clone ( ) ,
381+ "POST" ,
382+ "/api/projects" ,
383+ & serde_json:: json!( { "path" : second_project. path( ) . to_string_lossy( ) } ) . to_string ( ) ,
384+ )
385+ . await ;
386+ let second_id = serde_json:: from_str :: < Value > ( & body) . unwrap ( ) [ "id" ]
387+ . as_str ( )
388+ . unwrap ( )
389+ . to_string ( ) ;
390+
391+ let ( _, body) = make_request ( app. clone ( ) , "GET" , "/api/projects" ) . await ;
392+ let projects: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
393+ assert_eq ! ( projects[ "currentProjectId" ] . as_str( ) , Some ( second_id. as_str( ) ) ) ;
394+
395+ let ( status, body) =
396+ make_request ( app. clone ( ) , "POST" , & format ! ( "/api/projects/{first_id}/switch" ) ) . await ;
397+ assert_eq ! ( status, StatusCode :: OK ) ;
398+ let switched: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
399+ assert_eq ! ( switched[ "id" ] , first_id) ;
400+
401+ assert ! ( fs:: remove_dir_all( second_project. path( ) ) . is_ok( ) ) ;
402+ std:: thread:: sleep ( std:: time:: Duration :: from_millis ( 10 ) ) ;
403+ assert ! ( !second_project. path( ) . exists( ) ) ;
404+ let ( status, body) = make_request ( app. clone ( ) , "POST" , "/api/projects/refresh" ) . await ;
405+ assert_eq ! ( status, StatusCode :: OK ) ;
406+ let refresh: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
407+ assert_eq ! ( refresh[ "removed" ] . as_u64( ) , Some ( 1 ) ) ;
408+
409+ let ( _, body) = make_request ( app. clone ( ) , "GET" , "/api/projects" ) . await ;
410+ let projects: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
411+ assert_eq ! ( projects[ "projects" ] . as_array( ) . unwrap( ) . len( ) , 1 ) ;
412+ assert_eq ! ( projects[ "currentProjectId" ] . as_str( ) , Some ( first_id. as_str( ) ) ) ;
413+ }
414+
237415#[ tokio:: test]
238416async fn test_search_specs ( ) {
239417 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
@@ -266,6 +444,30 @@ async fn test_get_stats() {
266444 assert ! ( body. contains( "byPriority" ) ) ;
267445}
268446
447+ #[ tokio:: test]
448+ async fn test_stats_camel_case_structure_and_counts ( ) {
449+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
450+ let state = create_test_state ( & temp_dir) . await ;
451+ let app = create_router ( state) ;
452+
453+ let ( status, body) = make_request ( app. clone ( ) , "GET" , "/api/stats" ) . await ;
454+
455+ assert_eq ! ( status, StatusCode :: OK ) ;
456+ let stats: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
457+ assert_eq ! ( stats[ "total" ] , 3 ) ;
458+
459+ let by_status = stats[ "byStatus" ] . as_object ( ) . unwrap ( ) ;
460+ assert_eq ! ( by_status. get( "planned" ) . and_then( |v| v. as_u64( ) ) , Some ( 1 ) ) ;
461+ assert_eq ! ( by_status. get( "inProgress" ) . and_then( |v| v. as_u64( ) ) , Some ( 1 ) ) ;
462+ assert_eq ! ( by_status. get( "complete" ) . and_then( |v| v. as_u64( ) ) , Some ( 1 ) ) ;
463+ assert ! ( by_status. get( "in_progress" ) . is_none( ) ) ;
464+
465+ let by_priority = stats[ "byPriority" ] . as_object ( ) . unwrap ( ) ;
466+ assert_eq ! ( by_priority. get( "high" ) . and_then( |v| v. as_u64( ) ) , Some ( 1 ) ) ;
467+ assert_eq ! ( by_priority. get( "medium" ) . and_then( |v| v. as_u64( ) ) , Some ( 1 ) ) ;
468+ assert_eq ! ( by_priority. get( "low" ) . and_then( |v| v. as_u64( ) ) , Some ( 1 ) ) ;
469+ }
470+
269471#[ tokio:: test]
270472async fn test_get_dependencies ( ) {
271473 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
@@ -292,6 +494,28 @@ async fn test_validate_all() {
292494 assert ! ( body. contains( "issues" ) ) ;
293495}
294496
497+ #[ tokio:: test]
498+ async fn test_validate_detects_invalid_frontmatter ( ) {
499+ let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
500+ create_invalid_project ( temp_dir. path ( ) ) ;
501+
502+ let config = ServerConfig :: default ( ) ;
503+ let registry = leanspec_http:: ProjectRegistry :: default ( ) ;
504+ let state = AppState :: with_registry ( config, registry) ;
505+ {
506+ let mut reg = state. registry . write ( ) . await ;
507+ let _ = reg. add ( temp_dir. path ( ) ) ;
508+ }
509+
510+ let app = create_router ( state) ;
511+ let ( status, body) = make_request ( app, "GET" , "/api/validate" ) . await ;
512+
513+ assert_eq ! ( status, StatusCode :: OK ) ;
514+ let validation: Value = serde_json:: from_str ( & body) . unwrap ( ) ;
515+ assert_eq ! ( validation[ "isValid" ] , false ) ;
516+ assert ! ( !validation[ "issues" ] . as_array( ) . unwrap( ) . is_empty( ) ) ;
517+ }
518+
295519#[ tokio:: test]
296520async fn test_validate_single_spec ( ) {
297521 let temp_dir = TempDir :: new ( ) . unwrap ( ) ;
0 commit comments