@@ -2118,6 +2118,148 @@ func TestUpgradeRepairDryRunAndApply(t *testing.T) {
21182118 t .Fatalf ("expected missing provenance to remain blocked, got %+v" , diagnosis )
21192119 }
21202120 })
2121+
2122+ // Regression test for GitHub issue #446: when both repairable and blocked
2123+ // mutations coexist, RepairCloudUpgrade(apply=true) must apply the
2124+ // repairable subset and return Applied:true with Class=Blocked (and the
2125+ // message must reference the actual blocker, not the low-seq repairable
2126+ // entry that happens to be first in Findings order).
2127+ t .Run ("partial apply: repairable mutations applied even when a blocker is queued" , func (t * testing.T ) {
2128+ s := newTestStore (t )
2129+ if err := s .CreateSession ("partial-repair-s1" , "partial-repair-proj" , "/tmp/partial-repair" ); err != nil {
2130+ t .Fatalf ("create session: %v" , err )
2131+ }
2132+
2133+ // Create an observation so we have authoritative local state for the
2134+ // repairable mutation.
2135+ obsID , err := s .AddObservation (AddObservationParams {
2136+ SessionID : "partial-repair-s1" ,
2137+ Type : "decision" ,
2138+ Title : "Authoritative title" ,
2139+ Content : "Authoritative content" ,
2140+ Project : "partial-repair-proj" ,
2141+ Scope : "project" ,
2142+ })
2143+ if err != nil {
2144+ t .Fatalf ("add observation: %v" , err )
2145+ }
2146+ if err := s .EnrollProject ("partial-repair-proj" ); err != nil {
2147+ t .Fatalf ("enroll project: %v" , err )
2148+ }
2149+
2150+ // Look up the observation's sync_id for payload construction.
2151+ var obsSyncID string
2152+ if err := s .db .QueryRow (`SELECT sync_id FROM observations WHERE id = ?` , obsID ).Scan (& obsSyncID ); err != nil {
2153+ t .Fatalf ("lookup observation sync_id: %v" , err )
2154+ }
2155+
2156+ // Insert a REPAIRABLE observation mutation (missing title — low seq,
2157+ // will naturally come before the blocker we insert next).
2158+ repairablePayload := `{"sync_id":"` + obsSyncID + `","session_id":"partial-repair-s1","type":"decision","content":"legacy payload missing title","scope":"project"}`
2159+ if _ , err := s .execHook (s .db ,
2160+ `INSERT INTO sync_mutations (target_key, entity, entity_key, op, payload, source, project) VALUES (?, ?, ?, ?, ?, ?, ?)` ,
2161+ DefaultSyncTargetKey ,
2162+ SyncEntityObservation ,
2163+ obsSyncID ,
2164+ SyncOpUpsert ,
2165+ repairablePayload ,
2166+ SyncSourceLocal ,
2167+ "partial-repair-proj" ,
2168+ ); err != nil {
2169+ t .Fatalf ("insert repairable observation mutation: %v" , err )
2170+ }
2171+
2172+ // Create two observations for source/target of the relation we use as
2173+ // the blocker mutation.
2174+ srcID , err := s .AddObservation (AddObservationParams {SessionID : "partial-repair-s1" , Type : "decision" , Title : "Src" , Content : "src" , Project : "partial-repair-proj" , Scope : "project" })
2175+ if err != nil {
2176+ t .Fatalf ("add source observation: %v" , err )
2177+ }
2178+ dstID , err := s .AddObservation (AddObservationParams {SessionID : "partial-repair-s1" , Type : "decision" , Title : "Dst" , Content : "dst" , Project : "partial-repair-proj" , Scope : "project" })
2179+ if err != nil {
2180+ t .Fatalf ("add dest observation: %v" , err )
2181+ }
2182+ var srcSyncID , dstSyncID string
2183+ if err := s .db .QueryRow (`SELECT sync_id FROM observations WHERE id = ?` , srcID ).Scan (& srcSyncID ); err != nil {
2184+ t .Fatalf ("lookup src sync_id: %v" , err )
2185+ }
2186+ if err := s .db .QueryRow (`SELECT sync_id FROM observations WHERE id = ?` , dstID ).Scan (& dstSyncID ); err != nil {
2187+ t .Fatalf ("lookup dst sync_id: %v" , err )
2188+ }
2189+
2190+ // Insert a BLOCKED relation mutation (missing provenance — high seq,
2191+ // comes after the repairable entry so Findings[0] would point to the
2192+ // repairable entry without the fix).
2193+ const blockerEntityKey = "rel-partial-blocked-446"
2194+ blockerPayload := `{"sync_id":"` + blockerEntityKey + `","source_id":"` + srcSyncID + `","target_id":"` + dstSyncID + `","relation":"compatible","project":"partial-repair-proj"}`
2195+ if _ , err := s .execHook (s .db ,
2196+ `INSERT INTO sync_mutations (target_key, entity, entity_key, op, payload, source, project) VALUES (?, ?, ?, ?, ?, ?, ?)` ,
2197+ DefaultSyncTargetKey ,
2198+ SyncEntityRelation ,
2199+ blockerEntityKey ,
2200+ SyncOpUpsert ,
2201+ blockerPayload ,
2202+ SyncSourceLocal ,
2203+ "partial-repair-proj" ,
2204+ ); err != nil {
2205+ t .Fatalf ("insert blocked relation mutation: %v" , err )
2206+ }
2207+
2208+ // Confirm pre-conditions: diagnosis must see exactly one repairable
2209+ // and one blocked entry.
2210+ diag , err := s .DiagnoseCloudUpgradeLegacyMutations ("partial-repair-proj" )
2211+ if err != nil {
2212+ t .Fatalf ("pre-condition diagnose: %v" , err )
2213+ }
2214+ if diag .RepairableCount != 1 || diag .BlockedCount != 1 {
2215+ t .Fatalf ("expected 1 repairable + 1 blocked pre-condition, got %+v" , diag )
2216+ }
2217+
2218+ // Find the seq of the blocked finding so we can assert the message
2219+ // references the right entry.
2220+ var blockerSeq int64
2221+ for _ , f := range diag .Findings {
2222+ if ! f .Repairable {
2223+ blockerSeq = f .Seq
2224+ break
2225+ }
2226+ }
2227+ if blockerSeq == 0 {
2228+ t .Fatal ("pre-condition: could not locate blocked finding seq" )
2229+ }
2230+ // Also verify that Findings[0] is NOT the blocker (i.e. lowest-seq is
2231+ // the repairable one); this is the exact condition that triggered the
2232+ // wrong-message bug.
2233+ if diag .Findings [0 ].Seq == blockerSeq {
2234+ t .Fatalf ("pre-condition: expected Findings[0] to be repairable (lowest seq), but got blocker seq=%d" , blockerSeq )
2235+ }
2236+
2237+ // === THE ACTUAL ASSERTION ===
2238+ // With apply=true, repairable mutations must be applied and the
2239+ // report must still surface the blocker clearly.
2240+ report , err := s .RepairCloudUpgrade ("partial-repair-proj" , true )
2241+ if err != nil {
2242+ t .Fatalf ("repair with mixed repairable+blocker: %v" , err )
2243+ }
2244+
2245+ // Applied MUST be true: at least the repairable mutation was applied.
2246+ if ! report .Applied {
2247+ t .Fatalf ("expected Applied=true (repairable subset was processed), got %+v" , report )
2248+ }
2249+ // Class MUST be Blocked because the non-repairable mutation is still present.
2250+ if report .Class != UpgradeRepairClassBlocked {
2251+ t .Fatalf ("expected Class=%q, got %q (full report: %+v)" , UpgradeRepairClassBlocked , report .Class , report )
2252+ }
2253+ // The message must name the BLOCKER seq, not the repairable entry.
2254+ blockerSeqStr := fmt .Sprintf ("seq=%d" , blockerSeq )
2255+ if ! strings .Contains (report .Message , blockerSeqStr ) {
2256+ t .Fatalf ("expected message to reference blocker seq (%s), got %q" , blockerSeqStr , report .Message )
2257+ }
2258+ // The message must also include entity_key for debuggability.
2259+ if ! strings .Contains (report .Message , blockerEntityKey ) {
2260+ t .Fatalf ("expected message to include entity_key=%q, got %q" , blockerEntityKey , report .Message )
2261+ }
2262+ })
21212263}
21222264
21232265func TestRollbackCloudUpgradeSafetyBoundary (t * testing.T ) {
0 commit comments