package app

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"
	"testing"
	"time"

	"github.com/stretchr/testify/require"

	"github.com/tidbcloud/AgentTeam/internal/coredb"
	"github.com/tidbcloud/AgentTeam/internal/store/app/registry"
	"github.com/tidbcloud/AgentTeam/internal/store/app/runtime"
	"github.com/tidbcloud/AgentTeam/internal/store/message"
	"github.com/tidbcloud/AgentTeam/internal/store/model"
	"github.com/tidbcloud/AgentTeam/internal/store/provider"
)

// externalAppAppSeed holds the persisted definition/version data needed by
// external App runtime integration tests. App authoring ownership is
// deliberately absent: installation and mutation authority are workspace
// approval/admin concerns, not creator privileges.
type externalAppAppSeed struct {
	AppID       string
	VersionID   string
	Slug        string
	Name        string
	Visibility  string
	Version     string
	Scopes      []string
	Manifest    map[string]any
	CallbackURL string
	CreatedAt   time.Time
}

func insertExternalAppAppForTests(t *testing.T, pool coredb.QueryExecer, seed externalAppAppSeed) {
	t.Helper()
	manifestDocument := map[string]any{
		"schema_version": "1",
		"slug":           seed.Slug,
		"name":           seed.Name,
		"version":        seed.Version,
		"scopes":         seed.Scopes,
		"config_schema": map[string]any{
			"type": "object",
		},
		"credentials_schema": map[string]any{
			"type": "object",
		},
	}
	if seed.Manifest != nil {
		manifestDocument = seed.Manifest
		if _, ok := manifestDocument["config_schema"]; !ok {
			manifestDocument["config_schema"] = map[string]any{"type": "object"}
		}
		if _, ok := manifestDocument["credentials_schema"]; !ok {
			manifestDocument["credentials_schema"] = map[string]any{"type": "object"}
		}
	}
	manifest, err := json.Marshal(manifestDocument)
	require.NoError(t, err)
	visibility := strings.TrimSpace(seed.Visibility)
	if visibility == "" {
		visibility = "unlisted"
	}
	mustExec(t, pool, `
INSERT INTO service_definitions (
    id, service_kind, installation_mode, slug, name, description, avatar_url,
    visibility, developer_id, latest_version, manifest, config, status, created_at, updated_at
)
VALUES ($1, 'app', 'singleton', $2, $3, '', '', $4, '', $5, $6,
        '{"appKind":"third_party","callbackSigningSecret":"test-signing-secret"}', 'inactive', $7, $7)
`, seed.AppID, seed.Slug, seed.Name, visibility, seed.Version, manifest, seed.CreatedAt)
	callbackURL := strings.TrimSpace(seed.CallbackURL)
	if callbackURL == "" {
		callbackURL = "https://example.com/agentteam/" + seed.Slug
	}
	mustExec(t, pool, `
INSERT INTO extension_definition_versions (id, service_definition_id, version, manifest, callback_url, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $6)
`, seed.VersionID, seed.AppID, seed.Version, manifest, callbackURL, seed.CreatedAt)
}

// mustExec executes a query that must succeed.
func mustExec(t *testing.T, queryer coredb.QueryExecer, query string, args ...any) {
	t.Helper()
	_, err := queryer.Exec(context.Background(), query, args...)
	require.NoError(t, err)
}

func cancelAppRuntimeDeliveryForTests(t *testing.T, ctx context.Context, store *Store, workspaceID string, installationID string, deliveryID string, reason string) bool {
	t.Helper()

	var changed bool
	require.NoError(t, coredb.WithTx(ctx, store.db, func(tx coredb.Tx) error {
		if _, err := store.serviceActorStore().InTx(tx).LoadInstallationForUpdate(ctx, installationID); err != nil {
			return err
		}
		var err error
		changed, err = store.cancelAppRuntimeDeliveryInTx(ctx, tx, workspaceID, deliveryID, reason, time.Now().UTC())
		return err
	}))
	return changed
}

