owpengram-server/internal/app/chatlists/service_test.go

488 lines
19 KiB
Go

package chatlists
import (
"context"
"errors"
"strings"
"testing"
"telesrv/internal/domain"
"telesrv/internal/links"
"telesrv/internal/store/memory"
)
func TestSharedFolderInviteJoinUpdatesAndLeave(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
chatlists := memory.NewChatlistStore()
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) { return "slug-one", nil }))
ownerID := int64(1001)
viewerID := int64(2002)
peerA := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
peerB := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3002}, AccessHash: 32}
peerC := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3003}, AccessHash: 33}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IncludePeers: []domain.DialogFolderPeer{peerA},
}); err != nil {
t.Fatalf("seed owner folder: %v", err)
}
folder, invite, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peerA}, 100)
if err != nil {
t.Fatalf("ExportInvite: %v", err)
}
if invite.Slug != "slug-one" || !folder.IsChatlist || !folder.HasMyInvites {
t.Fatalf("export = folder %+v invite %+v, want chatlist with invite slug", folder, invite)
}
persisted, found, err := dialogs.GetFolder(ctx, ownerID, 2)
if err != nil || !found || !persisted.IsChatlist || !persisted.HasMyInvites {
t.Fatalf("persisted folder = %+v found %v err %v, want exported chatlist", persisted, found, err)
}
preview, err := svc.CheckInvite(ctx, viewerID, "https://t.me/addlist/slug-one")
if err != nil {
t.Fatalf("CheckInvite before join: %v", err)
}
if len(preview.Missing) != 1 || preview.Missing[0].Peer != peerA.Peer || preview.LocalFolder != nil {
t.Fatalf("preview before join = %+v, want missing peerA and no local folder", preview)
}
joined, err := svc.JoinInvite(ctx, viewerID, "slug-one", nil, 101)
if err != nil {
t.Fatalf("JoinInvite: %v", err)
}
if joined.Folder.ID != 2 || !joined.Folder.IsChatlist || joined.Folder.HasMyInvites || len(joined.Folder.IncludePeers) != 1 {
t.Fatalf("joined folder = %+v, want imported local chatlist with peerA", joined.Folder)
}
preview, err = svc.CheckInvite(ctx, viewerID, "slug-one")
if err != nil {
t.Fatalf("CheckInvite after join: %v", err)
}
if preview.LocalFolder == nil || preview.Membership == nil || len(preview.Already) != 1 || len(preview.Missing) != 0 {
t.Fatalf("preview after join = %+v, want already imported", preview)
}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IsChatlist: true,
HasMyInvites: true,
IncludePeers: []domain.DialogFolderPeer{peerA, peerB},
}); err != nil {
t.Fatalf("extend owner folder: %v", err)
}
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-one", nil, &[]domain.DialogFolderPeer{peerA, peerB}, false); err != nil {
t.Fatalf("EditInvite add peerB: %v", err)
}
updates, err := svc.GetUpdates(ctx, viewerID, joined.Folder.ID)
if err != nil {
t.Fatalf("GetUpdates: %v", err)
}
if len(updates.Missing) != 1 || updates.Missing[0].Peer != peerB.Peer {
t.Fatalf("updates = %+v, want missing peerB", updates)
}
ownerUpdates, err := svc.GetUpdates(ctx, ownerID, folder.ID)
if err != nil {
t.Fatalf("GetUpdates owner exported folder: %v", err)
}
if len(ownerUpdates.Missing) != 0 {
t.Fatalf("owner updates = %+v, want empty", ownerUpdates)
}
updated, err := svc.JoinUpdates(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerB}, 102)
if err != nil {
t.Fatalf("JoinUpdates: %v", err)
}
if len(updated.Folder.IncludePeers) != 2 {
t.Fatalf("updated folder peers = %+v, want 2 peers", updated.Folder.IncludePeers)
}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IsChatlist: true,
HasMyInvites: true,
IncludePeers: []domain.DialogFolderPeer{peerA, peerB, peerC},
}); err != nil {
t.Fatalf("extend owner folder with peerC: %v", err)
}
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-one", nil, &[]domain.DialogFolderPeer{peerA, peerB, peerC}, false); err != nil {
t.Fatalf("EditInvite add peerC: %v", err)
}
if err := svc.HideUpdates(ctx, viewerID, joined.Folder.ID); err != nil {
t.Fatalf("HideUpdates: %v", err)
}
updates, err = svc.GetUpdates(ctx, viewerID, joined.Folder.ID)
if err != nil {
t.Fatalf("GetUpdates after hide: %v", err)
}
if len(updates.Missing) != 0 {
t.Fatalf("updates after hide = %+v, want empty", updates)
}
suggestions, err := svc.LeaveSuggestions(ctx, viewerID, joined.Folder.ID)
if err != nil {
t.Fatalf("LeaveSuggestions: %v", err)
}
if len(suggestions) != 2 {
t.Fatalf("leave suggestions = %+v, want current two imported peers", suggestions)
}
if _, err := svc.Leave(ctx, viewerID, joined.Folder.ID, suggestions, 103); err != nil {
t.Fatalf("Leave: %v", err)
}
if _, found, err := dialogs.GetFolder(ctx, viewerID, joined.Folder.ID); err != nil || found {
t.Fatalf("local folder after leave found=%v err=%v, want deleted", found, err)
}
}
func TestDeleteLastSharedFolderInviteClearsOwnerFlag(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
chatlists := memory.NewChatlistStore()
slugs := []string{"slug-a", "slug-b"}
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) {
slug := slugs[0]
slugs = slugs[1:]
return slug, nil
}))
ownerID := int64(1001)
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IncludePeers: []domain.DialogFolderPeer{peer},
}); err != nil {
t.Fatalf("seed owner folder: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "one", []domain.DialogFolderPeer{peer}, 100); err != nil {
t.Fatalf("ExportInvite one: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "two", []domain.DialogFolderPeer{peer}, 101); err != nil {
t.Fatalf("ExportInvite two: %v", err)
}
if folder, changed, err := svc.DeleteInvite(ctx, ownerID, 2, "slug-a"); err != nil || changed || folder.HasMyInvites {
t.Fatalf("DeleteInvite first = folder %+v changed %v err %v, want no folder update", folder, changed, err)
}
persisted, found, err := dialogs.GetFolder(ctx, ownerID, 2)
if err != nil || !found || !persisted.HasMyInvites {
t.Fatalf("folder after first delete = %+v found %v err %v, want has_my_invites", persisted, found, err)
}
folder, changed, err := svc.DeleteInvite(ctx, ownerID, 2, "slug-b")
if err != nil || !changed || folder.HasMyInvites {
t.Fatalf("DeleteInvite last = folder %+v changed %v err %v, want cleared flag", folder, changed, err)
}
persisted, found, err = dialogs.GetFolder(ctx, ownerID, 2)
if err != nil || !found || persisted.HasMyInvites {
t.Fatalf("folder after last delete = %+v found %v err %v, want has_my_invites=false", persisted, found, err)
}
}
func TestSharedFolderSlugValidation(t *testing.T) {
ctx := context.Background()
svc := NewService(memory.NewChatlistStore(), memory.NewDialogStore())
if _, err := svc.CheckInvite(ctx, 2002, "bad!"); !errors.Is(err, domain.ErrChatlistInviteInvalid) {
t.Fatalf("CheckInvite bad slug err = %v, want ErrChatlistInviteInvalid", err)
}
longSlug := strings.Repeat("a", links.MaxChatlistSlugBytes+1)
if _, err := svc.JoinInvite(ctx, 2002, longSlug, nil, 100); !errors.Is(err, domain.ErrChatlistInviteInvalid) {
t.Fatalf("JoinInvite long slug err = %v, want ErrChatlistInviteInvalid", err)
}
}
func TestRevokedSharedFolderInviteRemainsListedButCannotBeImported(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
chatlists := memory.NewChatlistStore()
svc := NewService(chatlists, dialogs, WithSlugGenerator(func() (string, error) { return "slug-revoke", nil }))
ownerID := int64(1001)
viewerID := int64(2002)
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IncludePeers: []domain.DialogFolderPeer{peer},
}); err != nil {
t.Fatalf("seed owner folder: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peer}, 100); err != nil {
t.Fatalf("ExportInvite: %v", err)
}
revoked, err := svc.EditInvite(ctx, ownerID, 2, "slug-revoke", nil, nil, true)
if err != nil {
t.Fatalf("EditInvite revoke: %v", err)
}
if !revoked.Revoked {
t.Fatalf("revoked invite = %+v, want Revoked=true", revoked)
}
invites, err := svc.ListInvites(ctx, ownerID, 2)
if err != nil {
t.Fatalf("ListInvites: %v", err)
}
if len(invites) != 1 || !invites[0].Revoked {
t.Fatalf("listed invites = %+v, want revoked invite visible to owner", invites)
}
if _, err := svc.CheckInvite(ctx, viewerID, "slug-revoke"); !errors.Is(err, domain.ErrChatlistInviteExpired) {
t.Fatalf("CheckInvite revoked err = %v, want ErrChatlistInviteExpired", err)
}
if _, err := svc.JoinInvite(ctx, viewerID, "slug-revoke", nil, 101); !errors.Is(err, domain.ErrChatlistInviteExpired) {
t.Fatalf("JoinInvite revoked err = %v, want ErrChatlistInviteExpired", err)
}
}
func TestSharedFolderJoinUpdatesAndLeaveUseChannelMemberships(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
channels := &fakeChatlistChannels{}
svc := NewService(
memory.NewChatlistStore(),
dialogs,
WithChannels(channels),
WithSlugGenerator(func() (string, error) { return "slug-channel", nil }),
)
ownerID := int64(1001)
viewerID := int64(2002)
peerA := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
peerB := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3002}, AccessHash: 32}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IncludePeers: []domain.DialogFolderPeer{peerA, peerB},
}); err != nil {
t.Fatalf("seed owner folder: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "Main link", []domain.DialogFolderPeer{peerA, peerB}, 100); err != nil {
t.Fatalf("ExportInvite: %v", err)
}
if got := channels.getIDs; len(got) != 2 || got[0] != peerA.Peer.ID || got[1] != peerB.Peer.ID {
t.Fatalf("shareability channel checks = %v, want peerA/peerB", got)
}
joined, err := svc.JoinInvite(ctx, viewerID, "slug-channel", []domain.DialogFolderPeer{peerA}, 101)
if err != nil {
t.Fatalf("JoinInvite peerA: %v", err)
}
if got := channels.inviteIDs; len(got) != 1 || got[0] != peerA.Peer.ID {
t.Fatalf("invite channel calls = %v, want peerA", got)
}
if len(joined.ChannelResults) != 1 || joined.ChannelResults[0].Channel.ID != peerA.Peer.ID {
t.Fatalf("join channel results = %+v, want peerA result", joined.ChannelResults)
}
updated, err := svc.JoinUpdates(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerB}, 102)
if err != nil {
t.Fatalf("JoinUpdates peerB: %v", err)
}
if got := channels.inviteIDs; len(got) != 2 || got[1] != peerB.Peer.ID {
t.Fatalf("invite channel calls after updates = %v, want peerB appended", got)
}
if len(updated.ChannelResults) != 1 || updated.ChannelResults[0].Channel.ID != peerB.Peer.ID {
t.Fatalf("update channel results = %+v, want peerB result", updated.ChannelResults)
}
leave, err := svc.Leave(ctx, viewerID, joined.Folder.ID, []domain.DialogFolderPeer{peerA}, 103)
if err != nil {
t.Fatalf("Leave peerA: %v", err)
}
if got := channels.leaveIDs; len(got) != 1 || got[0] != peerA.Peer.ID {
t.Fatalf("leave channel calls = %v, want peerA", got)
}
if len(leave.ChannelResults) != 1 || leave.ChannelResults[0].Channel.ID != peerA.Peer.ID {
t.Fatalf("leave channel results = %+v, want peerA result", leave.ChannelResults)
}
}
func TestSharedFolderPublicPeerFallsBackToSelfJoin(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
channels := &fakeChatlistChannels{publicOnly: true}
svc := NewService(
memory.NewChatlistStore(),
dialogs,
WithChannels(channels),
WithSlugGenerator(func() (string, error) { return "slug-public", nil }),
)
ownerID := int64(1001)
viewerID := int64(2002)
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Public",
IncludePeers: []domain.DialogFolderPeer{peer},
}); err != nil {
t.Fatalf("seed owner folder: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 100); err != nil {
t.Fatalf("ExportInvite: %v", err)
}
if _, err := svc.JoinInvite(ctx, viewerID, "slug-public", []domain.DialogFolderPeer{peer}, 101); err != nil {
t.Fatalf("JoinInvite public peer: %v", err)
}
if len(channels.inviteIDs) != 0 {
t.Fatalf("invite channel calls = %v, want none for public-only owner", channels.inviteIDs)
}
if got := channels.joinIDs; len(got) != 1 || got[0] != peer.Peer.ID {
t.Fatalf("join channel calls = %v, want self-join peer", got)
}
}
func TestExportInviteRejectsRuleBasedFolder(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
svc := NewService(memory.NewChatlistStore(), dialogs, WithSlugGenerator(func() (string, error) { return "slug-two", nil }))
ownerID := int64(1001)
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Rule based",
Groups: true,
IncludePeers: []domain.DialogFolderPeer{peer},
}); err != nil {
t.Fatalf("seed folder: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 0); !errors.Is(err, domain.ErrChatlistNotShareable) {
t.Fatalf("ExportInvite rule folder err = %v, want ErrChatlistNotShareable", err)
}
}
func TestExportInviteRejectsUserPeers(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
svc := NewService(memory.NewChatlistStore(), dialogs, WithSlugGenerator(func() (string, error) { return "slug-user-peer", nil }))
ownerID := int64(1001)
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: 2002}, AccessHash: 22}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Users",
IncludePeers: []domain.DialogFolderPeer{peer},
}); err != nil {
t.Fatalf("seed folder: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 0); !errors.Is(err, domain.ErrChatlistNotShareable) {
t.Fatalf("ExportInvite user peer err = %v, want ErrChatlistNotShareable", err)
}
}
func TestChatlistInviteLimitUsesPremiumTier(t *testing.T) {
ctx := context.Background()
dialogs := memory.NewDialogStore()
chatlists := memory.NewChatlistStore()
ownerID := int64(1001)
peer := domain.DialogFolderPeer{Peer: domain.Peer{Type: domain.PeerTypeChannel, ID: 3001}, AccessHash: 31}
if err := dialogs.UpsertFolder(ctx, ownerID, domain.DialogFolder{
ID: 2,
Title: "Team",
IncludePeers: []domain.DialogFolderPeer{peer},
}); err != nil {
t.Fatalf("seed folder: %v", err)
}
nextSlug := 0
slugger := func() (string, error) {
nextSlug++
return "slug-limit-" + string(rune('a'+nextSlug)), nil
}
svc := NewService(chatlists, dialogs, WithSlugGenerator(slugger))
for i := 0; i < domain.MaxChatlistInvitesDefault; i++ {
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, i+1); err != nil {
t.Fatalf("ExportInvite default #%d: %v", i+1, err)
}
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 10); !errors.Is(err, domain.ErrChatlistInvitesTooMuch) {
t.Fatalf("ExportInvite over default limit err = %v, want ErrChatlistInvitesTooMuch", err)
}
if _, err := svc.EditInvite(ctx, ownerID, 2, "slug-limit-b", nil, nil, true); err != nil {
t.Fatalf("EditInvite revoke first default link: %v", err)
}
if _, _, err := svc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 11); err != nil {
t.Fatalf("ExportInvite after revoked link freed active limit: %v", err)
}
premiumSvc := NewService(
chatlists,
dialogs,
WithSlugGenerator(slugger),
WithPremiumChecker(func(context.Context, int64) bool { return true }),
)
if _, _, err := premiumSvc.ExportInvite(ctx, ownerID, 2, "", []domain.DialogFolderPeer{peer}, 11); err != nil {
t.Fatalf("ExportInvite premium extra link: %v", err)
}
}
type fakeChatlistChannels struct {
getIDs []int64
inviteIDs []int64
joinIDs []int64
leaveIDs []int64
publicOnly bool
}
func (f *fakeChatlistChannels) GetChannel(_ context.Context, userID, channelID int64) (domain.ChannelView, error) {
f.getIDs = append(f.getIDs, channelID)
role := domain.ChannelRoleCreator
if f.publicOnly {
role = domain.ChannelRoleMember
}
return domain.ChannelView{
Channel: domain.Channel{
ID: channelID,
AccessHash: channelID * 10,
Title: "Team",
Username: "team",
Megagroup: true,
},
Self: domain.ChannelMember{
ChannelID: channelID,
UserID: userID,
Role: role,
Status: domain.ChannelMemberActive,
},
}, nil
}
func (f *fakeChatlistChannels) InviteToChannel(_ context.Context, userID, channelID int64, userIDs []int64, date int) (domain.CreateChannelResult, error) {
f.inviteIDs = append(f.inviteIDs, channelID)
if len(userIDs) == 0 {
return domain.CreateChannelResult{}, domain.ErrUsersTooMuch
}
return fakeChatlistChannelResult(userIDs[0], channelID, domain.ChannelMemberActive, date), nil
}
func (f *fakeChatlistChannels) JoinChannel(_ context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
f.joinIDs = append(f.joinIDs, channelID)
return fakeChatlistChannelResult(userID, channelID, domain.ChannelMemberActive, date), nil
}
func (f *fakeChatlistChannels) LeaveChannel(_ context.Context, userID, channelID int64, date int) (domain.CreateChannelResult, error) {
f.leaveIDs = append(f.leaveIDs, channelID)
return fakeChatlistChannelResult(userID, channelID, domain.ChannelMemberLeft, date), nil
}
func fakeChatlistChannelResult(userID, channelID int64, status domain.ChannelMemberStatus, date int) domain.CreateChannelResult {
return domain.CreateChannelResult{
Channel: domain.Channel{
ID: channelID,
AccessHash: channelID * 10,
Title: "Team",
Username: "team",
Megagroup: true,
Date: date,
},
Members: []domain.ChannelMember{{
ChannelID: channelID,
UserID: userID,
Role: domain.ChannelRoleMember,
Status: status,
JoinedAt: date,
}},
Recipients: []int64{userID},
}
}