Ctrl K
ComunidadGuías…

internal/app/chatlists/service_test.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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},
	}
}