func seedWorkspaceOrganizationForTests(t *testing.T, pool coredb.QueryExecer, organizationID string, slug string, createdByUserID string, now time.Time) {
	t.Helper()

	defaultTeamID := organizationID + "_default_team"
	defaultUnitID := organizationID + "_default_unit"
	memberID := organizationID + "_owner_member"
	teamMembershipID := organizationID + "_owner_team"
	unitMembershipID := organizationID + "_owner_unit"

	mustExec(t, pool, `
INSERT INTO organizations (id, slug, name, created_by_user_id, created_at, updated_at)
VALUES ($1, $2, $2, $3, $4, $4)
`, organizationID, slug, createdByUserID, now)
	mustExec(t, pool, `
INSERT INTO organization_settings (
    organization_id,
    status,
    timezone,
    join_policy,
    directory_backend,
    workspace_bootstrap_policy,
    service_install_policy,
    created_at,
    updated_at
) VALUES ($1, 'active', 'UTC', 'admin_invite', 'embedded', 'manual', 'workspace_admin', $2, $2)
`, organizationID, now)
	mustExec(t, pool, `
INSERT INTO organization_teams (
    id,
    organization_id,
    name,
    slug,
    description,
    status,
    source,
    is_default,
    created_at,
    updated_at
) VALUES ($1, $2, 'General', 'general', '', 'active', 'system', TRUE, $3, $3)
`, defaultTeamID, organizationID, now)
	mustExec(t, pool, `
INSERT INTO organization_units (
    id,
    organization_id,
    parent_unit_id,
    manager_member_id,
    name,
    slug,
    status,
    external_provider,
    external_id,
    synced_at,
    is_default,
    created_by_user_id,
    created_at,
    updated_at
) VALUES ($1, $2, NULL, NULL, 'General', 'general', 'active', '', '', NULL, TRUE, $3, $4, $4)
`, defaultUnitID, organizationID, createdByUserID, now)
	mustExec(t, pool, `
INSERT INTO organization_members (
    id,
    organization_id,
    user_id,
    primary_team_id,
    primary_unit_id,
    org_role,
    status,
    source,
    default_workspace_role,
    joined_at,
    invited_by_user_id,
    created_by_user_id,
    created_at,
    updated_at
) VALUES ($1, $2, $3, $4, $5, 'org_owner', 'active', 'local', 'member', $6, $3, $3, $6, $6)
`, memberID, organizationID, createdByUserID, defaultTeamID, defaultUnitID, now)
	mustExec(t, pool, `
INSERT INTO organization_member_team_memberships (
    id,
    organization_id,
    organization_team_id,
    organization_member_id,
    membership_role,
    source,
    status,
    is_primary,
    created_by_user_id,
    created_at,
    updated_at
) VALUES ($1, $2, $3, $4, 'team_member', 'direct', 'active', TRUE, $5, $6, $6)
`, teamMembershipID, organizationID, defaultTeamID, memberID, createdByUserID, now)
	mustExec(t, pool, `
INSERT INTO organization_member_unit_memberships (
    id,
    organization_id,
    organization_unit_id,
    organization_member_id,
    source,
    status,
    is_primary,
    created_by_user_id,
    created_at,
    updated_at
) VALUES ($1, $2, $3, $4, 'direct', 'active', TRUE, $5, $6, $6)
`, unitMembershipID, organizationID, defaultUnitID, memberID, createdByUserID, now)
}

// appAdminFixture holds IDs for a seeded test workspace admin user.
type appAdminFixture struct {
	UserID         string
	OrganizationID string
	WorkspaceID    string
	MemberID       string
	AllChannelID   string
	AllThreadID    string
}

