1// SPDX-FileCopyrightText: Amolith <amolith@secluded.site>
  2//
  3// SPDX-License-Identifier: AGPL-3.0-or-later
  4
  5package event_test
  6
  7import (
  8	"context"
  9	"errors"
 10	"testing"
 11	"time"
 12
 13	"git.secluded.site/np/internal/event"
 14	"git.secluded.site/np/internal/goal"
 15	"git.secluded.site/np/internal/task"
 16	"git.secluded.site/np/internal/testutil"
 17)
 18
 19func TestStoreAppendAndList(t *testing.T) {
 20	ctx := context.Background()
 21	db := testutil.OpenDB(t)
 22
 23	clock := &testutil.SequenceClock{
 24		Times: []time.Time{
 25			time.Date(2025, time.March, 3, 10, 15, 0, 0, time.FixedZone("A", 3600)),
 26			time.Date(2025, time.March, 3, 10, 20, 0, 0, time.FixedZone("B", -3600)),
 27		},
 28	}
 29
 30	store := event.NewStore(db, clock)
 31	sid := "sid-events"
 32
 33	goalDoc := goal.Document{
 34		Title:       "Plan",
 35		Description: "do something",
 36		UpdatedAt:   time.Date(2025, time.March, 3, 9, 0, 0, 0, time.FixedZone("C", 7200)),
 37	}
 38
 39	first, err := store.Append(ctx, sid, event.BuildGoalSet("np g s", " reason ", goalDoc))
 40	if err != nil {
 41		t.Fatalf("Append goal_set: %v", err)
 42	}
 43	if first.Seq != 1 {
 44		t.Fatalf("Expected seq 1, got %d", first.Seq)
 45	}
 46	if !first.At.Equal(clock.Times[0].UTC()) {
 47		t.Fatalf("At mismatch: want %v got %v", clock.Times[0].UTC(), first.At)
 48	}
 49	if !first.HasReason() {
 50		t.Fatalf("Expected reason")
 51	}
 52	if want := "reason"; *first.Reason != want {
 53		t.Fatalf("Reason mismatch: want %q got %q", want, *first.Reason)
 54	}
 55	var goalPayload event.GoalSetPayload
 56	if err := first.UnmarshalPayload(&goalPayload); err != nil {
 57		t.Fatalf("UnmarshalPayload goal_set: %v", err)
 58	}
 59	if want := event.GoalSnapshotFrom(goalDoc); goalPayload.Goal != want {
 60		t.Fatalf("Goal payload mismatch: want %+v got %+v", want, goalPayload.Goal)
 61	}
 62
 63	taskAdded := task.Task{
 64		ID:          "abc123",
 65		Title:       "Task",
 66		Description: "finish work",
 67		Status:      task.StatusPending,
 68		CreatedAt:   time.Date(2025, time.March, 3, 9, 30, 0, 0, time.UTC),
 69		UpdatedAt:   time.Date(2025, time.March, 3, 9, 30, 0, 0, time.UTC),
 70		CreatedSeq:  1,
 71	}
 72
 73	second, err := store.Append(ctx, sid, event.BuildTaskAdded("np t a", "", taskAdded))
 74	if err != nil {
 75		t.Fatalf("Append task_added: %v", err)
 76	}
 77	if second.Seq != 2 {
 78		t.Fatalf("Expected seq 2, got %d", second.Seq)
 79	}
 80	if second.Reason != nil {
 81		t.Fatalf("Expected nil reason")
 82	}
 83	if !second.At.Equal(clock.Times[1].UTC()) {
 84		t.Fatalf("Second At mismatch: want %v got %v", clock.Times[1].UTC(), second.At)
 85	}
 86
 87	var taskPayload event.TaskAddedPayload
 88	if err := second.UnmarshalPayload(&taskPayload); err != nil {
 89		t.Fatalf("UnmarshalPayload task_added: %v", err)
 90	}
 91	if taskPayload.Task != taskAdded {
 92		t.Fatalf("Task payload mismatch: want %+v got %+v", taskAdded, taskPayload.Task)
 93	}
 94
 95	all, err := store.List(ctx, sid, event.ListOptions{})
 96	if err != nil {
 97		t.Fatalf("List: %v", err)
 98	}
 99	if len(all) != 2 {
100		t.Fatalf("Expected 2 events, got %d", len(all))
101	}
102	if all[0].Seq != 1 || all[1].Seq != 2 {
103		t.Fatalf("Events not ordered: %+v", all)
104	}
105
106	after, err := store.List(ctx, sid, event.ListOptions{After: 1})
107	if err != nil {
108		t.Fatalf("List after=1: %v", err)
109	}
110	if len(after) != 1 || after[0].Seq != 2 {
111		t.Fatalf("After list mismatch: %+v", after)
112	}
113
114	limited, err := store.List(ctx, sid, event.ListOptions{Limit: 1})
115	if err != nil {
116		t.Fatalf("List limit=1: %v", err)
117	}
118	if len(limited) != 1 || limited[0].Seq != 1 {
119		t.Fatalf("Limited list mismatch: %+v", limited)
120	}
121
122	seq, err := store.LatestSequence(ctx, sid)
123	if err != nil {
124		t.Fatalf("LatestSequence: %v", err)
125	}
126	if seq != 2 {
127		t.Fatalf("LatestSequence mismatch: want 2 got %d", seq)
128	}
129
130	emptySeq, err := store.LatestSequence(ctx, "missing")
131	if err != nil {
132		t.Fatalf("LatestSequence missing: %v", err)
133	}
134	if emptySeq != 0 {
135		t.Fatalf("LatestSequence should be 0 for missing, got %d", emptySeq)
136	}
137}
138
139func TestAppendValidation(t *testing.T) {
140	ctx := context.Background()
141	db := testutil.OpenDB(t)
142	store := event.NewStore(db, &testutil.SequenceClock{Times: []time.Time{time.Now()}})
143
144	if _, err := store.Append(ctx, "sid", event.AppendInput{Type: "unknown", Command: "cmd"}); !errors.Is(err, event.ErrInvalidType) {
145		t.Fatalf("expected ErrInvalidType, got %v", err)
146	}
147
148	if _, err := store.Append(ctx, "sid", event.AppendInput{Type: event.TypeGoalSet, Command: "   "}); !errors.Is(err, event.ErrEmptyCommand) {
149		t.Fatalf("expected ErrEmptyCommand, got %v", err)
150	}
151}