@@ -12,6 +12,8 @@ import (
1212 "time"
1313
1414 "github.com/Gentleman-Programming/engram/internal/cloud/cloudstore"
15+ "github.com/Gentleman-Programming/engram/internal/store"
16+ engramsync "github.com/Gentleman-Programming/engram/internal/sync"
1517)
1618
1719// ─── Fakes for mutation tests ─────────────────────────────────────────────────
@@ -50,6 +52,125 @@ func (s *fakeMutationStore) IsProjectSyncEnabled(project string) (bool, error) {
5052 return true , nil // default: enabled
5153}
5254
55+ func (s * fakeMutationStore ) WriteChunk (ctx context.Context , project string , chunkID , createdBy , clientCreatedAt string , payload []byte ) error {
56+ if _ , exists := s .chunks [chunkID ]; exists {
57+ return s .fakeStore .WriteChunk (ctx , project , chunkID , createdBy , clientCreatedAt , payload )
58+ }
59+ if err := s .fakeStore .WriteChunk (ctx , project , chunkID , createdBy , clientCreatedAt , payload ); err != nil {
60+ return err
61+ }
62+ var chunk engramsync.ChunkData
63+ if err := json .Unmarshal (payload , & chunk ); err != nil {
64+ return err
65+ }
66+ batch := make ([]MutationEntry , 0 , len (chunk .Sessions )+ len (chunk .Observations )+ len (chunk .Prompts ))
67+ for _ , session := range chunk .Sessions {
68+ body , _ := json .Marshal (session )
69+ batch = append (batch , MutationEntry {Project : project , Entity : store .SyncEntitySession , EntityKey : strings .TrimSpace (session .ID ), Op : store .SyncOpUpsert , Payload : body })
70+ }
71+ for _ , observation := range chunk .Observations {
72+ body , _ := json .Marshal (observation )
73+ batch = append (batch , MutationEntry {Project : project , Entity : store .SyncEntityObservation , EntityKey : strings .TrimSpace (observation .SyncID ), Op : store .SyncOpUpsert , Payload : body })
74+ }
75+ for _ , prompt := range chunk .Prompts {
76+ body , _ := json .Marshal (prompt )
77+ batch = append (batch , MutationEntry {Project : project , Entity : store .SyncEntityPrompt , EntityKey : strings .TrimSpace (prompt .SyncID ), Op : store .SyncOpUpsert , Payload : body })
78+ }
79+ _ , err := s .InsertMutationBatch (ctx , batch )
80+ return err
81+ }
82+
83+ func TestChunkPushMaterializesMutationsForAutosyncPull (t * testing.T ) {
84+ ms := newFakeMutationStore ()
85+ srv := newMutationTestServer (ms , "secret" , []string {"proj-a" })
86+ body := strings .NewReader (`{
87+ "project":"proj-a",
88+ "created_by":"tester",
89+ "data":{
90+ "sessions":[{"id":"s-1","directory":"/tmp/s-1","started_at":"2026-04-29T10:00:00Z"}],
91+ "observations":[{"sync_id":"obs-1","session_id":"s-1","type":"decision","title":"Decision","content":"Content","scope":"project","created_at":"2026-04-29T10:01:00Z","updated_at":"2026-04-29T10:01:00Z"}],
92+ "prompts":[{"sync_id":"prompt-1","session_id":"s-1","content":"Prompt","created_at":"2026-04-29T10:02:00Z"}]
93+ }
94+ }` )
95+
96+ pushRec := httptest .NewRecorder ()
97+ pushReq := httptest .NewRequest (http .MethodPost , "/sync/push" , body )
98+ pushReq .Header .Set ("Authorization" , "Bearer secret" )
99+ pushReq .Header .Set ("Content-Type" , "application/json" )
100+ srv .Handler ().ServeHTTP (pushRec , pushReq )
101+ if pushRec .Code != http .StatusOK {
102+ t .Fatalf ("expected chunk push 200, got %d body=%q" , pushRec .Code , pushRec .Body .String ())
103+ }
104+ if len (ms .chunks ) != 1 {
105+ t .Fatalf ("expected one stored chunk, got %d" , len (ms .chunks ))
106+ }
107+ if len (ms .mutations ) != 3 {
108+ t .Fatalf ("expected 3 materialized mutations, got %d: %+v" , len (ms .mutations ), ms .mutations )
109+ }
110+ if ms .mutations [0 ].Entity != store .SyncEntitySession || ms .mutations [1 ].Entity != store .SyncEntityObservation || ms .mutations [2 ].Entity != store .SyncEntityPrompt {
111+ t .Fatalf ("expected session/observation/prompt order, got %+v" , ms .mutations )
112+ }
113+ if ms .mutations [1 ].Project != "proj-a" || ms .mutations [1 ].EntityKey != "obs-1" || ms .mutations [1 ].Op != store .SyncOpUpsert {
114+ t .Fatalf ("unexpected materialized observation mutation: %+v" , ms .mutations [1 ])
115+ }
116+
117+ pullRec := httptest .NewRecorder ()
118+ pullReq := httptest .NewRequest (http .MethodGet , "/sync/mutations/pull?since_seq=0&limit=100" , nil )
119+ pullReq .Header .Set ("Authorization" , "Bearer secret" )
120+ srv .Handler ().ServeHTTP (pullRec , pullReq )
121+ if pullRec .Code != http .StatusOK {
122+ t .Fatalf ("expected mutation pull 200, got %d body=%q" , pullRec .Code , pullRec .Body .String ())
123+ }
124+ var pulled struct {
125+ Mutations []StoredMutation `json:"mutations"`
126+ }
127+ if err := json .NewDecoder (pullRec .Body ).Decode (& pulled ); err != nil {
128+ t .Fatalf ("decode pull response: %v" , err )
129+ }
130+ if len (pulled .Mutations ) != 3 || pulled .Mutations [1 ].Entity != store .SyncEntityObservation || pulled .Mutations [1 ].EntityKey != "obs-1" {
131+ t .Fatalf ("expected pulled observation mutation after chunk push, got %+v" , pulled .Mutations )
132+ }
133+ }
134+
135+ func TestChunkPushReplayDoesNotDuplicateMaterializedMutations (t * testing.T ) {
136+ ms := newFakeMutationStore ()
137+ srv := newMutationTestServer (ms , "secret" , []string {"proj-a" })
138+ body := `{"project":"proj-a","created_by":"tester","data":{"sessions":[{"id":"s-1","directory":"/tmp/s-1"}],"observations":[{"sync_id":"obs-1","session_id":"s-1","type":"decision","title":"Decision","content":"Content","scope":"project"}]}}`
139+
140+ for i := 0 ; i < 2 ; i ++ {
141+ rec := httptest .NewRecorder ()
142+ req := httptest .NewRequest (http .MethodPost , "/sync/push" , strings .NewReader (body ))
143+ req .Header .Set ("Authorization" , "Bearer secret" )
144+ req .Header .Set ("Content-Type" , "application/json" )
145+ srv .Handler ().ServeHTTP (rec , req )
146+ if rec .Code != http .StatusOK {
147+ t .Fatalf ("push %d expected 200, got %d body=%q" , i + 1 , rec .Code , rec .Body .String ())
148+ }
149+ }
150+ if len (ms .chunks ) != 1 {
151+ t .Fatalf ("expected one stored chunk after replay, got %d" , len (ms .chunks ))
152+ }
153+ if len (ms .mutations ) != 2 {
154+ t .Fatalf ("expected replay to keep 2 materialized mutations, got %d: %+v" , len (ms .mutations ), ms .mutations )
155+ }
156+ }
157+
158+ func TestMalformedChunkRejectsBeforeMaterialization (t * testing.T ) {
159+ ms := newFakeMutationStore ()
160+ srv := newMutationTestServer (ms , "secret" , []string {"proj-a" })
161+ rec := httptest .NewRecorder ()
162+ req := httptest .NewRequest (http .MethodPost , "/sync/push" , strings .NewReader (`{"project":"proj-a","created_by":"tester","data":{"observations":[{"sync_id":"obs-1","session_id":"missing","type":"decision","title":"Decision","content":"Content","scope":"project"}]}}` ))
163+ req .Header .Set ("Authorization" , "Bearer secret" )
164+ req .Header .Set ("Content-Type" , "application/json" )
165+ srv .Handler ().ServeHTTP (rec , req )
166+ if rec .Code != http .StatusBadRequest {
167+ t .Fatalf ("expected malformed chunk 400, got %d body=%q" , rec .Code , rec .Body .String ())
168+ }
169+ if len (ms .chunks ) != 0 || len (ms .mutations ) != 0 {
170+ t .Fatalf ("expected no chunk or mutation after malformed push, chunks=%d mutations=%d" , len (ms .chunks ), len (ms .mutations ))
171+ }
172+ }
173+
53174func (s * fakeMutationStore ) InsertMutationBatch (ctx context.Context , batch []MutationEntry ) ([]int64 , error ) {
54175 if s .errInsert != nil {
55176 return nil , s .errInsert
0 commit comments