// seedAppAdminFixture creates a user, workspace, admin member, all-channel, and
// all-thread in one shot for integration tests.
func seedAppAdminFixture(t *testing.T, pool coredb.QueryExecer, suffix string, now time.Time) appAdminFixture {
	t.Helper()

	fixture := appAdminFixture{
		UserID:         fmt.Sprintf("usr_%s", suffix),
		OrganizationID: fmt.Sprintf("org_%s", suffix),
		WorkspaceID:    fmt.Sprintf("wsp_%s", suffix),
		MemberID:       fmt.Sprintf("mem_%s", suffix),
		AllChannelID:   fmt.Sprintf("chn_%s_all", suffix),
		AllThreadID:    fmt.Sprintf("chn_%s_all", suffix),
	}

	mustExec(t, pool, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'hash', FALSE, $5, $5, $5)
`, fixture.UserID, suffix+"@example.com", suffix, suffix, now)
	seedWorkspaceOrganizationForTests(t, pool, fixture.OrganizationID, suffix, fixture.UserID, now)
	mustExec(t, pool, `
INSERT INTO workspaces (id, organization_id, slug, name, latest_message_seq, created_at, updated_at)
VALUES ($1, $2, $3, $3, 0, $4, $4)
`, fixture.WorkspaceID, fixture.OrganizationID, suffix, now)
	mustExec(t, pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'admin', 'active', $5, $5)
`, fixture.MemberID, fixture.WorkspaceID, suffix, fixture.UserID, now)
	seedChannelParticipantProjectionForTests(t, pool, fixture.WorkspaceID, fixture.AllChannelID, "all", "", "public", fixture.MemberID, fixture.MemberID, now)

	return fixture
}

// seedGitHubConfigSystemAdminFixture creates a system-admin user, workspace,
// admin member, all-channel, and all-thread (user with is_admin=TRUE).
func seedGitHubConfigSystemAdminFixture(t *testing.T, pool coredb.QueryExecer, suffix string, now time.Time) appAdminFixture {
	t.Helper()

	fixture := appAdminFixture{
		UserID:         "usr_" + suffix,
		OrganizationID: "org_" + suffix,
		WorkspaceID:    "wsp_" + suffix,
		MemberID:       "mem_" + suffix,
		AllChannelID:   "chn_" + suffix + "_all",
		AllThreadID:    "chn_" + suffix + "_all",
	}

	mustExec(t, pool, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'hash', TRUE, $5, $5, $5)
`, fixture.UserID, suffix+"@example.com", suffix, suffix, now)
	seedWorkspaceOrganizationForTests(t, pool, fixture.OrganizationID, suffix, fixture.UserID, now)
	mustExec(t, pool, `
INSERT INTO workspaces (id, organization_id, slug, name, latest_message_seq, created_at, updated_at)
VALUES ($1, $2, $3, $3, 0, $4, $4)
`, fixture.WorkspaceID, fixture.OrganizationID, suffix, now)
	mustExec(t, pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'admin', 'active', $5, $5)
`, fixture.MemberID, fixture.WorkspaceID, suffix, fixture.UserID, now)
	seedChannelParticipantProjectionForTests(t, pool, fixture.WorkspaceID, fixture.AllChannelID, "all", "", "public", fixture.MemberID, fixture.MemberID, now)

	return fixture
}

func seedChannelParticipantProjectionForTests(t *testing.T, pool coredb.QueryExecer, workspaceID string, channelID string, slug string, description string, visibility string, ownerMemberID string, participantMemberID string, now time.Time) {
	t.Helper()
	seedChannelParticipantProjectionWithDisplayNameForTests(t, pool, workspaceID, channelID, slug, slug, description, visibility, ownerMemberID, participantMemberID, now)
}

func seedChannelParticipantProjectionWithDisplayNameForTests(t *testing.T, pool coredb.QueryExecer, workspaceID string, channelID string, slug string, displayName string, description string, visibility string, ownerMemberID string, participantMemberID string, now time.Time) {
	t.Helper()
	canonicalSlug := strings.TrimSpace(slug)
	canonicalDisplayName := strings.TrimSpace(displayName)
	if canonicalDisplayName == "" {
		canonicalDisplayName = canonicalSlug
	}

	mustExec(t, pool, `
INSERT IGNORE INTO scopes (
  id, workspace_id, type, identity_key, latest_seq, latest_global_seq,
  archived_until_seq, status, created_by_member_id, created_at, updated_at
) VALUES ($1, $2, 'channel', $3, 0, 0, 0, 'active', $4, $5, $5)
`, channelID, workspaceID, "channel:"+channelID, ownerMemberID, now)
	mustExec(t, pool, `
INSERT IGNORE INTO scope_channel_profiles (
  scope_id, workspace_id, slug, display_name, description, visibility,
  owner_member_id, is_all_channel, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, CASE WHEN $3 = 'all' THEN 1 ELSE 0 END, $8, $8)
`, channelID, workspaceID, canonicalSlug, canonicalDisplayName, description, visibility, ownerMemberID, now)
	require.NoError(t, message.EnsureScopeMembership(context.Background(), pool, channelID, participantMemberID, "public", "", "active", 0, 0, now))
}

