diff --git a/internal/db/gorm/observation_store.go b/internal/db/gorm/observation_store.go index 1da0ae0..96e6ec6 100644 --- a/internal/db/gorm/observation_store.go +++ b/internal/db/gorm/observation_store.go @@ -252,9 +252,6 @@ func (s *ObservationStore) UpdateObservation(ctx context.Context, id int64, upda updates["scope"] = sql.NullString{String: *update.Scope, Valid: true} } - // Add updated_at timestamp - updates["updated_at_epoch"] = sql.NullInt64{Int64: time.Now().Unix(), Valid: true} - if len(updates) == 0 { // Nothing to update, just return existing observation return toModelObservation(&dbObs), nil diff --git a/internal/db/gorm/observation_update_test.go b/internal/db/gorm/observation_update_test.go new file mode 100644 index 0000000..e46167e --- /dev/null +++ b/internal/db/gorm/observation_update_test.go @@ -0,0 +1,94 @@ +package gorm + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/lukaszraczylo/claude-mnemonic/pkg/models" +) + +func storeForUpdate(t *testing.T) (*ObservationStore, int64, func()) { + t.Helper() + obsStore, store, cleanup := testObservationStore(t) + ctx := context.Background() + sessionID, err := NewSessionStore(store).CreateSDKSession(ctx, "claude-1", "test-project", "") + require.NoError(t, err) + id, _, err := obsStore.StoreObservation(ctx, "claude-1", "test-project", &models.ParsedObservation{ + Type: models.ObsTypeDiscovery, Title: "Original title", Subtitle: "Original subtitle", Narrative: "Original narrative", + Facts: []string{"fact one"}, Concepts: []string{"cache"}, FilesRead: []string{"a.go"}, + }, int(sessionID), 1) + require.NoError(t, err) + return obsStore, id, cleanup +} + +func ptr[T any](v T) *T { return &v } + +func TestUpdateObservation_ChangesOnlyTheGivenFields(t *testing.T) { + obsStore, id, cleanup := storeForUpdate(t) + defer cleanup() + ctx := context.Background() + + got, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{Title: ptr("New title"), Narrative: ptr("New narrative")}) + require.NoError(t, err) + assert.Equal(t, "New title", got.Title.String) + assert.Equal(t, "New narrative", got.Narrative.String) + assert.Equal(t, "Original subtitle", got.Subtitle.String, "a field that was not given stays") + assert.Equal(t, []string{"fact one"}, []string(got.Facts)) + + stored, err := obsStore.GetObservationByID(ctx, id) + require.NoError(t, err) + assert.Equal(t, "New title", stored.Title.String, "and it is stored, not only returned") +} + +func TestUpdateObservation_ReplacesListsAndScope(t *testing.T) { + obsStore, id, cleanup := storeForUpdate(t) + defer cleanup() + ctx := context.Background() + + got, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{ + Facts: &[]string{"a", "b"}, Concepts: &[]string{}, FilesRead: &[]string{"x.go"}, FilesModified: &[]string{"y.go"}, Scope: ptr("global"), + }) + require.NoError(t, err) + assert.Equal(t, []string{"a", "b"}, []string(got.Facts)) + assert.Empty(t, got.Concepts, "an empty list clears the field") + assert.Equal(t, []string{"x.go"}, []string(got.FilesRead)) + assert.Equal(t, []string{"y.go"}, []string(got.FilesModified)) + assert.Equal(t, models.ScopeGlobal, got.Scope) +} + +func TestUpdateObservation_NothingToChangeReturnsTheObservation(t *testing.T) { + obsStore, id, cleanup := storeForUpdate(t) + defer cleanup() + + got, err := obsStore.UpdateObservation(context.Background(), id, &ObservationUpdate{}) + require.NoError(t, err) + assert.Equal(t, "Original title", got.Title.String) +} + +func TestUpdateObservation_Refusals(t *testing.T) { + obsStore, id, cleanup := storeForUpdate(t) + defer cleanup() + ctx := context.Background() + + _, err := obsStore.UpdateObservation(ctx, id, nil) + assert.Error(t, err, "a nil update") + _, err = obsStore.UpdateObservation(ctx, 999999, &ObservationUpdate{Title: ptr("x")}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestUpdateObservation_KeepsTheSearchIndexInStep(t *testing.T) { + obsStore, id, cleanup := storeForUpdate(t) + defer cleanup() + ctx := context.Background() + + _, err := obsStore.UpdateObservation(ctx, id, &ObservationUpdate{Title: ptr("Zebrafish enclosure")}) + require.NoError(t, err) + var n int64 + require.NoError(t, obsStore.db.WithContext(ctx).Raw( + `SELECT COUNT(*) FROM observations_fts WHERE observations_fts MATCH 'zebrafish'`).Scan(&n).Error) + assert.EqualValues(t, 1, n, "the full-text index sees the new title") +} diff --git a/internal/worker/handlers_update_test.go b/internal/worker/handlers_update_test.go new file mode 100644 index 0000000..a6ac33a --- /dev/null +++ b/internal/worker/handlers_update_test.go @@ -0,0 +1,59 @@ +package worker + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func doUpdateRequest(t *testing.T, svc *Service, method, target string, body any) *httptest.ResponseRecorder { + t.Helper() + var buf bytes.Buffer + if body != nil { + require.NoError(t, json.NewEncoder(&buf).Encode(body)) + } + rec := httptest.NewRecorder() + svc.router.ServeHTTP(rec, httptest.NewRequest(method, target, &buf)) + return rec +} + +func TestHandleUpdateObservation_EditsAndReturnsTheObservation(t *testing.T) { + svc, cleanup := testService(t) + defer cleanup() + id := createTestObservation(t, svc.observationStore, "proj_aaaaaa", "Old title", "Old narrative", []string{"cache"}) + + rec := doUpdateRequest(t, svc, http.MethodPut, fmt.Sprintf("/api/observations/%d", id), + map[string]any{"title": "New title", "facts": []string{"one", "two"}}) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var resp struct { + Observation struct { + Title string `json:"title"` + Narrative string `json:"narrative"` + Facts []string `json:"facts"` + } `json:"observation"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.Equal(t, "New title", resp.Observation.Title) + assert.Equal(t, "Old narrative", resp.Observation.Narrative) + assert.Equal(t, []string{"one", "two"}, resp.Observation.Facts) + + rec = doUpdateRequest(t, svc, http.MethodGet, fmt.Sprintf("/api/observations/%d", id), nil) + require.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "New title", "the edit is stored") +} + +func TestHandleUpdateObservation_Refusals(t *testing.T) { + svc, cleanup := testService(t) + defer cleanup() + id := createTestObservation(t, svc.observationStore, "proj_aaaaaa", "A title", "A narrative", nil) + + assert.Equal(t, http.StatusNotFound, doUpdateRequest(t, svc, http.MethodPut, "/api/observations/999999", map[string]any{"title": "x"}).Code) + assert.Equal(t, http.StatusBadRequest, doUpdateRequest(t, svc, http.MethodPut, fmt.Sprintf("/api/observations/%d", id), map[string]any{"scope": "galaxy"}).Code) + assert.Equal(t, http.StatusBadRequest, doUpdateRequest(t, svc, http.MethodPut, "/api/observations/abc", map[string]any{"title": "x"}).Code) +}