verstak/cmd/verstak-gui/suggest_test.go

548 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
// Notes folder creation (from template) adds 1 auto event.
if len(s.Events) != 4 {
t.Fatalf("suggestion has %d events, want 4 (1 auto from Notes creation + 3 manual)", 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
// The first event (Notes folder creation) was not accepted,
// so the suggestion includes it plus the new eid2.
if len(s.Events) < 1 {
t.Fatalf("expected at least 1 new event, got %d", len(s.Events))
}
// eid2 must be among the suggested events
var hasNew bool
for _, ev := range s.Events {
if ev.ID == eid2 {
hasNew = true
break
}
}
if !hasNew {
t.Errorf("eid2 not found among suggestion events")
}
}
}
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)
}
// The template creates a Notes folder, which generates an auto event.
// Dismiss it first so it doesn't interfere with the test.
autoEvts, _ := app.GetSuggestions()
for _, s := range autoEvts {
if s.NodeID == n.ID && len(s.EventIDs) > 0 {
// Dismiss all outstanding auto events for this node
b, _ := json.Marshal(s.EventIDs)
_ = app.DismissSuggestion(n.ID, string(b))
}
}
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)
}
}