// seedWorkspaceMemberFixture creates a non-admin workspace member.
func seedWorkspaceMemberFixture(t *testing.T, pool coredb.QueryExecer, workspaceID string, suffix string, now time.Time) appAdminFixture {
	t.Helper()

	fixture := appAdminFixture{
		UserID:      "usr_" + suffix,
		WorkspaceID: strings.TrimSpace(workspaceID),
		MemberID:    "mem_" + suffix,
	}

	mustExec(t, pool, `
INSERT INTO users (id, email, name, display_name, password_hash, is_admin, email_verified_at, created_at, updated_at)
VALUES ($1, $2, $3, $4, 'hash', FALSE, $5, $5, $5)
`, fixture.UserID, suffix+"@example.com", suffix, suffix, now)
	mustExec(t, pool, `
INSERT INTO members (id, workspace_id, type, display_name, user_id, workspace_role, status, created_at, updated_at)
VALUES ($1, $2, 'human', $3, $4, 'member', 'active', $5, $5)
`, fixture.MemberID, fixture.WorkspaceID, suffix, fixture.UserID, now)

	return fixture
}

// requestVerifiedGitHubInstallationForTests follows the production member
// contract: provider verification creates a private setup proposal first, and
// only an explicit proposal-id submission creates the pending request.
func requestVerifiedGitHubInstallationForTests(
	t *testing.T,
	ctx context.Context,
	store *Store,
	requesterUserID string,
	workspaceID string,
	reason string,
) AppInstallationRequestView {
	t.Helper()
	proposal, err := store.SaveVerifiedGitHubSetupProposalForUser(
		ctx,
		requesterUserID,
		workspaceID,
		provider.UpsertGitHubAppConfigParams{
			AppID:         42,
			AppSlug:       "agentteam-app",
			AppName:       "AgentTeam App",
			HTMLURL:       "https://github.com/apps/agentteam-app",
			ClientID:      "Iv1.integration-test",
			ClientSecret:  "integration-test-client-secret",
			PrivateKeyPEM: "integration-test-private-key",
			WebhookSecret: "integration-test-webhook-secret",
			OwnerLogin:    "integration-test-owner",
			OwnerID:       7,
		},
	)
	require.NoError(t, err)
	request, err := store.RequestAppInstallationForUser(ctx, requesterUserID, workspaceID, RequestAppInstallationParams{
		AppID:                 registry.GitHubAppID,
		RequestKind:           AppInstallationRequestKindInstall,
		GitHubSetupProposalID: proposal.ID,
		Reason:                reason,
	})
	require.NoError(t, err)
	return request
}

// insertApprovedGitHubVersion inserts a GitHub app version for testing.
func insertApprovedGitHubVersion(t *testing.T, pool coredb.QueryExecer, versionID string, version string, scopes []string, now time.Time) {
	t.Helper()
	insertGitHubVersionForTests(t, pool, versionID, version, scopes, now)
}

// insertGitHubVersionForTests inserts a GitHub app version for testing.
func insertGitHubVersionForTests(t *testing.T, pool coredb.QueryExecer, versionID string, version string, scopes []string, now time.Time) {
	t.Helper()
	manifest, err := model.MarshalJSONObject("test GitHub manifest", map[string]any{
		"schema_version": "1",
		"slug":           "github",
		"name":           "GitHub",
		"version":        version,
		"scopes":         scopes,
	})
	require.NoError(t, err)

	mustExec(t, pool, `
	INSERT INTO extension_definition_versions (id, service_definition_id, version, manifest, created_at, updated_at)
	VALUES ($1, 'app_github', $2, $3, $4, $4)
	`, versionID, version, manifest, now)
}

