285 lines
9.1 KiB
Go
285 lines
9.1 KiB
Go
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)
|
||
}
|
||
}
|