package main import ( "encoding/json" "testing" "time" "verstak/internal/core/activity" syncsvc "verstak/internal/core/sync" "verstak/internal/core/util" "verstak/internal/core/worklog" ) // nowISO returns the current time in RFC3339, usable as an activity_events.created_at. func nowISO() string { return time.Now().UTC().Format(time.RFC3339) } // insertTestEvent inserts an activity event at the given created_at and returns its ID. func insertTestEventAt(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title, createdAt string) string { t.Helper() id := util.UUID7() _, err := app.db.Exec( `INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at) VALUES(?,?,?,?,?,?,?,?,?)`, id, nodeID, eventType, targetType, targetID, "", title, "{}", createdAt) if err != nil { t.Fatalf("insert event: %v", err) } return id } // insertTestEvent creates a "now" event. func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string { return insertTestEventAt(t, app, nodeID, eventType, targetType, targetID, title, nowISO()) } // countLinked returns the number of worklog_entry_events for an entry. func countLinked(t *testing.T, app *App, entryID string) int { t.Helper() var n int if err := app.db.QueryRow(`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, entryID).Scan(&n); err != nil { t.Fatalf("count worklog_entry_events: %v", err) } return n } // countJoined returns the number of events in worklog_entry_events that successfully // join to activity_events for a given entry. func countJoined(t *testing.T, app *App, entryID string) int { t.Helper() var n int if err := app.db.QueryRow( `SELECT COUNT(*) FROM worklog_entry_events wle JOIN activity_events ae ON ae.id = wle.event_id WHERE wle.entry_id = ?`, entryID).Scan(&n); err != nil { t.Fatalf("join count: %v", err) } return n } // --------------------------------------------------------------------------- // Test 1: Full journal regression — GetSuggestions + Accept + verify // --------------------------------------------------------------------------- func TestJournalFullRegression(t *testing.T) { app, _ := setupTestApp(t) // Create a node n, err := app.CreateNodeFromTemplate("", "Regression Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } // Create activity events (use now() so GetSuggestions picks them up) eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Создана заметка") eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена") eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "f1", "Добавлен файл") // Call GetSuggestions suggestions, err := app.GetSuggestions() if err != nil { t.Fatalf("GetSuggestions: %v", err) } // Must find at least one suggestion for our node if len(suggestions) == 0 { t.Fatal("GetSuggestions returned 0 suggestions, expected at least 1") } var found bool for _, s := range suggestions { if s.NodeID == n.ID { found = true // Verify events match what we inserted if len(s.Events) != 3 { t.Fatalf("suggestion has %d events, want 3", len(s.Events)) } if len(s.EventIDs) != len(s.Events) { t.Fatalf("suggestion eventIds (%d) != events (%d)", len(s.EventIDs), len(s.Events)) } // Each eventId must be in the events list idSet := make(map[string]bool, len(s.Events)) for _, ev := range s.Events { idSet[ev.ID] = true } for _, eid := range s.EventIDs { if !idSet[eid] { t.Errorf("eventId %s not found in events list", eid) } } } } if !found { t.Fatalf("no suggestion found for node %s", n.ID) } // Accept the suggestion via AcceptSuggestionWith (JSON-serialised eventIDs) eventIDs := []string{eid1, eid2, eid3} eventIDsJSON, _ := json.Marshal(eventIDs) dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками и файлами", 20, "", string(eventIDsJSON)) if err != nil { t.Fatalf("AcceptSuggestionWith: %v", err) } // Check worklog_entry_events count if n := countLinked(t, app, dto.ID); n != 3 { t.Errorf("worklog_entry_events count = %d, want 3", n) } // Check JOIN with activity_events if n := countJoined(t, app, dto.ID); n != 3 { t.Errorf("JOIN count = %d, want 3", n) } // Check GetWorklogEntryEvents returns 3 events events, err := app.GetWorklogEntryEvents(dto.ID) if err != nil { t.Fatalf("GetWorklogEntryEvents: %v", err) } if len(events) != 3 { t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events)) } // All three event IDs must be present returnedIDs := make(map[string]bool, len(events)) for _, ev := range events { returnedIDs[ev.ID] = true } for _, eid := range eventIDs { if !returnedIDs[eid] { t.Errorf("event %s missing from GetWorklogEntryEvents", eid) } } // Source must be suggestion var src string app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src) if src != worklog.SourceSuggestion { t.Errorf("source = %q, want %q", src, worklog.SourceSuggestion) } } // --------------------------------------------------------------------------- // Test 2: Repeated activity on same node — suggestion must still appear // --------------------------------------------------------------------------- func TestSuggestionOnRepeatedActivity(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Repeat Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } // Create a first event and accept it eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "n1", "Первая заметка") eid1JSON, _ := json.Marshal([]string{eid1}) _, err = app.AcceptSuggestionWith(n.ID, "Создание заметки", 5, "", string(eid1JSON)) if err != nil { t.Fatalf("first AcceptSuggestionWith: %v", err) } // Now create a brand new event on the same node eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "n1", "Заметка изменена") // GetSuggestions must still return a suggestion for this node (new event is unaccounted) suggestions, err := app.GetSuggestions() if err != nil { t.Fatalf("GetSuggestions: %v", err) } var found bool for _, s := range suggestions { if s.NodeID == n.ID { found = true // Only the second event should be suggested if len(s.Events) != 1 { t.Fatalf("expected 1 new event, got %d", len(s.Events)) } if s.Events[0].ID != eid2 { t.Errorf("expected event %s, got %s", eid2, s.Events[0].ID) } if len(s.EventIDs) != 1 || s.EventIDs[0] != eid2 { t.Errorf("eventIds does not contain the new event") } } } if !found { t.Fatal("GetSuggestions did not return a suggestion for the new event — it should") } // Accept the new suggestion eid2JSON, _ := json.Marshal([]string{eid2}) dto2, err := app.AcceptSuggestionWith(n.ID, "Обновление заметки", 5, "", string(eid2JSON)) if err != nil { t.Fatalf("second AcceptSuggestionWith: %v", err) } // Each entry must have exactly 1 linked event if n := countLinked(t, app, dto2.ID); n != 1 { t.Errorf("second entry: worklog_entry_events count = %d, want 1", n) } } // --------------------------------------------------------------------------- // Test 3: Manual worklog entry — source, billable, details, empty events // --------------------------------------------------------------------------- func TestManualWorklogEntry(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Manual Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } // Create a manual entry dto, err := app.CreateWorklogFull(n.ID, "Ручная работа", "Подробное описание", "", 30, true, true) if err != nil { t.Fatalf("CreateWorklogFull: %v", err) } // Source must be manual var src string app.db.QueryRow(`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&src) if src != worklog.SourceManual { t.Errorf("source = %q, want %q", src, worklog.SourceManual) } // Billable, approximate, details must be correct var details string var billable, approximate int app.db.QueryRow( `SELECT details, billable, approximate FROM worklog_entries WHERE id = ?`, dto.ID, ).Scan(&details, &billable, &approximate) if details != "Подробное описание" { t.Errorf("details = %q, want %q", details, "Подробное описание") } if billable != 1 { t.Errorf("billable = %d, want 1", billable) } if approximate != 1 { t.Errorf("approximate = %d, want 1", approximate) } // Minutes must be 30 if dto.Minutes != 30 { t.Errorf("minutes = %d, want 30", dto.Minutes) } // GetWorklogEntryEvents must be empty for a manual entry events, err := app.GetWorklogEntryEvents(dto.ID) if err != nil { t.Fatalf("GetWorklogEntryEvents: %v", err) } if len(events) != 0 { t.Errorf("manual entry has %d linked events, want 0", len(events)) } // DTO should distinguish manual from suggestion if dto.Source != worklog.SourceManual { t.Errorf("dto.Source = %q, want %q", dto.Source, worklog.SourceManual) } } func hasWorklogSyncOp(t *testing.T, app *App, entryID, opType string) bool { t.Helper() ops, err := app.sync.GetUnpushedOps() if err != nil { t.Fatalf("GetUnpushedOps: %v", err) } for _, op := range ops { if op.EntityType == syncsvc.EntityWorklog && op.EntityID == entryID && op.OpType == opType { return true } } return false } func TestUpdateAndDeleteWorklogEntryBinding(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Editable Worklog Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } dto, err := app.CreateWorklogFull(n.ID, "Old summary", "Old details", "2026-01-01", 30, false, false) if err != nil { t.Fatalf("CreateWorklogFull: %v", err) } eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Связанное событие") if _, err := app.db.Exec(`INSERT INTO worklog_entry_events(entry_id,event_id) VALUES(?,?)`, dto.ID, eventID); err != nil { t.Fatalf("insert worklog event link: %v", err) } updated, err := app.UpdateWorklogEntry(dto.ID, "New summary", "New details", "2026-01-02", 45, true, true) if err != nil { t.Fatalf("UpdateWorklogEntry: %v", err) } if updated.Summary != "New summary" || updated.Details != "New details" || updated.Date != "2026-01-02" { t.Fatalf("updated DTO = %#v", updated) } if updated.Minutes != 45 || !updated.Approximate || !updated.Billable { t.Fatalf("updated flags/minutes = %#v", updated) } if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpUpdate) { t.Fatal("missing worklog update sync op") } if n := countLinked(t, app, dto.ID); n != 1 { t.Fatalf("event links after update = %d, want 1", n) } if err := app.DeleteWorklogEntry(dto.ID); err != nil { t.Fatalf("DeleteWorklogEntry: %v", err) } if _, err := app.worklog.Get(dto.ID); err == nil { t.Fatal("expected deleted worklog entry to be gone") } if n := countLinked(t, app, dto.ID); n != 0 { t.Fatalf("event links after delete = %d, want 0", n) } if !hasWorklogSyncOp(t, app, dto.ID, syncsvc.OpDelete) { t.Fatal("missing worklog delete sync op") } } func TestNodeJournalAggregatesDescendantWorklogAndActivity(t *testing.T) { app, _ := setupTestApp(t) parent, err := app.CreateNodeFromTemplate("", "Parent Project", "folder.default") if err != nil { t.Fatalf("create parent: %v", err) } child, err := app.CreateNodeFromTemplate(parent.ID, "Documents", "folder.default") if err != nil { t.Fatalf("create child: %v", err) } entry, err := app.CreateWorklogFull(child.ID, "Работа в документах", "details", "2026-06-05", 25, false, true) if err != nil { t.Fatalf("CreateWorklogFull: %v", err) } eventID := insertTestEvent(t, app, child.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл") parentLog, err := app.ListWorklog(parent.ID) if err != nil { t.Fatalf("ListWorklog(parent): %v", err) } if len(parentLog) != 1 || parentLog[0].ID != entry.ID { t.Fatalf("parent worklog = %+v, want descendant entry %s", parentLog, entry.ID) } if parentLog[0].NodeID != child.ID { t.Fatalf("entry NodeID = %q, want child %q", parentLog[0].NodeID, child.ID) } if parentLog[0].NodePath != "Parent Project > Documents" { t.Fatalf("entry NodePath = %q, want breadcrumb path", parentLog[0].NodePath) } parentActivity, err := app.ListActivityByNode(parent.ID, 50, 0) if err != nil { t.Fatalf("ListActivityByNode(parent): %v", err) } var foundEvent *EventDTO for i := range parentActivity { if parentActivity[i].ID == eventID { foundEvent = &parentActivity[i] } } if foundEvent == nil { t.Fatalf("parent activity = %+v, want descendant event %s", parentActivity, eventID) } if foundEvent.NodePath != "Parent Project > Documents" { t.Fatalf("event NodePath = %q, want breadcrumb path", foundEvent.NodePath) } var physicalEvents int if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ? AND node_id = ?`, eventID, child.ID).Scan(&physicalEvents); err != nil { t.Fatalf("count physical event: %v", err) } if physicalEvents != 1 { t.Fatalf("physical child event count = %d, want 1", physicalEvents) } var copiedToParent int if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE title = ? AND node_id = ?`, "Добавлен файл", parent.ID).Scan(&copiedToParent); err != nil { t.Fatalf("count copied parent event: %v", err) } if copiedToParent != 0 { t.Fatalf("copied parent events = %d, want 0", copiedToParent) } } func TestDismissSuggestionHidesSuggestionWithoutDeletingEvents(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Dismiss Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } eventID := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note-1", "Изменение заметки") if err := app.DismissSuggestion(n.ID, string(mustJSON(t, []string{eventID}))); err != nil { t.Fatalf("DismissSuggestion: %v", err) } suggestions, err := app.GetSuggestions() if err != nil { t.Fatalf("GetSuggestions: %v", err) } for _, s := range suggestions { if s.NodeID == n.ID { t.Fatalf("dismissed suggestion still visible: %+v", s) } } var eventCount int if err := app.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eventID).Scan(&eventCount); err != nil { t.Fatalf("count event: %v", err) } if eventCount != 1 { t.Fatalf("activity event count = %d, want 1", eventCount) } } func mustJSON(t *testing.T, value any) []byte { t.Helper() data, err := json.Marshal(value) if err != nil { t.Fatalf("marshal json: %v", err) } return data } func TestApplyRemoteWorklogUpdate(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Remote Worklog Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } dto, err := app.CreateWorklogFull(n.ID, "Before remote", "", "2026-01-03", 15, false, false) if err != nil { t.Fatalf("CreateWorklogFull: %v", err) } payload, err := json.Marshal(map[string]interface{}{ "id": dto.ID, "node_id": n.ID, "summary": "After remote", "details": "Remote details", "minutes": 75, "date": "2026-01-04", "approximate": true, "billable": true, "updated_at": nowISO(), }) if err != nil { t.Fatalf("marshal payload: %v", err) } if err := app.applyRemoteWorklogOp(syncsvc.Op{ EntityType: syncsvc.EntityWorklog, EntityID: dto.ID, OpType: syncsvc.OpUpdate, PayloadJSON: string(payload), }); err != nil { t.Fatalf("applyRemoteWorklogOp update: %v", err) } got, err := app.worklog.Get(dto.ID) if err != nil { t.Fatalf("get updated entry: %v", err) } if got.Summary != "After remote" || got.Details != "Remote details" || got.Date != "2026-01-04" { t.Fatalf("remote updated entry = %#v", got) } if got.Minutes == nil || *got.Minutes != 75 || !got.Approximate || !got.Billable { t.Fatalf("remote updated minutes/flags = %#v", got) } } func TestAcceptSuggestionFullUsesEditedFields(t *testing.T) { app, _ := setupTestApp(t) n, err := app.CreateNodeFromTemplate("", "Edited Suggestion Node", "folder.default") if err != nil { t.Fatalf("create node: %v", err) } eventID := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file-1", "Добавлен файл") eventIDsJSON, _ := json.Marshal([]string{eventID}) dto, err := app.AcceptSuggestionFull(n.ID, "Edited summary", "Edited details", "2026-01-05", 55, false, true, string(eventIDsJSON)) if err != nil { t.Fatalf("AcceptSuggestionFull: %v", err) } if dto.Summary != "Edited summary" || dto.Details != "Edited details" || dto.Date != "2026-01-05" { t.Fatalf("dto = %#v", dto) } if dto.Minutes != 55 || dto.Approximate || !dto.Billable { t.Fatalf("dto minutes/flags = %#v", dto) } if dto.Source != worklog.SourceSuggestion { t.Fatalf("dto.Source = %q, want %q", dto.Source, worklog.SourceSuggestion) } if n := countLinked(t, app, dto.ID); n != 1 { t.Fatalf("linked events = %d, want 1", n) } }