@@ -595,3 +595,174 @@ func TestEntityFullLifecycle(t *testing.T) {
595595 t .Fatalf ("expected exit 1 after delete, got %d" , r .exitCode )
596596 }
597597}
598+
599+ func setupGatedPhaseWithUnresolvedQuestion (t * testing.T , dir , dbFile string ) {
600+ t .Helper ()
601+ cmds := [][]string {
602+ {"--db" , dbFile , "entity" , "add" , "--type" , "phase" , "--id" , "PHS-001" , "--title" , "Phase 1" , "--status" , "active" },
603+ {"--db" , dbFile , "entity" , "add" , "--type" , "question" , "--id" , "QST-001" , "--title" , "Unresolved question" , "--status" , "active" },
604+ {"--db" , dbFile , "relation" , "add" , "--from" , "PHS-001" , "--to" , "QST-001" , "--type" , "covers" },
605+ }
606+ for _ , args := range cmds {
607+ r := runCLI (t , dir , args ... )
608+ if r .exitCode != 0 {
609+ t .Fatalf ("setup failed (%v): exit=%d stderr=%s" , args , r .exitCode , r .stderr )
610+ }
611+ }
612+ }
613+
614+ func TestGateBlocksPhaseResolution (t * testing.T ) {
615+ dbFile := initTestProject (t )
616+ dir := t .TempDir ()
617+ setupGatedPhaseWithUnresolvedQuestion (t , dir , dbFile )
618+
619+ r := runCLI (t , dir , "--db" , dbFile , "entity" , "update" , "PHS-001" , "--status" , "resolved" )
620+ if r .exitCode != 2 {
621+ t .Fatalf ("expected exit 2 (gate blocked), got %d; stdout=%s stderr=%s" , r .exitCode , r .stdout , r .stderr )
622+ }
623+
624+ var resp jsoncontract.EntityUpdateGateResponse
625+ if err := json .Unmarshal ([]byte (r .stdout ), & resp ); err != nil {
626+ t .Fatalf ("unmarshal gate response: %v\n raw: %s" , err , r .stdout )
627+ }
628+ if ! resp .Blocked {
629+ t .Error ("expected blocked=true" )
630+ }
631+ if resp .EntityID != "PHS-001" {
632+ t .Errorf ("entity_id = %q; want PHS-001" , resp .EntityID )
633+ }
634+ if resp .EntityType != "phase" {
635+ t .Errorf ("entity_type = %q; want phase" , resp .EntityType )
636+ }
637+ if resp .FromStatus != "active" {
638+ t .Errorf ("from_status = %q; want active" , resp .FromStatus )
639+ }
640+ if resp .ToStatus != "resolved" {
641+ t .Errorf ("to_status = %q; want resolved" , resp .ToStatus )
642+ }
643+ if len (resp .Issues ) == 0 {
644+ t .Fatal ("expected at least one blocking issue" )
645+ }
646+ foundGatesIssue := false
647+ for _ , iss := range resp .Issues {
648+ if iss .Check == "gates" && iss .Entity == "QST-001" {
649+ foundGatesIssue = true
650+ break
651+ }
652+ }
653+ if ! foundGatesIssue {
654+ t .Errorf ("expected gates issue for QST-001; got issues: %+v" , resp .Issues )
655+ }
656+ if resp .Summary .TotalIssues < 1 {
657+ t .Errorf ("summary.total_issues = %d; want >= 1" , resp .Summary .TotalIssues )
658+ }
659+ }
660+
661+ func TestGateForceBypassWithReason (t * testing.T ) {
662+ dbFile := initTestProject (t )
663+ dir := t .TempDir ()
664+ setupGatedPhaseWithUnresolvedQuestion (t , dir , dbFile )
665+
666+ r := runCLI (t , dir , "--db" , dbFile , "entity" , "update" , "PHS-001" ,
667+ "--status" , "resolved" , "--force" , "--reason" , "override for testing" )
668+ if r .exitCode != 0 {
669+ t .Fatalf ("expected exit 0 (force bypass), got %d; stdout=%s stderr=%s" , r .exitCode , r .stdout , r .stderr )
670+ }
671+
672+ var resp jsoncontract.EntityResponse
673+ if err := json .Unmarshal ([]byte (r .stdout ), & resp ); err != nil {
674+ t .Fatalf ("unmarshal entity response: %v\n raw: %s" , err , r .stdout )
675+ }
676+ if resp .Entity .ID != "PHS-001" {
677+ t .Errorf ("entity.id = %q; want PHS-001" , resp .Entity .ID )
678+ }
679+ if resp .Entity .Status != model .EntityStatusResolved {
680+ t .Errorf ("entity.status = %q; want resolved" , resp .Entity .Status )
681+ }
682+
683+ if r .stderr == "" {
684+ t .Fatal ("expected warnings on stderr" )
685+ }
686+ var warnings []jsoncontract.ValidateIssue
687+ if err := json .Unmarshal ([]byte (r .stderr ), & warnings ); err != nil {
688+ t .Fatalf ("unmarshal stderr warnings: %v\n raw: %s" , err , r .stderr )
689+ }
690+ if len (warnings ) == 0 {
691+ t .Error ("expected at least one warning in stderr" )
692+ }
693+ }
694+
695+ func TestGateForceWithoutReasonFails (t * testing.T ) {
696+ dbFile := initTestProject (t )
697+ dir := t .TempDir ()
698+ setupGatedPhaseWithUnresolvedQuestion (t , dir , dbFile )
699+
700+ r := runCLI (t , dir , "--db" , dbFile , "entity" , "update" , "PHS-001" ,
701+ "--status" , "resolved" , "--force" )
702+ if r .exitCode != 3 {
703+ t .Fatalf ("expected exit 3 (INVALID_INPUT), got %d; stdout=%s stderr=%s" , r .exitCode , r .stdout , r .stderr )
704+ }
705+
706+ var errResp jsoncontract.ErrorResponse
707+ if err := json .Unmarshal ([]byte (r .stderr ), & errResp ); err != nil {
708+ t .Fatalf ("unmarshal error response: %v\n raw: %s" , err , r .stderr )
709+ }
710+ if errResp .Error .Code != "INVALID_INPUT" {
711+ t .Errorf ("error.code = %q; want INVALID_INPUT" , errResp .Error .Code )
712+ }
713+ }
714+
715+ func TestGateNotAppliedForNonResolvedTransition (t * testing.T ) {
716+ dbFile := initTestProject (t )
717+ dir := t .TempDir ()
718+
719+ setupGatedPhaseWithUnresolvedQuestion (t , dir , dbFile )
720+
721+ r := runCLI (t , dir , "--db" , dbFile , "entity" , "update" , "PHS-001" , "--status" , "draft" )
722+ if r .exitCode != 0 {
723+ t .Fatalf ("expected exit 0 for non-gated transition, got %d; stdout=%s stderr=%s" , r .exitCode , r .stdout , r .stderr )
724+ }
725+
726+ var resp jsoncontract.EntityResponse
727+ if err := json .Unmarshal ([]byte (r .stdout ), & resp ); err != nil {
728+ t .Fatalf ("unmarshal: %v\n raw: %s" , err , r .stdout )
729+ }
730+ if resp .Entity .Status != model .EntityStatusDraft {
731+ t .Errorf ("status = %q; want draft" , resp .Entity .Status )
732+ }
733+ }
734+
735+ func TestGatePassesWhenNoIssues (t * testing.T ) {
736+ dbFile := initTestProject (t )
737+ dir := t .TempDir ()
738+
739+ cmds := [][]string {
740+ {"--db" , dbFile , "entity" , "add" , "--type" , "phase" , "--id" , "PHS-001" , "--title" , "Phase 1" , "--status" , "active" },
741+ {"--db" , dbFile , "entity" , "add" , "--type" , "question" , "--id" , "QST-001" , "--title" , "Resolved question" , "--status" , "active" },
742+ {"--db" , dbFile , "entity" , "add" , "--type" , "decision" , "--id" , "DEC-001" , "--title" , "Answer" , "--status" , "active" },
743+ {"--db" , dbFile , "relation" , "add" , "--from" , "PHS-001" , "--to" , "QST-001" , "--type" , "covers" },
744+ {"--db" , dbFile , "relation" , "add" , "--from" , "DEC-001" , "--to" , "QST-001" , "--type" , "answers" },
745+ }
746+ for _ , args := range cmds {
747+ r := runCLI (t , dir , args ... )
748+ if r .exitCode != 0 {
749+ t .Fatalf ("setup failed (%v): exit=%d stderr=%s" , args , r .exitCode , r .stderr )
750+ }
751+ }
752+
753+ r := runCLI (t , dir , "--db" , dbFile , "entity" , "update" , "PHS-001" , "--status" , "resolved" )
754+ if r .exitCode != 0 {
755+ t .Fatalf ("expected exit 0 (gate passes), got %d; stdout=%s stderr=%s" , r .exitCode , r .stdout , r .stderr )
756+ }
757+
758+ var resp jsoncontract.EntityResponse
759+ if err := json .Unmarshal ([]byte (r .stdout ), & resp ); err != nil {
760+ t .Fatalf ("unmarshal: %v\n raw: %s" , err , r .stdout )
761+ }
762+ if resp .Entity .ID != "PHS-001" {
763+ t .Errorf ("entity.id = %q; want PHS-001" , resp .Entity .ID )
764+ }
765+ if resp .Entity .Status != model .EntityStatusResolved {
766+ t .Errorf ("entity.status = %q; want resolved" , resp .Entity .Status )
767+ }
768+ }
0 commit comments