// requestAndApproveAppInstallationForTests uses the member approval workflow
// when requester and approver differ. Legacy admin-only fixture callers are
// migrated to the explicit direct command so tests never manufacture a
// self-approval request. GitHub provider-store tests intentionally use the
// test-only fixture installer because their subject is the transition from an
// unconfigured legacy installation; production callers must use the atomic
// GitHub setup command instead.
func requestAndApproveAppInstallationForTests(
	t *testing.T,
	ctx context.Context,
	appStore *Store,
	requesterUserID string,
	approverUserID string,
	workspaceID string,
	appSlug string,
	config map[string]any,
) AppInstallationView {
	t.Helper()
	if strings.EqualFold(strings.TrimSpace(appSlug), giteaAppSlug) && config == nil {
		config = map[string]any{
			giteaConfigInstanceURLKey:   "https://gitea.example.com",
			giteaConfigAPITokenKey:      "integration-test-token",
			giteaConfigWebhookSecretKey: "integration-test-webhook-secret",
			giteaConfigDisplayNameKey:   "Integration Test Gitea",
		}
	}
	if strings.TrimSpace(requesterUserID) == strings.TrimSpace(approverUserID) {
		if strings.EqualFold(strings.TrimSpace(appSlug), registry.GitHubAppSlug) {
			installation, err := appStore.installAppForUserForTest(ctx, requesterUserID, workspaceID, InstallAppParams{
				AppSlug: appSlug,
				Config:  config,
			})
			require.NoError(t, err)
			return installation
		}
		if strings.EqualFold(strings.TrimSpace(appSlug), giteaAppSlug) {
			_, err := appStore.SaveGiteaAppConfigForWorkspaceAdmin(ctx, requesterUserID, workspaceID, provider.UpsertGiteaAppConfigParams{
				InstanceURL:   requestSnapshotString(config, giteaConfigInstanceURLKey),
				APIToken:      requestSnapshotString(config, giteaConfigAPITokenKey),
				WebhookSecret: requestSnapshotString(config, giteaConfigWebhookSecretKey),
				DisplayName:   requestSnapshotString(config, giteaConfigDisplayNameKey),
			})
			require.NoError(t, err)
			installations, err := appStore.ListAppInstallationsForUser(ctx, requesterUserID, workspaceID)
			require.NoError(t, err)
			for _, installation := range installations {
				if strings.EqualFold(installation.AppSlug, giteaAppSlug) {
					return installation
				}
			}
			require.FailNow(t, "direct Gitea fixture installation not found")
			return AppInstallationView{}
		}
		installation, err := appStore.InstallAppForWorkspaceAdmin(ctx, requesterUserID, workspaceID, InstallAppParams{
			AppSlug: appSlug,
			Config:  config,
		})
		require.NoError(t, err)
		return installation
	}
	request, err := appStore.RequestAppInstallationForUser(ctx, requesterUserID, workspaceID, RequestAppInstallationParams{
		AppSlug: appSlug,
		Config:  config,
		Reason:  "integration test installation",
	})
	require.NoError(t, err)
	approved, err := appStore.ApproveAppInstallationRequestForUser(ctx, approverUserID, workspaceID, request.ID, DecideAppInstallationRequestParams{
		Reason: "approved for integration test",
	})
	require.NoError(t, err)
	installation, err := appStore.AppInstallationViewByID(ctx, workspaceID, approved.InstallationID)
	require.NoError(t, err)
	return installation
}

