package main import ( "encoding/json" "testing" "time" "verstak/internal/core/activity" "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) } }