548 lines
18 KiB
Go
548 lines
18 KiB
Go
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)
|
||
}
|
||
}
|