// requestAndApproveExternalAppInstallationForTests admits a pre-seeded
// unlisted App through the same private onboarding capability used by the
// workspace App form. Runtime-focused tests intentionally seed definitions
// directly so they can choose stable IDs and secrets without creating an
// unrelated initial runner credential.
func requestAndApproveExternalAppInstallationForTests(
	t *testing.T,
	ctx context.Context,
	appStore *Store,
	requesterUserID string,
	approverUserID string,
	workspaceID string,
	appID string,
	appSlug string,
	config map[string]any,
) AppInstallationView {
	t.Helper()
	requester, err := appStore.requireWorkspaceAccess(ctx, requesterUserID, workspaceID)
	require.NoError(t, err)
	mustExec(t, appStore.db, `
INSERT INTO extension_catalog_attributions (
    workspace_id, service_definition_id, created_by_member_id, created_at, updated_at
)
SELECT $1, app.id, $3, NOW(), NOW()
FROM service_definitions app
WHERE app.id = $2
  AND NOT EXISTS (
      SELECT 1
      FROM extension_catalog_attributions attribution
      WHERE attribution.workspace_id = $1
        AND attribution.service_definition_id = $2
  )
	`, workspaceID, appID, requester.Member.ID)
	mustExec(t, appStore.db, `
INSERT IGNORE INTO extension_definition_managers (
    id, workspace_id, service_definition_id, member_id, status, version,
    granted_by_member_id, created_at, updated_at, ended_at
)
SELECT
    CONCAT('botmgr_test_', SUBSTRING(SHA2(CONCAT($1, ':', $2, ':', $3), 256), 1, 32)),
    $1, $2, $3, 'active', 1, $3, NOW(), NOW(), NULL
FROM extension_catalog_attributions attribution
WHERE attribution.workspace_id = $1
  AND attribution.service_definition_id = $2
`, workspaceID, appID, requester.Member.ID)
	if strings.TrimSpace(requesterUserID) == strings.TrimSpace(approverUserID) {
		installation, err := appStore.installAppForUserForTest(
			ctx,
			requesterUserID,
			workspaceID,
			InstallAppParams{AppID: appID, AppSlug: appSlug, Config: config},
		)
		require.NoError(t, err)
		return installation
	}
	request, err := appStore.RequestAppInstallationForUser(ctx, requesterUserID, workspaceID, RequestAppInstallationParams{
		AppSlug: appSlug,
		Config:  config,
		Reason:  "integration test App installation",
	})
	require.NoError(t, err)
	approved, err := appStore.ApproveAppInstallationRequestForUser(ctx, approverUserID, workspaceID, request.ID, DecideAppInstallationRequestParams{
		Reason: "approved for integration test",
	})
	require.NoError(t, err)
	installation, err := appStore.AppInstallationViewByID(ctx, workspaceID, approved.InstallationID)
	require.NoError(t, err)
	return installation
}

// activeAppGrantScopes returns the currently-active grant scopes for an installation.
func activeAppGrantScopes(t *testing.T, ctx context.Context, pool coredb.Queryer, installationID string) []string {
	t.Helper()

	rows, err := pool.Query(ctx, `
SELECT scope
FROM service_grants
WHERE service_installation_id = $1
  AND revoked_at IS NULL
ORDER BY scope ASC
`, installationID)
	require.NoError(t, err)
	defer rows.Close()

	scopes := make([]string, 0)
	for rows.Next() {
		var scope string
		require.NoError(t, rows.Scan(&scope))
		scopes = append(scopes, scope)
	}
	require.NoError(t, rows.Err())
	return scopes
}

// activeServiceGrantScopes returns the currently-active service grant scopes for an installation.
func activeServiceGrantScopes(t *testing.T, ctx context.Context, pool coredb.Queryer, installationID string) []string {
	t.Helper()

	rows, err := pool.Query(ctx, `
SELECT scope
FROM service_grants
WHERE service_installation_id = $1
  AND revoked_at IS NULL
ORDER BY scope ASC
`, installationID)
	require.NoError(t, err)
	defer rows.Close()

	scopes := make([]string, 0)
	for rows.Next() {
		var scope string
		require.NoError(t, rows.Scan(&scope))
		scopes = append(scopes, scope)
	}
	require.NoError(t, rows.Err())
	return scopes
}

// upsertAppConnectionForTests creates an app connection via the app Store.
func upsertAppConnectionForTests(
	t *testing.T,
	ctx context.Context,
	appStore *Store,
	installationID string,
	createdByUserID string,
	externalSubjectRef string,
	displayLabel string,
	credentials map[string]any,
) runtime.ConnectionView {
	t.Helper()

	connection, err := appStore.UpsertAppConnection(ctx, UpsertAppConnectionParams{
		InstallationID:     installationID,
		ExternalSubjectRef: externalSubjectRef,
		DisplayLabel:       displayLabel,
		Credentials:        credentials,
		CreatedByUserID:    createdByUserID,
	})
	require.NoError(t, err)
	return connection
}

// githubBaseScopes returns the base scopes for the GitHub app in tests.
func githubBaseScopes() []string {
	return []string{"messages.read", "messages.write", "threads.read"}
}
