clickclack/apps/api/internal/store/sqlite/chat_test.go
2026-05-08 05:36:16 +01:00

473 lines
14 KiB
Go

package sqlite
import (
"context"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestStoreChatThreadsSearchUploadsAndEvents(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]
createdChannel, channelEvent, err := st.CreateChannel(ctx, store.CreateChannelInput{
WorkspaceID: workspace.ID,
Name: "Store Room",
UserID: owner.ID,
})
if err != nil {
t.Fatal(err)
}
if createdChannel.Name != "store-room" || channelEvent.Type != "channel.created" {
t.Fatalf("unexpected channel create result: %#v %#v", createdChannel, channelEvent)
}
root, event, err := st.CreateMessage(ctx, store.CreateMessageInput{
ChannelID: channel.ID,
AuthorID: owner.ID,
Body: "searchable **message**",
})
if err != nil {
t.Fatal(err)
}
if event.Type != "message.created" || event.ChannelID != channel.ID {
t.Fatalf("unexpected message event: %#v", event)
}
if root.ChannelSeq == nil || *root.ChannelSeq != 1 {
t.Fatalf("expected first channel sequence, got %#v", root.ChannelSeq)
}
messages, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 || messages[0].ID != root.ID {
t.Fatalf("unexpected messages: %#v", messages)
}
after, err := st.ListMessages(ctx, channel.ID, owner.ID, *root.ChannelSeq, 10)
if err != nil {
t.Fatal(err)
}
if len(after) != 0 {
t.Fatalf("expected no messages after seq, got %#v", after)
}
reply, state, events, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{
RootMessageID: root.ID,
AuthorID: owner.ID,
Body: "reply body",
})
if err != nil {
t.Fatal(err)
}
if reply.ThreadSeq == nil || *reply.ThreadSeq != 1 || state.ReplyCount != 1 || len(events) != 2 {
t.Fatalf("unexpected reply result: %#v %#v %#v", reply, state, events)
}
threadRoot, replies, threadState, err := st.GetThread(ctx, root.ID, owner.ID, 10)
if err != nil {
t.Fatal(err)
}
if threadRoot.ID != root.ID || len(replies) != 1 || threadState.ReplyCount != 1 {
t.Fatalf("unexpected thread: %#v %#v %#v", threadRoot, replies, threadState)
}
results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "searchable", 10)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Message.ID != root.ID {
t.Fatalf("unexpected search results: %#v", results)
}
eventsAfter, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, channelEvent.Cursor, 10)
if err != nil {
t.Fatal(err)
}
if len(eventsAfter) == 0 {
t.Fatal("expected events after channel cursor")
}
allEvents, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 0)
if err != nil {
t.Fatal(err)
}
if len(allEvents) == 0 {
t.Fatal("expected events with empty cursor")
}
upload, err := st.CreateUpload(ctx, store.CreateUploadInput{
WorkspaceID: workspace.ID,
OwnerID: owner.ID,
Filename: "note.txt",
ContentType: "text/plain",
ByteSize: 4,
StoragePath: "/tmp/note.txt",
})
if err != nil {
t.Fatal(err)
}
gotUpload, err := st.GetUpload(ctx, upload.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
if gotUpload.ID != upload.ID || gotUpload.Filename != "note.txt" {
t.Fatalf("unexpected upload: %#v", gotUpload)
}
if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: owner.ID}); err != nil {
t.Fatal(err)
}
withAttachment, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(withAttachment[0].Attachments) != 1 {
t.Fatalf("expected attachment on message, got %#v", withAttachment[0])
}
added, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "claw"})
if err != nil {
t.Fatal(err)
}
removed, err := st.RemoveReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "claw"})
if err != nil {
t.Fatal(err)
}
if added.Type != "reaction.added" || removed.Type != "reaction.removed" {
t.Fatalf("unexpected reaction events: %#v %#v", added, removed)
}
}
func TestStoreAccessErrors(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "out@example.com"})
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "private"})
if err != nil {
t.Fatal(err)
}
upload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: workspaces[0].ID, OwnerID: owner.ID, Filename: "x", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/x"})
if err != nil {
t.Fatal(err)
}
errorCases := []struct {
name string
fn func() error
}{
{"list workspaces outsider empty ok", func() error {
items, err := st.ListWorkspaces(ctx, outsider.ID)
if err != nil {
return err
}
if len(items) != 0 {
t.Fatalf("expected no workspaces for outsider, got %#v", items)
}
return nil
}},
{"get workspace denied", func() error {
_, err := st.GetWorkspace(ctx, workspaces[0].ID, outsider.ID)
return err
}},
{"list channels denied", func() error {
_, err := st.ListChannels(ctx, workspaces[0].ID, outsider.ID)
return err
}},
{"list messages denied", func() error {
_, err := st.ListMessages(ctx, channels[0].ID, outsider.ID, 0, 10)
return err
}},
{"thread denied", func() error {
_, _, _, err := st.GetThread(ctx, root.ID, outsider.ID, 10)
return err
}},
{"events denied", func() error {
_, err := st.ListEventsAfter(ctx, workspaces[0].ID, outsider.ID, "", 10)
return err
}},
{"upload denied", func() error {
_, err := st.GetUpload(ctx, upload.ID, outsider.ID)
return err
}},
{"attach denied", func() error {
return st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: outsider.ID})
}},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.fn()
if tc.name == "list workspaces outsider empty ok" {
if err != nil {
t.Fatal(err)
}
return
}
if err == nil {
t.Fatal("expected error")
}
})
}
}
func TestStoreDirectMessagesAndUserLookup(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
if got, err := st.GetUser(ctx, owner.ID); err != nil || got.ID != owner.ID {
t.Fatalf("unexpected user lookup: %#v err=%v", got, err)
}
other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"})
if err != nil {
t.Fatal(err)
}
third, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Third", Email: "third@example.com"})
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
if err := st.AddWorkspaceMember(ctx, workspace.ID, other.ID, "member"); err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, third.ID, "member"); err != nil {
t.Fatal(err)
}
if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
MemberIDs: []string{"", other.ID, other.ID},
}); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
MemberIDs: []string{other.ID, third.ID},
})
if err != nil {
t.Fatal(err)
}
if len(dm.Members) != 3 {
t.Fatalf("expected three dm members, got %#v", dm.Members)
}
list, err := st.ListDirectConversations(ctx, workspace.ID, other.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected two dm conversations for other member, got %#v", list)
}
msg, event, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{
ConversationID: dm.ID,
AuthorID: other.ID,
Body: " direct hello ",
})
if err != nil {
t.Fatal(err)
}
if msg.DirectConversationID != dm.ID || event.Type != "message.created" || event.ChannelID != "" {
t.Fatalf("unexpected direct message result: %#v %#v", msg, event)
}
messages, err := st.ListDirectMessages(ctx, dm.ID, third.ID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 || messages[0].Body != "direct hello" {
t.Fatalf("unexpected direct messages: %#v", messages)
}
after, err := st.ListDirectMessages(ctx, dm.ID, third.ID, *messages[0].ChannelSeq, 10)
if err != nil {
t.Fatal(err)
}
if len(after) != 0 {
t.Fatalf("expected no direct messages after seq, got %#v", after)
}
errorCases := []struct {
name string
fn func() error
}{
{"single member", func() error {
_, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID})
return err
}},
{"nonmember create dm", func() error {
outside, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outside", Email: "outside@example.com"})
if err != nil {
return err
}
_, err = st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{outside.ID}})
return err
}},
{"empty dm body", func() error {
_, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID})
return err
}},
{"missing dm", func() error {
_, err := st.ListDirectMessages(ctx, "dm_missing", owner.ID, 0, 10)
return err
}},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.fn(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestStoreBranchCases(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
again, err := st.EnsureBootstrap(ctx, "Ignored", "ignored@example.com")
if err != nil {
t.Fatal(err)
}
if again.ID != owner.ID {
t.Fatalf("expected existing bootstrap user, got %#v", again)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]
secondWorkspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Other"}, owner.ID)
if err != nil {
t.Fatal(err)
}
defaultChannel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: secondWorkspace.ID, UserID: owner.ID})
if err != nil {
t.Fatal(err)
}
if defaultChannel.Name != "general" || defaultChannel.Kind != "public" {
t.Fatalf("unexpected default channel: %#v", defaultChannel)
}
otherUpload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: secondWorkspace.ID, OwnerID: owner.ID, Filename: "other", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/other"})
if err != nil {
t.Fatal(err)
}
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root for branches"})
if err != nil {
t.Fatal(err)
}
if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: otherUpload.ID, UserID: owner.ID}); err == nil {
t.Fatal("expected mismatched upload workspace error")
}
reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"})
if err != nil {
t.Fatal(err)
}
if _, _, _, err := st.GetThread(ctx, reply.ID, owner.ID, 10); err == nil {
t.Fatal("expected reply-as-root error")
}
if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: reply.ID, AuthorID: owner.ID, Body: "nested"}); err == nil {
t.Fatal("expected nested reply error")
}
if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID}); err == nil {
t.Fatal("expected empty reply body error")
}
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: "chn_missing", AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected missing channel error")
}
if results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "missingterm", 999); err != nil || len(results) != 0 {
t.Fatalf("expected no search results, got %#v err=%v", results, err)
}
if _, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 999); err != nil {
t.Fatal(err)
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Branch Outsider", Email: "branch-out@example.com"})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateInvite(ctx, workspace.ID, outsider.ID); err == nil {
t.Fatal("expected invite membership error")
}
if _, err := st.SearchMessages(ctx, workspace.ID, outsider.ID, "root", 10); err == nil {
t.Fatal("expected search membership error")
}
firstLink, err := st.CreateMagicLink(ctx, "reuse@example.com", "Reuse One")
if err != nil {
t.Fatal(err)
}
firstUser, _, err := st.ConsumeMagicLink(ctx, firstLink.Token)
if err != nil {
t.Fatal(err)
}
secondLink, err := st.CreateMagicLink(ctx, "reuse@example.com", "Reuse Two")
if err != nil {
t.Fatal(err)
}
secondUser, _, err := st.ConsumeMagicLink(ctx, secondLink.Token)
if err != nil {
t.Fatal(err)
}
if firstUser.ID != secondUser.ID {
t.Fatalf("expected reused magic user, got %s and %s", firstUser.ID, secondUser.ID)
}
expired, err := st.CreateMagicLink(ctx, "expired@example.com", "Expired")
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `UPDATE auth_magic_links SET expires_at = '2000-01-01T00:00:00Z' WHERE token = ?`, expired.Token); err != nil {
t.Fatal(err)
}
if _, _, err := st.ConsumeMagicLink(ctx, expired.Token); err == nil {
t.Fatal("expected expired magic link error")
}
}