Files
jules/engine/actions/actions_test.go
T

592 lines
17 KiB
Go

package actions
import (
"context"
"testing"
"time"
"github.com/d1nch8g/jules/chat"
"github.com/d1nch8g/jules/database"
"github.com/d1nch8g/jules/engine/user"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type mockDB struct {
users *mockUsers
chats *mockChats
facts *mockFacts
contacts *mockContacts
notifications *mockNotifications
actions *mockActions
}
func (m *mockDB) Users() database.Users { return m.users }
func (m *mockDB) Chats() database.Chats { return m.chats }
func (m *mockDB) Facts() database.Facts { return m.facts }
func (m *mockDB) Contacts() database.Contacts { return m.contacts }
func (m *mockDB) Notifications() database.Notifications { return m.notifications }
func (m *mockDB) Actions() database.Actions { return m.actions }
func (m *mockDB) Close() error { return nil }
type mockUsers struct {
getFunc func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error)
updateFunc func(ctx context.Context, u *database.User) error
deleteFunc func(ctx context.Context, id uuid.UUID) error
}
func (m *mockUsers) Get(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
if m.getFunc != nil {
return m.getFunc(ctx, id, lookup)
}
return &database.User{ID: id}, nil
}
func (m *mockUsers) Create(ctx context.Context, u *database.User) error { return nil }
func (m *mockUsers) Update(ctx context.Context, u *database.User) error {
if m.updateFunc != nil {
return m.updateFunc(ctx, u)
}
return nil
}
func (m *mockUsers) Delete(ctx context.Context, id uuid.UUID) error {
if m.deleteFunc != nil {
return m.deleteFunc(ctx, id)
}
return nil
}
type mockChats struct {
attachFunc func(ctx context.Context, userID uuid.UUID, platform, identifier string) error
}
func (m *mockChats) Attach(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
if m.attachFunc != nil {
return m.attachFunc(ctx, userID, platform, identifier)
}
return nil
}
func (m *mockChats) Detach(ctx context.Context, userID uuid.UUID, platform string) error { return nil }
func (m *mockChats) GetUserID(ctx context.Context, platform, identifier string) (uuid.UUID, error) {
return uuid.Nil, nil
}
func (m *mockChats) List(ctx context.Context, userID uuid.UUID) ([]database.Chat, error) {
return nil, nil
}
type mockFacts struct {
addFunc func(ctx context.Context, userID uuid.UUID, value string) error
deleteFunc func(ctx context.Context, userID uuid.UUID, value string) error
}
func (m *mockFacts) Add(ctx context.Context, userID uuid.UUID, value string) error {
if m.addFunc != nil {
return m.addFunc(ctx, userID, value)
}
return nil
}
func (m *mockFacts) List(ctx context.Context, userID uuid.UUID) ([]database.Fact, error) {
return nil, nil
}
func (m *mockFacts) Delete(ctx context.Context, userID uuid.UUID, value string) error {
if m.deleteFunc != nil {
return m.deleteFunc(ctx, userID, value)
}
return nil
}
type mockContacts struct {
addFunc func(ctx context.Context, contact *database.Contact) error
}
func (m *mockContacts) Add(ctx context.Context, contact *database.Contact) error {
if m.addFunc != nil {
return m.addFunc(ctx, contact)
}
return nil
}
func (m *mockContacts) List(ctx context.Context, ownerID uuid.UUID) ([]database.Contact, error) {
return nil, nil
}
func (m *mockContacts) Delete(ctx context.Context, ownerID, targetID uuid.UUID) error { return nil }
type mockNotifications struct {
pushFunc func(ctx context.Context, n *database.Notification) error
deleteFunc func(ctx context.Context, id uuid.UUID) error
}
func (m *mockNotifications) Push(ctx context.Context, n *database.Notification) error {
if m.pushFunc != nil {
return m.pushFunc(ctx, n)
}
return nil
}
func (m *mockNotifications) Pop(ctx context.Context, limit int) ([]database.Notification, error) {
return nil, nil
}
func (m *mockNotifications) List(ctx context.Context, userID uuid.UUID) ([]database.Notification, error) {
return nil, nil
}
func (m *mockNotifications) ListOutgoing(ctx context.Context, initiatorID uuid.UUID) ([]database.Notification, error) {
return nil, nil
}
func (m *mockNotifications) Delete(ctx context.Context, id uuid.UUID) error {
if m.deleteFunc != nil {
return m.deleteFunc(ctx, id)
}
return nil
}
type mockActions struct{}
func (m *mockActions) Log(ctx context.Context, a *database.Action) error { return nil }
func (m *mockActions) Recent(ctx context.Context, userID uuid.UUID, limit int) ([]database.Action, error) {
return nil, nil
}
type mockChat struct {
sendFunc func(ctx context.Context, id, text string) error
}
func (m *mockChat) Send(ctx context.Context, id, text string) error {
if m.sendFunc != nil {
return m.sendFunc(ctx, id, text)
}
return nil
}
func (m *mockChat) Receive(ctx context.Context) <-chan chat.Message { return nil }
func testRuntime() *Runtime {
return &Runtime{
User: &user.User{
User: &database.User{
ID: uuid.New(),
Language: "en",
Timezone: "UTC",
PreferredChat: "telegram",
},
Chats: []database.Chat{{Platform: "telegram", Identifier: "123"}},
Contacts: []database.Contact{{Name: "Mom", TargetID: uuid.New()}},
},
Database: &mockDB{
users: &mockUsers{},
chats: &mockChats{},
facts: &mockFacts{},
contacts: &mockContacts{},
notifications: &mockNotifications{},
actions: &mockActions{},
},
Chats: map[string]chat.Chat{"telegram": &mockChat{}},
}
}
func TestMessage_Validate(t *testing.T) {
rt := testRuntime()
rt.User.Chats = []database.Chat{{Platform: "telegram"}}
tests := []struct {
name string
action Message
wantErr bool
}{
{"valid", Message{Platform: "telegram", Text: "hi"}, false},
{"empty text", Message{Platform: "telegram", Text: ""}, true},
{"unknown platform", Message{Platform: "whatsapp", Text: "hi"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.action.Validate(t.Context(), rt)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestMessage_Execute(t *testing.T) {
rt := testRuntime()
rt.User.Chats = []database.Chat{{Platform: "telegram", Identifier: "123"}}
called := false
rt.Chats["telegram"] = &mockChat{
sendFunc: func(ctx context.Context, id, text string) error {
called = true
assert.Equal(t, "123", id)
assert.Equal(t, "hi", text)
return nil
},
}
err := Message{Platform: "telegram", Text: "hi"}.Execute(t.Context(), rt)
assert.NoError(t, err)
assert.True(t, called)
}
func TestWait_Validate(t *testing.T) {
rt := testRuntime()
assert.NoError(t, Wait{Ms: 100}.Validate(t.Context(), rt))
assert.Error(t, Wait{Ms: 0}.Validate(t.Context(), rt))
assert.Error(t, Wait{Ms: 70000}.Validate(t.Context(), rt))
}
func TestWait_Execute(t *testing.T) {
start := time.Now()
err := Wait{Ms: 10}.Execute(t.Context(), testRuntime())
assert.NoError(t, err)
assert.True(t, time.Since(start) >= 10*time.Millisecond)
}
func TestUpdateLang_Validate(t *testing.T) {
rt := testRuntime()
assert.NoError(t, UpdateLang{Lang: "ru"}.Validate(t.Context(), rt))
assert.Error(t, UpdateLang{Lang: ""}.Validate(t.Context(), rt))
}
func TestUpdateLang_Execute(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).users.updateFunc = func(ctx context.Context, u *database.User) error {
assert.Equal(t, "ru", u.Language)
return nil
}
err := UpdateLang{Lang: "ru"}.Execute(t.Context(), rt)
assert.NoError(t, err)
assert.Equal(t, "ru", rt.User.Language)
}
func TestUpdateTZ_Execute(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).users.updateFunc = func(ctx context.Context, u *database.User) error {
assert.Equal(t, "Europe/Moscow", u.Timezone)
return nil
}
err := UpdateTZ{TZ: "Europe/Moscow"}.Execute(t.Context(), rt)
assert.NoError(t, err)
assert.Equal(t, "Europe/Moscow", rt.User.Timezone)
}
func TestSetChat_Validate(t *testing.T) {
rt := testRuntime()
rt.User.Chats = []database.Chat{{Platform: "telegram"}}
assert.NoError(t, SetChat{Chat: "telegram"}.Validate(t.Context(), rt))
assert.Error(t, SetChat{Chat: "whatsapp"}.Validate(t.Context(), rt))
}
func TestSetChat_Execute(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).users.updateFunc = func(ctx context.Context, u *database.User) error {
assert.Equal(t, "telegram", u.PreferredChat)
return nil
}
err := SetChat{Chat: "telegram"}.Execute(t.Context(), rt)
assert.NoError(t, err)
assert.Equal(t, "telegram", rt.User.PreferredChat)
}
func TestAddFact_Execute(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).facts.addFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
assert.Equal(t, rt.User.ID, userID)
assert.Equal(t, "test", value)
return nil
}
err := AddFact{Value: "test"}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestRemoveFact_Execute(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).facts.deleteFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
assert.Equal(t, rt.User.ID, userID)
assert.Equal(t, "test", value)
return nil
}
err := RemoveFact{Value: "test"}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestRemoveFact_Execute_NotFound(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).facts.deleteFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
return database.ErrNotFound
}
err := RemoveFact{Value: "test"}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestAddContact_Validate(t *testing.T) {
rt := testRuntime()
code := uuid.New()
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
return &database.User{ID: id}, nil
}
tests := []struct {
name string
action AddContact
wantErr bool
}{
{"valid", AddContact{UUID: code.String(), Name: "Brother"}, false},
{"empty uuid", AddContact{UUID: "", Name: "Brother"}, true},
{"invalid uuid", AddContact{UUID: "not-uuid", Name: "Brother"}, true},
{"empty name", AddContact{UUID: code.String(), Name: ""}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.action.Validate(t.Context(), rt)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestAddContact_Execute(t *testing.T) {
rt := testRuntime()
contactUser := &database.User{ID: uuid.New()}
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
return contactUser, nil
}
rt.Database.(*mockDB).contacts.addFunc = func(ctx context.Context, c *database.Contact) error {
assert.Equal(t, rt.User.ID, c.OwnerID)
assert.Equal(t, contactUser.ID, c.TargetID)
assert.Equal(t, "Brother", c.Name)
return nil
}
err := AddContact{UUID: uuid.New().String(), Name: "Brother"}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestAddNotification_Validate(t *testing.T) {
rt := testRuntime()
rt.User.Contacts = []database.Contact{{Name: "Mom"}}
tests := []struct {
name string
action AddNotification
wantErr bool
}{
{"valid self", AddNotification{Target: "self", Time: "2026-04-20 15:00", Content: "test"}, false},
{"valid contact", AddNotification{Target: "Mom", Time: "2026-04-20 15:00", Content: "test"}, false},
{"invalid contact", AddNotification{Target: "Dad", Time: "2026-04-20 15:00", Content: "test"}, true},
{"empty time", AddNotification{Target: "self", Time: "", Content: "test"}, true},
{"empty content", AddNotification{Target: "self", Time: "2026-04-20 15:00", Content: ""}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.action.Validate(t.Context(), rt)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestAddNotification_Execute(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).notifications.pushFunc = func(ctx context.Context, n *database.Notification) error {
assert.Equal(t, rt.User.ID, n.UserID)
assert.Equal(t, rt.User.ID, n.InitiatorID)
assert.Equal(t, "test", n.Content)
return nil
}
err := AddNotification{Target: "self", Time: "2026-04-20 15:00", Content: "test"}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestRemoveNotification_Validate(t *testing.T) {
rt := testRuntime()
assert.NoError(t, RemoveNotification{UUID: uuid.New().String()}.Validate(t.Context(), rt))
assert.Error(t, RemoveNotification{UUID: ""}.Validate(t.Context(), rt))
assert.Error(t, RemoveNotification{UUID: "not-uuid"}.Validate(t.Context(), rt))
}
func TestRemoveNotification_Execute(t *testing.T) {
rt := testRuntime()
id := uuid.New()
rt.Database.(*mockDB).notifications.deleteFunc = func(ctx context.Context, nid uuid.UUID) error {
assert.Equal(t, id, nid)
return nil
}
err := RemoveNotification{UUID: id.String()}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestRemoveNotification_Execute_NotFound(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).notifications.deleteFunc = func(ctx context.Context, id uuid.UUID) error {
return database.ErrNotFound
}
err := RemoveNotification{UUID: uuid.New().String()}.Execute(t.Context(), rt)
assert.NoError(t, err)
}
func TestParse(t *testing.T) {
tests := []struct {
name string
raw string
userTZ string
wantActions int
wantErr bool
}{
{
name: "empty",
raw: "",
wantErr: true,
},
{
name: "invalid json",
raw: "not json",
wantErr: true,
},
{
name: "not array",
raw: `{"type":"wait"}`,
wantErr: true,
},
{
name: "unknown type",
raw: `[{"type":"unknown"}]`,
wantErr: true,
},
{
name: "missing type",
raw: `[{"ms":100}]`,
wantErr: true,
},
{
name: "wait valid",
raw: `[{"type":"wait","ms":100}]`,
wantActions: 1,
},
{
name: "message valid",
raw: `[{"type":"message","platform":"telegram","text":"hi"}]`,
wantActions: 1,
},
{
name: "add_fact valid",
raw: `[{"type":"add_fact","value":"test"}]`,
wantActions: 1,
},
{
name: "multiple actions",
raw: `[{"type":"wait","ms":100},{"type":"add_fact","value":"test"}]`,
wantActions: 2,
},
{
name: "json with markdown",
raw: "```json\n[{\"type\":\"wait\",\"ms\":100}]\n```",
wantActions: 1,
},
{
name: "json with text before",
raw: "Here: [{\"type\":\"wait\",\"ms\":100}]",
wantActions: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actions, err := Parse(tt.raw, "UTC")
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Len(t, actions, tt.wantActions)
})
}
}
func TestRaw(t *testing.T) {
a := AddFact{Type: "add_fact", Value: "test"}
raw := Raw(a)
assert.JSONEq(t, `{"type":"add_fact","value":"test"}`, string(raw))
}
func TestBindChat_Validate(t *testing.T) {
rt := testRuntime()
code := uuid.New()
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
return &database.User{ID: id}, nil
}
tests := []struct {
name string
action BindChat
wantErr bool
}{
{"valid", BindChat{UUID: code.String()}, false},
{"empty", BindChat{UUID: ""}, true},
{"invalid", BindChat{UUID: "not-uuid"}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.action.Validate(t.Context(), rt)
if tt.wantErr {
assert.Error(t, err)
} else {
assert.NoError(t, err)
}
})
}
}
func TestBindChat_Validate_NotFound(t *testing.T) {
rt := testRuntime()
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
return nil, database.ErrNotFound
}
err := BindChat{UUID: uuid.New().String()}.Validate(t.Context(), rt)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not found")
}
func TestBindChat_Execute(t *testing.T) {
rt := testRuntime()
targetUser := &database.User{ID: uuid.New()}
rt.Database.(*mockDB).users.getFunc = func(ctx context.Context, id uuid.UUID, lookup database.UserLookup) (*database.User, error) {
return targetUser, nil
}
rt.Database.(*mockDB).users.deleteFunc = func(ctx context.Context, id uuid.UUID) error {
assert.Equal(t, rt.User.ID, id)
return nil
}
rt.Database.(*mockDB).chats.attachFunc = func(ctx context.Context, userID uuid.UUID, platform, identifier string) error {
assert.Equal(t, targetUser.ID, userID)
return nil
}
rt.Database.(*mockDB).contacts.addFunc = func(ctx context.Context, c *database.Contact) error {
assert.Equal(t, targetUser.ID, c.OwnerID)
return nil
}
rt.Database.(*mockDB).facts.addFunc = func(ctx context.Context, userID uuid.UUID, value string) error {
assert.Equal(t, targetUser.ID, userID)
return nil
}
rt.Database.(*mockDB).notifications.pushFunc = func(ctx context.Context, n *database.Notification) error {
return nil
}
rt.User.Chats = []database.Chat{{Platform: "telegram", Identifier: "123"}}
rt.User.Contacts = []database.Contact{{TargetID: uuid.New(), Name: "Mom"}}
rt.User.Facts = []database.Fact{{Value: "fact"}}
rt.User.IncomingNotifications = []database.Notification{{ID: uuid.New(), InitiatorID: rt.User.ID}}
rt.User.OutgoingNotifications = []database.Notification{{ID: uuid.New()}}
err := BindChat{UUID: uuid.New().String()}.Execute(t.Context(), rt)
assert.NoError(t, err)
}