mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(threads): ship threads and the Plutonium page to everyone (#3277)
This commit is contained in:
1 parent
1515a8487f
commit
f8505d19f7
53 files changed
+296
-2008
No files matched your search
@@ -1,8 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes';
|
||||
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
|
||||
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
isTainted,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
resolveThreadPermissionMode,
|
||||
shouldMaskThreadPermissionBits,
|
||||
} from '@app/api/guild/services/ThreadPermissionBits';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {
|
||||
applyProtectedOverwriteBits,
|
||||
@@ -26,14 +23,7 @@ import {
|
||||
protectedThreadBits,
|
||||
} from '@app/api/utils/featureUtils';
|
||||
import {computePermissionsDiff} from '@app/api/utils/PermissionUtils';
|
||||
import {
|
||||
enabledThreadGuildIds,
|
||||
enqueueThreadPermissionSeeds,
|
||||
newlyEnabledThreadGuildIds,
|
||||
seedThreadOverwriteBits,
|
||||
seedThreadOverwriteValue,
|
||||
} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {seedThreadOverwriteBits, seedThreadOverwriteValue} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
@@ -43,13 +33,9 @@ import {
|
||||
ThreadPermissionFlags,
|
||||
} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {
|
||||
ChannelThreadsConfigSchema,
|
||||
DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set();
|
||||
const CAPABLE: ReadonlySet<string> = new Set(['channel_threads']);
|
||||
@@ -254,61 +240,4 @@ describe('thread permission seeding rules', () => {
|
||||
Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('targets enabled, non-disabled guilds of an enabled config', () => {
|
||||
const config = {
|
||||
...DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
enabled: true,
|
||||
enabled_guild_ids: ['1', '2', '3'],
|
||||
disabled_guild_ids: ['3'],
|
||||
};
|
||||
expect(enabledThreadGuildIds(config)).toEqual(['1', '2']);
|
||||
expect(enabledThreadGuildIds({...config, enabled: false})).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists only guilds that became active in the landed config', () => {
|
||||
const previous = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
|
||||
expect(newlyEnabledThreadGuildIds(previous, previous)).toEqual([]);
|
||||
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled_guild_ids: ['1', '2', '3', '4']})).toEqual(['4']);
|
||||
expect(newlyEnabledThreadGuildIds({...previous, disabled_guild_ids: ['2']}, previous)).toEqual(['2']);
|
||||
expect(newlyEnabledThreadGuildIds({...previous, enabled: false}, previous)).toEqual(['1', '2', '3']);
|
||||
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled: false})).toEqual([]);
|
||||
});
|
||||
|
||||
it('enqueues seeds for every targeted guild without a seeded marker, so a retried update still seeds', async () => {
|
||||
const addJob = vi.fn(async () => {});
|
||||
setInjectedWorkerService({addJob} as unknown as IWorkerService<WorkerTaskName>);
|
||||
const markers = new Map<string, GuildThreadStateRow>([
|
||||
[
|
||||
'1',
|
||||
{
|
||||
guild_id: createGuildID(1n),
|
||||
first_active_at: new Date(),
|
||||
perms_seeded_at: new Date(),
|
||||
search_backfilled_at: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
'2',
|
||||
{guild_id: createGuildID(2n), first_active_at: new Date(), perms_seeded_at: null, search_backfilled_at: null},
|
||||
],
|
||||
]);
|
||||
const threads = {
|
||||
getGuildMarker: async (guildId: GuildID) => markers.get(guildId.toString()) ?? null,
|
||||
} as unknown as IThreadRepository;
|
||||
const config = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
|
||||
try {
|
||||
await enqueueThreadPermissionSeeds(threads, config);
|
||||
await enqueueThreadPermissionSeeds(threads, config);
|
||||
} finally {
|
||||
setInjectedWorkerService(undefined);
|
||||
}
|
||||
const keys = addJob.mock.calls.map((call) => (call as unknown as [string, unknown, {jobKey: string}])[2].jobKey);
|
||||
expect(keys).toEqual([
|
||||
'seed-thread-permissions-2',
|
||||
'seed-thread-permissions-3',
|
||||
'seed-thread-permissions-2',
|
||||
'seed-thread-permissions-3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,9 @@ import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannelID, createGuildID, createRoleID} from '@app/api/BrandedTypes';
|
||||
import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {fetchOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
|
||||
import {
|
||||
acceptInvite,
|
||||
@@ -20,7 +17,6 @@ import {
|
||||
getChannel,
|
||||
updateRolePositions,
|
||||
} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {GuildThreadState} from '@app/api/Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
@@ -31,10 +27,7 @@ import {
|
||||
THREAD_PERMISSIONS,
|
||||
ThreadPermissionFlags,
|
||||
} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
@@ -50,17 +43,6 @@ const ACTIVE: Partial<ChannelThreadsConfig> = {
|
||||
|
||||
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
|
||||
|
||||
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
...patch,
|
||||
ever_enabled: current.ever_enabled || patch.enabled === true,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
function bits(value: string | undefined): bigint {
|
||||
return BigInt(value ?? '0');
|
||||
}
|
||||
@@ -74,13 +56,11 @@ describe('thread permission bits across guild modes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -124,7 +104,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('active guilds seed @everyone, write bits for capable users and restore them for others', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Active');
|
||||
expect(await roleBits(owner.token, guild.id, guild.id)).toBe(DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS);
|
||||
@@ -155,7 +135,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('bots write thread bits and bot invites keep them only for capable active users in active guilds', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bots');
|
||||
const bot = await createTestBotAccount(harness);
|
||||
@@ -185,7 +165,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
.execute();
|
||||
expect(bits(vcm.permissions)).toBe(0n);
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
const controlOwner = await createTestAccount(harness);
|
||||
const controlGuild = await createGuild(harness, controlOwner.token, 'Bots control');
|
||||
const controlBot = await createTestBotAccount(harness);
|
||||
@@ -197,7 +177,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('retired guilds keep stored thread bits read-only without escalation errors', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Retired');
|
||||
@@ -222,7 +202,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, mod.id);
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
const updated = await createBuilder<GuildRoleResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${target.id}`)
|
||||
.header(FEATURES, CAPABLE)
|
||||
@@ -268,7 +248,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('active channel creates store thread-aware overwrites and mask them for non-viewers', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Overwrites');
|
||||
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -309,7 +289,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('overwrite removals keep thread bits the actor cannot write, without escalation errors', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Removals');
|
||||
@@ -352,7 +332,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
expect([after.everyone?.allow, after.everyone?.deny]).toEqual([inThreads, 0n]);
|
||||
expect([after.target?.allow, after.target?.deny]).toEqual([0n, manageThreads]);
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.header(FEATURES, CAPABLE)
|
||||
@@ -368,7 +348,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('deleting a forum removes its thread-only index row', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Forum delete');
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
@@ -390,7 +370,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
expect(await repository.channelData.countGuildChannels(guildId)).toBe(before);
|
||||
});
|
||||
test('category sync and delete in a retired guild update forums without dispatching them', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Retired category');
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
@@ -407,7 +387,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
const forumId = createChannelID(BigInt(text.id) + 1n);
|
||||
await repository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'forum'});
|
||||
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).not.toBeNull();
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const updatedIds = () =>
|
||||
dispatchSpy.mock.calls
|
||||
@@ -431,7 +411,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
}
|
||||
});
|
||||
test('channel overwrite edits keep thread bits for clients that cannot write them', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Overwrite edits');
|
||||
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -474,7 +454,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
expect(await storedAllow()).toBeUndefined();
|
||||
});
|
||||
test('lock_permissions moves copy parent thread bits only for writers', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Lock sync');
|
||||
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -545,7 +525,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
const controlRoles = await listRoles(controlOwner.token, controlGuild.id, true);
|
||||
expect(controlRoles.map((role) => bits(role.permissions) & THREAD_PERMISSIONS)).toEqual([0n, 0n]);
|
||||
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await templated(owner.token);
|
||||
const roles = await listRoles(owner.token, guild.id, true);
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {acceptInvite, createChannelInvite, createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const FEATURES = 'X-Fluxer-Features';
|
||||
const CAPABLE = 'channel_threads';
|
||||
|
||||
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
...patch,
|
||||
ever_enabled: current.ever_enabled || patch.enabled === true,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
function resetGate(): void {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
describe('threads_active on GET /users/@me/guilds', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
@@ -46,12 +23,12 @@ describe('threads_active on GET /users/@me/guilds', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetGate();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetGate();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -93,7 +70,7 @@ describe('threads_active on GET /users/@me/guilds', () => {
|
||||
|
||||
test('flags only the enrolled guild for an enrolled capable viewer', async () => {
|
||||
const {owner, enrolled, plain, ownerBaseline} = await setup();
|
||||
await setConfig(enrol(enrolled, [owner.userId]));
|
||||
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
|
||||
const guilds = await listGuilds(owner.token, true);
|
||||
const byId = new Map(guilds.map((guild) => [guild.id, guild]));
|
||||
const baseline = new Map(ownerBaseline.map((guild) => [guild.id, guild]));
|
||||
@@ -109,17 +86,17 @@ describe('threads_active on GET /users/@me/guilds', () => {
|
||||
for (const guild of guilds) expect(guild).not.toHaveProperty('threads_active');
|
||||
};
|
||||
|
||||
await setConfig(enrol(enrolled, [owner.userId]));
|
||||
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
|
||||
expectUnchanged(await listGuilds(member.token, true), memberBaseline);
|
||||
expectUnchanged(await listGuilds(owner.token, false), ownerBaseline);
|
||||
|
||||
await setConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
|
||||
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
|
||||
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
|
||||
|
||||
await setConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
|
||||
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
|
||||
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
|
||||
|
||||
await setConfig({...enrol(enrolled, [owner.userId]), enabled: false});
|
||||
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), enabled: false});
|
||||
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user