mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-07-25 14:22:57 +00:00
Squash merge PR #115 after resolving changelog and SQLite migration-map conflicts with current dev. Renumbered channel-context PostgreSQL migration to 000075 and bumped PG required schema to 75 plus SQLite schema to 44 so it follows the run timeline migration. Local checks passed: go test ./..., go build ./..., go build -tags sqliteonly ./..., go vet ./..., and pnpm -C ui/web build. PR CI run 26705617311 passed release-versioning, go, and web.
190 lines
6.4 KiB
Go
190 lines
6.4 KiB
Go
package http
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/crypto"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
type fakeChannelContextContactStore struct {
|
|
contacts []store.ChannelContact
|
|
seenOpts []store.ContactListOpts
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) UpsertContact(context.Context, string, string, string, string, string, string, string, string, string, string) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) ListContacts(_ context.Context, opts store.ContactListOpts) ([]store.ChannelContact, error) {
|
|
s.seenOpts = append(s.seenOpts, opts)
|
|
var out []store.ChannelContact
|
|
for _, c := range s.contacts {
|
|
if opts.ChannelType != "" && c.ChannelType != opts.ChannelType {
|
|
continue
|
|
}
|
|
if opts.ChannelInstance != "" && (c.ChannelInstance == nil || *c.ChannelInstance != opts.ChannelInstance) {
|
|
continue
|
|
}
|
|
if opts.ContactType != "" && c.ContactType != opts.ContactType {
|
|
continue
|
|
}
|
|
if opts.PeerKind != "" && (c.PeerKind == nil || *c.PeerKind != opts.PeerKind) {
|
|
continue
|
|
}
|
|
out = append(out, c)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) CountContacts(context.Context, store.ContactListOpts) (int, error) {
|
|
return len(s.contacts), nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) GetContactsBySenderIDs(context.Context, []string) (map[string]store.ChannelContact, error) {
|
|
return map[string]store.ChannelContact{}, nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) GetContactByID(context.Context, uuid.UUID) (*store.ChannelContact, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) GetSenderIDsByContactIDs(context.Context, []uuid.UUID) ([]string, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) MergeContacts(context.Context, []uuid.UUID, uuid.UUID) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) UnmergeContacts(context.Context, []uuid.UUID) error {
|
|
return nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) GetContactsByMergedID(context.Context, uuid.UUID) ([]store.ChannelContact, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (s *fakeChannelContextContactStore) ResolveTenantUserID(context.Context, string, string) (string, error) {
|
|
return "", nil
|
|
}
|
|
|
|
func TestChannelContextsListUsesStoredGroupsAndMasksToContextShape(t *testing.T) {
|
|
token := "channel-contexts-key"
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"},
|
|
})
|
|
|
|
instID := uuid.New()
|
|
channelName := "discord-admin"
|
|
displayName := "Ops Discord"
|
|
peerKind := "group"
|
|
lastSeen := time.Date(2026, 5, 29, 10, 0, 0, 0, time.UTC)
|
|
contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{
|
|
ID: uuid.New(),
|
|
ChannelType: "discord",
|
|
ChannelInstance: &channelName,
|
|
SenderID: "guild-123/channel-456",
|
|
DisplayName: &displayName,
|
|
PeerKind: &peerKind,
|
|
ContactType: "group",
|
|
LastSeenAt: lastSeen,
|
|
}}}
|
|
handler := NewChannelInstancesHandler(
|
|
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, DisplayName: "Discord Admin", ChannelType: "discord", AgentID: uuid.New()}},
|
|
nil, nil, contacts, nil, nil,
|
|
)
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Contexts []channelContextDTO `json:"contexts"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(body.Contexts) != 2 {
|
|
t.Fatalf("contexts len = %d, want 2: %+v", len(body.Contexts), body.Contexts)
|
|
}
|
|
if body.Contexts[0].ScopeType != store.ChannelScopeTypeChannel || body.Contexts[0].ScopeKey != channelName {
|
|
t.Fatalf("base context = %+v", body.Contexts[0])
|
|
}
|
|
gotGroup := body.Contexts[1]
|
|
if gotGroup.ScopeType != store.ChannelScopeTypeGroup || gotGroup.ScopeKey != "guild-123/channel-456" || gotGroup.Source != "stored_contact" {
|
|
t.Fatalf("group context = %+v", gotGroup)
|
|
}
|
|
if len(contacts.seenOpts) == 0 || contacts.seenOpts[0].ChannelInstance != channelName {
|
|
t.Fatalf("expected channel_instance filter %q, got %+v", channelName, contacts.seenOpts)
|
|
}
|
|
}
|
|
|
|
func TestChannelContextMembersDoNotShowChannelWideContactsAsGroupMembers(t *testing.T) {
|
|
token := "channel-context-members-key"
|
|
setupTestCache(t, map[string]*store.APIKeyData{
|
|
crypto.HashAPIKey(token): {Scopes: []string{"operator.write"}, OwnerID: "caller"},
|
|
})
|
|
|
|
instID := uuid.New()
|
|
channelName := "telegram-main"
|
|
peerKind := "group"
|
|
displayName := "Duy"
|
|
username := "duy"
|
|
userID := "tenant-user-1"
|
|
contacts := &fakeChannelContextContactStore{contacts: []store.ChannelContact{{
|
|
ID: uuid.New(),
|
|
ChannelType: "telegram",
|
|
ChannelInstance: &channelName,
|
|
SenderID: "386246614",
|
|
UserID: &userID,
|
|
DisplayName: &displayName,
|
|
Username: &username,
|
|
PeerKind: &peerKind,
|
|
ContactType: "user",
|
|
LastSeenAt: time.Date(2026, 5, 29, 10, 5, 0, 0, time.UTC),
|
|
}}}
|
|
handler := NewChannelInstancesHandler(
|
|
&stubChannelInstanceStore{inst: &store.ChannelInstanceData{BaseModel: store.BaseModel{ID: instID}, Name: channelName, ChannelType: "telegram", AgentID: uuid.New()}},
|
|
nil, nil, contacts, nil, nil,
|
|
)
|
|
mux := http.NewServeMux()
|
|
handler.RegisterRoutes(mux)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/v1/channels/instances/"+instID.String()+"/contexts/group/group:telegram:-100123/members", nil)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
var body struct {
|
|
Members []channelContextMemberDTO `json:"members"`
|
|
LiveMembersSupported bool `json:"live_members_supported"`
|
|
Source string `json:"source"`
|
|
}
|
|
if err := json.NewDecoder(rec.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.LiveMembersSupported || body.Source != "stored_contacts" {
|
|
t.Fatalf("expected unsupported stored contact response, got %+v", body)
|
|
}
|
|
if len(body.Members) != 0 {
|
|
t.Fatalf("members = %+v", body.Members)
|
|
}
|
|
}
|