mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
140 lines
6.2 KiB
TypeScript
140 lines
6.2 KiB
TypeScript
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
|
import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes';
|
|
import {createTestGuildStack} from '@app/api/channel/tests/CrosspostTestUtils';
|
|
import {processRecordedGuildDeletions, recordGuildDeletions} from '@app/api/guild/tests/GuildDeletionTestUtils';
|
|
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
|
import {getGuildRepository} from '@app/api/middleware/ServiceSingletons';
|
|
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
|
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
|
import type {RecordingWorkerService} from '@app/api/test/RecordingWorkerService';
|
|
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
|
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
|
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
|
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
|
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
|
|
|
describe('Guild deletion job', () => {
|
|
let harness: ApiTestHarness;
|
|
let worker: RecordingWorkerService;
|
|
|
|
beforeAll(async () => {
|
|
harness = await createApiTestHarness();
|
|
});
|
|
|
|
beforeEach(async () => {
|
|
await harness.reset();
|
|
worker = recordGuildDeletions();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await harness?.shutdown();
|
|
});
|
|
|
|
async function guildWithMember(): Promise<{owner: TestAccount; member: TestAccount; guild: GuildResponse}> {
|
|
const owner = await createTestAccount(harness);
|
|
const member = await createTestAccount(harness);
|
|
const guild = await createGuild(harness, owner.token, 'Deletion Guild');
|
|
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
|
const invite = await createChannelInvite(harness, owner.token, channel.id);
|
|
await acceptInvite(harness, member.token, invite.code);
|
|
return {owner, member, guild};
|
|
}
|
|
|
|
async function deleteAsOwner(owner: TestAccount, guildId: string): Promise<void> {
|
|
await createBuilder(harness, owner.token)
|
|
.post(`/guilds/${guildId}/delete`)
|
|
.body({password: owner.password})
|
|
.expect(HTTP_STATUS.NO_CONTENT)
|
|
.execute();
|
|
}
|
|
|
|
function storedGuild(guildId: string) {
|
|
return getGuildRepository().findUnique(createGuildID(BigInt(guildId)));
|
|
}
|
|
|
|
test('the request hides the guild and leaves the purge to the worker', async () => {
|
|
const {owner, member, guild} = await guildWithMember();
|
|
const dispatched = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
|
const leaves = vi.spyOn(NoopGatewayService.prototype, 'leaveGuild');
|
|
await deleteAsOwner(owner, guild.id);
|
|
const jobs = worker.byTask('guildProcessDeletion');
|
|
expect(jobs).toHaveLength(1);
|
|
expect(jobs[0]?.payload).toEqual({guildId: guild.id, copyMode: 'source_deleted'});
|
|
expect(jobs[0]?.options?.jobKey).toBe(`guild-deletion-${guild.id}`);
|
|
expect(dispatched).toHaveBeenCalledWith(expect.objectContaining({event: 'GUILD_DELETE'}));
|
|
expect(leaves).not.toHaveBeenCalled();
|
|
const pending = await storedGuild(guild.id);
|
|
expect(pending?.features.has(GuildFeatures.UNAVAILABLE_FOR_EVERYONE)).toBe(true);
|
|
expect(pending?.features.has(GuildFeatures.UNAVAILABLE_HIDDEN)).toBe(true);
|
|
await createBuilder(harness, member.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.FORBIDDEN).execute();
|
|
await processRecordedGuildDeletions(worker);
|
|
expect(await storedGuild(guild.id)).toBeNull();
|
|
await createBuilder(harness, owner.token).get(`/guilds/${guild.id}`).expect(HTTP_STATUS.NOT_FOUND).execute();
|
|
});
|
|
|
|
test('an admin deletion enqueues a purge job', async () => {
|
|
const admin = await createTestAccount(harness);
|
|
await setUserACLs(harness, admin, ['admin:authenticate', 'guild:lookup', 'guild:delete']);
|
|
const guild = await createGuild(harness, admin.token, 'Admin Deletion Guild');
|
|
await createBuilder(harness, admin.token)
|
|
.delete(`/admin/guilds/${guild.id}`)
|
|
.body(null)
|
|
.expect(HTTP_STATUS.OK)
|
|
.execute();
|
|
expect(worker.byTask('guildProcessDeletion').map((job) => job.payload)).toEqual([
|
|
{guildId: guild.id, copyMode: 'purge'},
|
|
]);
|
|
await processRecordedGuildDeletions(worker);
|
|
expect(await storedGuild(guild.id)).toBeNull();
|
|
});
|
|
|
|
test('a purge interrupted by a gateway failure completes when retried', async () => {
|
|
const {owner, guild} = await guildWithMember();
|
|
await deleteAsOwner(owner, guild.id);
|
|
const leaveGuild = NoopGatewayService.prototype.leaveGuild;
|
|
const failing = vi
|
|
.spyOn(NoopGatewayService.prototype, 'leaveGuild')
|
|
.mockRejectedValueOnce(new Error('gateway timeout'))
|
|
.mockImplementation(function (this: NoopGatewayService, params) {
|
|
return leaveGuild.call(this, params);
|
|
});
|
|
await expect(processRecordedGuildDeletions(worker)).rejects.toThrow('gateway timeout');
|
|
expect(await storedGuild(guild.id)).not.toBeNull();
|
|
await processRecordedGuildDeletions(worker);
|
|
expect(await storedGuild(guild.id)).toBeNull();
|
|
await processRecordedGuildDeletions(worker);
|
|
expect(failing).toHaveBeenCalled();
|
|
});
|
|
|
|
test('member teardown runs with bounded concurrency', async () => {
|
|
const {owner, guild} = await guildWithMember();
|
|
await deleteAsOwner(owner, guild.id);
|
|
const guildId: GuildID = createGuildID(BigInt(guild.id));
|
|
const repository = getGuildRepository();
|
|
const realMembers = await repository.listMembers(guildId);
|
|
const extraMembers = Array.from({length: 120}, (_, index) => ({
|
|
...realMembers[0]!,
|
|
userId: createUserID(BigInt(realMembers[0]!.userId) + BigInt(index + 1)),
|
|
}));
|
|
vi.spyOn(Object.getPrototypeOf(repository), 'listMembers').mockResolvedValueOnce([...realMembers, ...extraMembers]);
|
|
let inFlight = 0;
|
|
let peak = 0;
|
|
vi.spyOn(NoopGatewayService.prototype, 'leaveGuild').mockImplementation(async () => {
|
|
inFlight += 1;
|
|
peak = Math.max(peak, inFlight);
|
|
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
inFlight -= 1;
|
|
});
|
|
await createTestGuildStack().guildService.data.operations.purgeGuild(guildId, 'source_deleted');
|
|
expect(peak).toBeGreaterThan(1);
|
|
expect(peak).toBeLessThanOrEqual(25);
|
|
expect(await storedGuild(guild.id)).toBeNull();
|
|
});
|
|
});
|