refactor progress
This commit is contained in:
452
packages/api/src/voice/tests/VoiceAvailabilityService.test.tsx
Normal file
452
packages/api/src/voice/tests/VoiceAvailabilityService.test.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
|
||||
import type {VoiceAccessContext} from '@fluxer/api/src/voice/VoiceAvailabilityService';
|
||||
import {VoiceAvailabilityService} from '@fluxer/api/src/voice/VoiceAvailabilityService';
|
||||
import type {VoiceRegionRecord, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
|
||||
import type {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
|
||||
import {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function createMockRegion(overrides: Partial<VoiceRegionRecord> = {}): VoiceRegionRecord {
|
||||
return {
|
||||
id: 'us-default',
|
||||
name: 'US Default',
|
||||
emoji: 'flag',
|
||||
latitude: 39.8283,
|
||||
longitude: -98.5795,
|
||||
isDefault: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockServer(overrides: Partial<VoiceServerRecord> = {}): VoiceServerRecord {
|
||||
return {
|
||||
regionId: 'us-default',
|
||||
serverId: 'server-1',
|
||||
endpoint: 'wss://voice.example.com',
|
||||
apiKey: 'test-key',
|
||||
apiSecret: 'test-secret',
|
||||
isActive: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTopology(
|
||||
regions: Array<VoiceRegionRecord>,
|
||||
serversByRegion: Map<string, Array<VoiceServerRecord>>,
|
||||
): VoiceTopology {
|
||||
return {
|
||||
getAllRegions: () => regions,
|
||||
getServersForRegion: (regionId: string) => serversByRegion.get(regionId) ?? [],
|
||||
getRegionMetadataList: () =>
|
||||
regions.map((r) => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
emoji: r.emoji,
|
||||
latitude: r.latitude,
|
||||
longitude: r.longitude,
|
||||
isDefault: r.isDefault,
|
||||
vipOnly: r.restrictions.vipOnly,
|
||||
requiredGuildFeatures: Array.from(r.restrictions.requiredGuildFeatures),
|
||||
})),
|
||||
} as VoiceTopology;
|
||||
}
|
||||
|
||||
describe('VoiceAvailabilityService', () => {
|
||||
let service: VoiceAvailabilityService;
|
||||
|
||||
describe('isRegionAccessible', () => {
|
||||
it('returns true for unrestricted region', () => {
|
||||
const region = createMockRegion();
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when user is not in allowedUserIds', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set([456n as UserID]),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when user is in allowedUserIds', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set([123n as UserID]),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for vipOnly region without VIP_VOICE feature', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: true,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
guildId: 456n as GuildID,
|
||||
guildFeatures: new Set(),
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for vipOnly region with VIP_VOICE feature', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: true,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
guildId: 456n as GuildID,
|
||||
guildFeatures: new Set([GuildFeatures.VIP_VOICE]),
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for vipOnly region without guildId', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: true,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for guild in allowedGuildIds', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set([456n as GuildID]),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
guildId: 456n as GuildID,
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for guild not in allowedGuildIds', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set([789n as GuildID]),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
guildId: 456n as GuildID,
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for guild with required feature', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(['PREMIUM']),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
guildId: 456n as GuildID,
|
||||
guildFeatures: new Set(['PREMIUM']),
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for guild without required feature', () => {
|
||||
const region = createMockRegion({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(['PREMIUM']),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
guildId: 456n as GuildID,
|
||||
guildFeatures: new Set(),
|
||||
};
|
||||
|
||||
expect(service.isRegionAccessible(region, context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isServerAccessible', () => {
|
||||
it('returns false for inactive server', () => {
|
||||
const region = createMockRegion();
|
||||
const server = createMockServer({isActive: false});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isServerAccessible(server, context)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for active unrestricted server', () => {
|
||||
const region = createMockRegion();
|
||||
const server = createMockServer();
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isServerAccessible(server, context)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when user is not in server allowedUserIds', () => {
|
||||
const region = createMockRegion();
|
||||
const server = createMockServer({
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set([456n as UserID]),
|
||||
},
|
||||
});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
expect(service.isServerAccessible(server, context)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableRegions', () => {
|
||||
it('returns regions with accessibility status', () => {
|
||||
const region1 = createMockRegion({id: 'us-default', isDefault: true});
|
||||
const region2 = createMockRegion({
|
||||
id: 'eu-vip',
|
||||
isDefault: false,
|
||||
restrictions: {
|
||||
vipOnly: true,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
});
|
||||
const server1 = createMockServer({regionId: 'us-default'});
|
||||
const topology = createMockTopology(
|
||||
[region1, region2],
|
||||
new Map([
|
||||
['us-default', [server1]],
|
||||
['eu-vip', []],
|
||||
]),
|
||||
);
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
const regions = service.getAvailableRegions(context);
|
||||
|
||||
expect(regions).toHaveLength(2);
|
||||
expect(regions[0].id).toBe('us-default');
|
||||
expect(regions[0].isAccessible).toBe(true);
|
||||
expect(regions[1].id).toBe('eu-vip');
|
||||
expect(regions[1].isAccessible).toBe(false);
|
||||
});
|
||||
|
||||
it('marks region as not accessible if no servers are available', () => {
|
||||
const region = createMockRegion();
|
||||
const topology = createMockTopology([region], new Map([['us-default', []]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
const regions = service.getAvailableRegions(context);
|
||||
|
||||
expect(regions).toHaveLength(1);
|
||||
expect(regions[0].isAccessible).toBe(false);
|
||||
expect(regions[0].serverCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('selectServer', () => {
|
||||
it('returns server from specified region', () => {
|
||||
const region = createMockRegion();
|
||||
const server = createMockServer();
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server]]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
const selected = service.selectServer('us-default', context);
|
||||
|
||||
expect(selected).not.toBeNull();
|
||||
expect(selected!.serverId).toBe('server-1');
|
||||
});
|
||||
|
||||
it('returns null for region with no servers', () => {
|
||||
const region = createMockRegion();
|
||||
const topology = createMockTopology([region], new Map([['us-default', []]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
const selected = service.selectServer('us-default', context);
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for non-existent region', () => {
|
||||
const topology = createMockTopology([], new Map());
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
const selected = service.selectServer('non-existent', context);
|
||||
|
||||
expect(selected).toBeNull();
|
||||
});
|
||||
|
||||
it('rotates between accessible servers', () => {
|
||||
const region = createMockRegion();
|
||||
const server1 = createMockServer({serverId: 'server-1'});
|
||||
const server2 = createMockServer({serverId: 'server-2'});
|
||||
const topology = createMockTopology([region], new Map([['us-default', [server1, server2]]]));
|
||||
service = new VoiceAvailabilityService(topology);
|
||||
|
||||
const context: VoiceAccessContext = {
|
||||
requestingUserId: 123n as UserID,
|
||||
};
|
||||
|
||||
const first = service.selectServer('us-default', context);
|
||||
const second = service.selectServer('us-default', context);
|
||||
const third = service.selectServer('us-default', context);
|
||||
|
||||
expect(first!.serverId).toBe('server-1');
|
||||
expect(second!.serverId).toBe('server-2');
|
||||
expect(third!.serverId).toBe('server-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
139
packages/api/src/voice/tests/VoiceCallEligibility.test.tsx
Normal file
139
packages/api/src/voice/tests/VoiceCallEligibility.test.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
getChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {updateUserSettings} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {IncomingCallFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Voice Call Eligibility', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setupUsersWithMutualGuild() {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
return {user1, user2, guild};
|
||||
}
|
||||
|
||||
describe('DM call eligibility', () => {
|
||||
it('returns ringable true for DM between friends', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean; silent?: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData.ringable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns ringable true for DM with mutual guild membership', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
await updateUserSettings(harness, user2.token, {
|
||||
incoming_call_flags: IncomingCallFlags.GUILD_MEMBERS,
|
||||
});
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData.ringable).toBe(true);
|
||||
});
|
||||
|
||||
it('returns ringable false for unclaimed account trying DM call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await unclaimAccount(harness, user1.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData.ringable).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Channel type validation', () => {
|
||||
it('returns 404 for non-existent channel', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.get('/channels/999999999999999999/call')
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns error for text channel call eligibility check', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user.token);
|
||||
|
||||
const guild = await createGuild(harness, user.token, 'Test Guild');
|
||||
const textChannel = await getChannel(harness, user.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.get(`/channels/${textChannel.id}/call`)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
157
packages/api/src/voice/tests/VoiceCallRinging.test.tsx
Normal file
157
packages/api/src/voice/tests/VoiceCallRinging.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
getChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Voice Call Ringing', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setupUsersWithMutualGuild() {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
return {user1, user2, guild};
|
||||
}
|
||||
|
||||
describe('Ring call', () => {
|
||||
it('rings call recipients in DM channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/ring`)
|
||||
.body({recipients: [user2.userId]})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rings call without specifying recipients', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/ring`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('rings call for friends DM', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/ring`)
|
||||
.body({recipients: [user2.userId]})
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent channel', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.post('/channels/999999999999999999/call/ring')
|
||||
.body({recipients: []})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns error for text channel ring', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user.token);
|
||||
|
||||
const guild = await createGuild(harness, user.token, 'Test Guild');
|
||||
const textChannel = await getChannel(harness, user.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.post(`/channels/${textChannel.id}/call/ring`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stop ringing', () => {
|
||||
it('returns 404 when stopping ring for non-existent call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
|
||||
.body({recipients: [user2.userId]})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns 404 when stopping ring without recipients for non-existent call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/stop-ringing`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
138
packages/api/src/voice/tests/VoiceCallUpdate.test.tsx
Normal file
138
packages/api/src/voice/tests/VoiceCallUpdate.test.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createGuild,
|
||||
getChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
|
||||
|
||||
describe('Voice Call Update', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
async function setupUsersWithMutualGuild() {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
return {user1, user2, guild};
|
||||
}
|
||||
|
||||
describe('Update call region', () => {
|
||||
it('returns 404 when updating region for non-existent call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.patch(`/channels/${dmChannel.id}/call`)
|
||||
.body({region: 'us-west'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns 404 when updating call without body', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.patch(`/channels/${dmChannel.id}/call`)
|
||||
.body({})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'NO_ACTIVE_CALL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns 404 for non-existent channel update', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.patch('/channels/999999999999999999/call')
|
||||
.body({region: 'us-west'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_CHANNEL')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('returns error for text channel update', async () => {
|
||||
const user = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, user.token);
|
||||
|
||||
const guild = await createGuild(harness, user.token, 'Test Guild');
|
||||
const textChannel = await getChannel(harness, user.token, guild.system_channel_id!);
|
||||
|
||||
await createBuilder(harness, user.token)
|
||||
.patch(`/channels/${textChannel.id}/call`)
|
||||
.body({region: 'us-west'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_CHANNEL_TYPE_FOR_CALL')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('End call', () => {
|
||||
it('ends call for DM channel', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/end`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('ends call for channel without active call', async () => {
|
||||
const {user1, user2} = await setupUsersWithMutualGuild();
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await createBuilder(harness, user1.token)
|
||||
.post(`/channels/${dmChannel.id}/call/end`)
|
||||
.body(null)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
264
packages/api/src/voice/tests/VoiceChannelPermissions.test.tsx
Normal file
264
packages/api/src/voice/tests/VoiceChannelPermissions.test.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
addMemberRole,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createGuild,
|
||||
createPermissionOverwrite,
|
||||
createRole,
|
||||
getChannel,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Voice Channel Permissions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('Voice channel creation', () => {
|
||||
it('owner can create voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
expect(voiceChannel.type).toBe(ChannelTypes.GUILD_VOICE);
|
||||
expect(voiceChannel.name).toBe('voice-test');
|
||||
});
|
||||
|
||||
it('member without permission cannot create voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'voice-test', type: ChannelTypes.GUILD_VOICE})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('member with MANAGE_CHANNELS permission can create voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const role = await createRole(harness, owner.token, guild.id, {
|
||||
name: 'Channel Manager',
|
||||
permissions: Permissions.MANAGE_CHANNELS.toString(),
|
||||
});
|
||||
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, role.id);
|
||||
|
||||
const voiceChannel = await createBuilder<ChannelResponse>(harness, member.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'voice-test', type: ChannelTypes.GUILD_VOICE})
|
||||
.execute();
|
||||
|
||||
expect(voiceChannel.type).toBe(ChannelTypes.GUILD_VOICE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voice channel access', () => {
|
||||
it('member can view voice channel by default', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
const channel = await getChannel(harness, member.token, voiceChannel.id);
|
||||
|
||||
expect(channel.id).toBe(voiceChannel.id);
|
||||
expect(channel.type).toBe(ChannelTypes.GUILD_VOICE);
|
||||
});
|
||||
|
||||
it('member cannot view voice channel when VIEW_CHANNEL is denied', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createPermissionOverwrite(harness, owner.token, voiceChannel.id, member.userId, {
|
||||
type: 1,
|
||||
allow: '0',
|
||||
deny: Permissions.VIEW_CHANNEL.toString(),
|
||||
});
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.get(`/channels/${voiceChannel.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voice channel modification', () => {
|
||||
it('owner can update voice channel name', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({name: 'updated-voice'})
|
||||
.execute();
|
||||
|
||||
expect(updated.name).toBe('updated-voice');
|
||||
});
|
||||
|
||||
it('owner can update voice channel bitrate', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({bitrate: 64000})
|
||||
.execute();
|
||||
|
||||
expect(updated.bitrate).toBe(64000);
|
||||
});
|
||||
|
||||
it('owner can update voice channel user_limit', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({user_limit: 10})
|
||||
.execute();
|
||||
|
||||
expect(updated.user_limit).toBe(10);
|
||||
});
|
||||
|
||||
it('member without permission cannot update voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({name: 'updated-voice'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Voice channel deletion', () => {
|
||||
it('owner can delete voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.delete(`/channels/${voiceChannel.id}`)
|
||||
.expect(HTTP_STATUS.NO_CONTENT)
|
||||
.execute();
|
||||
|
||||
await createBuilder(harness, owner.token)
|
||||
.get(`/channels/${voiceChannel.id}`)
|
||||
.expect(HTTP_STATUS.NOT_FOUND)
|
||||
.execute();
|
||||
});
|
||||
|
||||
it('member without permission cannot delete voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
await createBuilder(harness, member.token)
|
||||
.delete(`/channels/${voiceChannel.id}`)
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_PERMISSIONS')
|
||||
.execute();
|
||||
});
|
||||
});
|
||||
});
|
||||
126
packages/api/src/voice/tests/VoiceChannelRtcRegion.test.tsx
Normal file
126
packages/api/src/voice/tests/VoiceChannelRtcRegion.test.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {createChannel, createGuild, getChannel} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Voice Channel RTC Region', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('voice channel has null rtc_region by default', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
expect(voiceChannel.rtc_region).toBeNull();
|
||||
});
|
||||
|
||||
it('owner can set rtc_region on voice channel', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({rtc_region: 'us-west'})
|
||||
.execute();
|
||||
|
||||
expect(updated.rtc_region).toBe('us-west');
|
||||
});
|
||||
|
||||
it('owner can clear rtc_region to null', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({rtc_region: 'us-west'})
|
||||
.execute();
|
||||
|
||||
const updated = await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({rtc_region: null})
|
||||
.execute();
|
||||
|
||||
expect(updated.rtc_region).toBeNull();
|
||||
});
|
||||
|
||||
it('rtc_region persists after fetch', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
await createBuilder<ChannelResponse>(harness, owner.token)
|
||||
.patch(`/channels/${voiceChannel.id}`)
|
||||
.body({rtc_region: 'eu-west'})
|
||||
.execute();
|
||||
|
||||
const fetched = await getChannel(harness, owner.token, voiceChannel.id);
|
||||
|
||||
expect(fetched.rtc_region).toBe('eu-west');
|
||||
});
|
||||
|
||||
it('voice channel bitrate is set during creation', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
expect(voiceChannel.bitrate).toBeDefined();
|
||||
expect(typeof voiceChannel.bitrate).toBe('number');
|
||||
});
|
||||
|
||||
it('voice channel user_limit defaults to 0', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
|
||||
expect(voiceChannel.user_limit).toBe(0);
|
||||
});
|
||||
});
|
||||
107
packages/api/src/voice/tests/VoiceRegionSelection.test.tsx
Normal file
107
packages/api/src/voice/tests/VoiceRegionSelection.test.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {VoiceRegionAvailability} from '@fluxer/api/src/voice/VoiceModel';
|
||||
import {resolveVoiceRegionPreference, selectVoiceRegionId} from '@fluxer/api/src/voice/VoiceRegionSelection';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
function createRegionAvailability({
|
||||
id,
|
||||
latitude,
|
||||
longitude,
|
||||
isDefault,
|
||||
}: {
|
||||
id: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
isDefault: boolean;
|
||||
}): VoiceRegionAvailability {
|
||||
return {
|
||||
id,
|
||||
name: `Region ${id.toUpperCase()}`,
|
||||
emoji: id.toUpperCase(),
|
||||
latitude,
|
||||
longitude,
|
||||
isDefault,
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: [],
|
||||
isAccessible: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
serverCount: 1,
|
||||
activeServerCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe('VoiceRegionSelection', () => {
|
||||
it('selects the closest region when coordinates are provided', () => {
|
||||
const regions = [
|
||||
createRegionAvailability({id: 'a', latitude: 0, longitude: 0, isDefault: true}),
|
||||
createRegionAvailability({id: 'b', latitude: 50, longitude: 50, isDefault: false}),
|
||||
];
|
||||
|
||||
const preference = resolveVoiceRegionPreference({
|
||||
preferredRegionId: null,
|
||||
accessibleRegions: regions,
|
||||
availableRegions: regions,
|
||||
defaultRegionId: null,
|
||||
});
|
||||
|
||||
const selected = selectVoiceRegionId({
|
||||
preferredRegionId: preference.regionId,
|
||||
mode: preference.mode,
|
||||
accessibleRegions: regions,
|
||||
availableRegions: regions,
|
||||
latitude: '49',
|
||||
longitude: '49',
|
||||
});
|
||||
|
||||
expect(selected).toBe('b');
|
||||
});
|
||||
|
||||
it('keeps explicit regions even when coordinates would choose another', () => {
|
||||
const regions = [
|
||||
createRegionAvailability({id: 'a', latitude: 0, longitude: 0, isDefault: false}),
|
||||
createRegionAvailability({id: 'b', latitude: 50, longitude: 50, isDefault: false}),
|
||||
];
|
||||
|
||||
const preference = resolveVoiceRegionPreference({
|
||||
preferredRegionId: 'a',
|
||||
accessibleRegions: regions,
|
||||
availableRegions: regions,
|
||||
defaultRegionId: null,
|
||||
});
|
||||
|
||||
const selected = selectVoiceRegionId({
|
||||
preferredRegionId: preference.regionId,
|
||||
mode: preference.mode,
|
||||
accessibleRegions: regions,
|
||||
availableRegions: regions,
|
||||
latitude: '49',
|
||||
longitude: '49',
|
||||
});
|
||||
|
||||
expect(preference.mode).toBe('explicit');
|
||||
expect(selected).toBe('a');
|
||||
});
|
||||
});
|
||||
139
packages/api/src/voice/tests/VoiceTestUtils.tsx
Normal file
139
packages/api/src/voice/tests/VoiceTestUtils.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount as authCreateTestAccount, type TestAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannel,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
|
||||
export async function createTestAccount(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
return authCreateTestAccount(harness);
|
||||
}
|
||||
|
||||
export async function createTestAccountUnclaimed(harness: ApiTestHarness): Promise<TestAccount> {
|
||||
const account = await authCreateTestAccount(harness);
|
||||
await createBuilder(harness, '').post(`/test/users/${account.userId}/unclaim`).body(null).execute();
|
||||
return account;
|
||||
}
|
||||
|
||||
export async function createGuildWithVoiceChannel(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
guildName: string,
|
||||
): Promise<{guild: GuildResponse; voiceChannel: ChannelResponse}> {
|
||||
const guild = await createGuild(harness, token, guildName);
|
||||
const voiceChannel = await createChannel(harness, token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
return {guild, voiceChannel};
|
||||
}
|
||||
|
||||
export interface VoiceTestSetup {
|
||||
owner: TestAccount;
|
||||
member: TestAccount;
|
||||
guild: GuildResponse;
|
||||
voiceChannel: ChannelResponse;
|
||||
textChannel: ChannelResponse;
|
||||
}
|
||||
|
||||
export async function setupVoiceTestGuild(harness: ApiTestHarness): Promise<VoiceTestSetup> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, owner.token);
|
||||
await ensureSessionStarted(harness, member.token);
|
||||
|
||||
const guild = await createGuild(harness, owner.token, 'Voice Test Guild');
|
||||
const voiceChannel = await createChannel(harness, owner.token, guild.id, 'voice-test', ChannelTypes.GUILD_VOICE);
|
||||
const textChannel = await createChannel(harness, owner.token, guild.id, 'text-test', ChannelTypes.GUILD_TEXT);
|
||||
|
||||
const invite = await createChannelInvite(harness, owner.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
|
||||
return {owner, member, guild, voiceChannel, textChannel};
|
||||
}
|
||||
|
||||
export interface DmCallTestSetup {
|
||||
user1: TestAccount;
|
||||
user2: TestAccount;
|
||||
dmChannel: {id: string; type: number};
|
||||
}
|
||||
|
||||
export async function setupDmCallTest(harness: ApiTestHarness): Promise<DmCallTestSetup> {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
return {user1, user2, dmChannel};
|
||||
}
|
||||
|
||||
export async function setupDmCallTestWithFriendship(harness: ApiTestHarness): Promise<DmCallTestSetup> {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
return {user1, user2, dmChannel};
|
||||
}
|
||||
|
||||
export async function getCallEligibility(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
): Promise<{ringable: boolean; silent?: boolean}> {
|
||||
return createBuilder<{ringable: boolean; silent?: boolean}>(harness, token)
|
||||
.get(`/channels/${channelId}/call`)
|
||||
.execute();
|
||||
}
|
||||
|
||||
export async function ringCall(
|
||||
harness: ApiTestHarness,
|
||||
token: string,
|
||||
channelId: string,
|
||||
recipients?: Array<string>,
|
||||
): Promise<void> {
|
||||
const body = recipients ? {recipients} : {};
|
||||
await createBuilder(harness, token).post(`/channels/${channelId}/call/ring`).body(body).expect(204).execute();
|
||||
}
|
||||
|
||||
export async function endCall(harness: ApiTestHarness, token: string, channelId: string): Promise<void> {
|
||||
await createBuilder(harness, token).post(`/channels/${channelId}/call/end`).body(null).expect(204).execute();
|
||||
}
|
||||
313
packages/api/src/voice/tests/VoiceTopology.test.tsx
Normal file
313
packages/api/src/voice/tests/VoiceTopology.test.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type {IVoiceRepository} from '@fluxer/api/src/voice/IVoiceRepository';
|
||||
import type {VoiceRegionWithServers} from '@fluxer/api/src/voice/VoiceModel';
|
||||
import {VoiceTopology} from '@fluxer/api/src/voice/VoiceTopology';
|
||||
import {beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function createMockVoiceRepository(regions: Array<VoiceRegionWithServers>): IVoiceRepository {
|
||||
return {
|
||||
listRegionsWithServers: vi.fn().mockResolvedValue(regions),
|
||||
listRegions: vi.fn().mockResolvedValue(regions),
|
||||
getRegion: vi.fn().mockResolvedValue(null),
|
||||
getRegionWithServers: vi.fn().mockResolvedValue(null),
|
||||
upsertRegion: vi.fn().mockResolvedValue(undefined),
|
||||
deleteRegion: vi.fn().mockResolvedValue(undefined),
|
||||
createRegion: vi.fn().mockResolvedValue(null),
|
||||
listServersForRegion: vi.fn().mockResolvedValue([]),
|
||||
listServers: vi.fn().mockResolvedValue([]),
|
||||
getServer: vi.fn().mockResolvedValue(null),
|
||||
createServer: vi.fn().mockResolvedValue(null),
|
||||
upsertServer: vi.fn().mockResolvedValue(undefined),
|
||||
deleteServer: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
describe('VoiceTopology', () => {
|
||||
let topology: VoiceTopology;
|
||||
|
||||
const mockRegions: Array<VoiceRegionWithServers> = [
|
||||
{
|
||||
id: 'us-default',
|
||||
name: 'US Default',
|
||||
emoji: 'flag-us',
|
||||
latitude: 39.8283,
|
||||
longitude: -98.5795,
|
||||
isDefault: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
servers: [
|
||||
{
|
||||
regionId: 'us-default',
|
||||
serverId: 'us-server-1',
|
||||
endpoint: 'wss://us1.voice.example.com',
|
||||
|
||||
apiKey: 'key1',
|
||||
apiSecret: 'secret1',
|
||||
isActive: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
regionId: 'us-default',
|
||||
serverId: 'us-server-2',
|
||||
endpoint: 'wss://us2.voice.example.com',
|
||||
|
||||
apiKey: 'key2',
|
||||
apiSecret: 'secret2',
|
||||
isActive: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'eu-default',
|
||||
name: 'EU Default',
|
||||
emoji: 'flag-eu',
|
||||
latitude: 50.0755,
|
||||
longitude: 14.4378,
|
||||
isDefault: false,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
servers: [
|
||||
{
|
||||
regionId: 'eu-default',
|
||||
serverId: 'eu-server-1',
|
||||
endpoint: 'wss://eu1.voice.example.com',
|
||||
|
||||
apiKey: 'key3',
|
||||
apiSecret: 'secret3',
|
||||
isActive: true,
|
||||
restrictions: {
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: new Set(),
|
||||
allowedGuildIds: new Set(),
|
||||
allowedUserIds: new Set(),
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
const repository = createMockVoiceRepository(mockRegions);
|
||||
topology = new VoiceTopology(repository, null);
|
||||
});
|
||||
|
||||
describe('before initialization', () => {
|
||||
it('returns null for default region before initialization', () => {
|
||||
expect(topology.getDefaultRegion()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for default region id before initialization', () => {
|
||||
expect(topology.getDefaultRegionId()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns empty array for all regions before initialization', () => {
|
||||
expect(topology.getAllRegions()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('after initialization', () => {
|
||||
beforeEach(async () => {
|
||||
await topology.initialize();
|
||||
});
|
||||
|
||||
it('returns default region after initialization', () => {
|
||||
const defaultRegion = topology.getDefaultRegion();
|
||||
|
||||
expect(defaultRegion).not.toBeNull();
|
||||
expect(defaultRegion!.id).toBe('us-default');
|
||||
expect(defaultRegion!.isDefault).toBe(true);
|
||||
});
|
||||
|
||||
it('returns default region id after initialization', () => {
|
||||
expect(topology.getDefaultRegionId()).toBe('us-default');
|
||||
});
|
||||
|
||||
it('returns all regions after initialization', () => {
|
||||
const regions = topology.getAllRegions();
|
||||
|
||||
expect(regions).toHaveLength(2);
|
||||
expect(regions[0].id).toBe('us-default');
|
||||
expect(regions[1].id).toBe('eu-default');
|
||||
});
|
||||
|
||||
it('returns specific region by id', () => {
|
||||
const region = topology.getRegion('eu-default');
|
||||
|
||||
expect(region).not.toBeNull();
|
||||
expect(region!.id).toBe('eu-default');
|
||||
expect(region!.name).toBe('EU Default');
|
||||
});
|
||||
|
||||
it('returns null for non-existent region', () => {
|
||||
const region = topology.getRegion('non-existent');
|
||||
|
||||
expect(region).toBeNull();
|
||||
});
|
||||
|
||||
it('returns servers for a region', () => {
|
||||
const servers = topology.getServersForRegion('us-default');
|
||||
|
||||
expect(servers).toHaveLength(2);
|
||||
expect(servers[0].serverId).toBe('us-server-1');
|
||||
expect(servers[1].serverId).toBe('us-server-2');
|
||||
});
|
||||
|
||||
it('returns empty array for region with no servers', () => {
|
||||
const servers = topology.getServersForRegion('non-existent');
|
||||
|
||||
expect(servers).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns specific server by region and server id', () => {
|
||||
const server = topology.getServer('us-default', 'us-server-2');
|
||||
|
||||
expect(server).not.toBeNull();
|
||||
expect(server!.serverId).toBe('us-server-2');
|
||||
expect(server!.endpoint).toBe('wss://us2.voice.example.com');
|
||||
});
|
||||
|
||||
it('returns null for non-existent server', () => {
|
||||
const server = topology.getServer('us-default', 'non-existent');
|
||||
|
||||
expect(server).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for server in non-existent region', () => {
|
||||
const server = topology.getServer('non-existent', 'us-server-1');
|
||||
|
||||
expect(server).toBeNull();
|
||||
});
|
||||
|
||||
it('returns region metadata list', () => {
|
||||
const metadata = topology.getRegionMetadataList();
|
||||
|
||||
expect(metadata).toHaveLength(2);
|
||||
expect(metadata[0]).toEqual({
|
||||
id: 'us-default',
|
||||
name: 'US Default',
|
||||
emoji: 'flag-us',
|
||||
latitude: 39.8283,
|
||||
longitude: -98.5795,
|
||||
isDefault: true,
|
||||
vipOnly: false,
|
||||
requiredGuildFeatures: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNextServer', () => {
|
||||
beforeEach(async () => {
|
||||
await topology.initialize();
|
||||
});
|
||||
|
||||
it('rotates through servers in order', () => {
|
||||
const first = topology.getNextServer('us-default');
|
||||
const second = topology.getNextServer('us-default');
|
||||
const third = topology.getNextServer('us-default');
|
||||
|
||||
expect(first!.serverId).toBe('us-server-1');
|
||||
expect(second!.serverId).toBe('us-server-2');
|
||||
expect(third!.serverId).toBe('us-server-1');
|
||||
});
|
||||
|
||||
it('returns null for region with no servers', () => {
|
||||
const server = topology.getNextServer('non-existent');
|
||||
|
||||
expect(server).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subscriber management', () => {
|
||||
it('registers and unregisters subscribers', () => {
|
||||
const subscriber = vi.fn();
|
||||
|
||||
topology.registerSubscriber(subscriber);
|
||||
topology.unregisterSubscriber(subscriber);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shutdown', () => {
|
||||
it('shuts down without error', () => {
|
||||
expect(() => topology.shutdown()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('empty regions', () => {
|
||||
it('handles empty region list', async () => {
|
||||
const repository = createMockVoiceRepository([]);
|
||||
topology = new VoiceTopology(repository, null);
|
||||
|
||||
await topology.initialize();
|
||||
|
||||
expect(topology.getAllRegions()).toHaveLength(0);
|
||||
expect(topology.getDefaultRegion()).toBeNull();
|
||||
expect(topology.getDefaultRegionId()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('no default region', () => {
|
||||
it('uses first region as fallback when no default is set', async () => {
|
||||
const regionsWithoutDefault: Array<VoiceRegionWithServers> = [
|
||||
{
|
||||
...mockRegions[0],
|
||||
isDefault: false,
|
||||
},
|
||||
mockRegions[1],
|
||||
];
|
||||
|
||||
const repository = createMockVoiceRepository(regionsWithoutDefault);
|
||||
topology = new VoiceTopology(repository, null);
|
||||
|
||||
await topology.initialize();
|
||||
|
||||
expect(topology.getDefaultRegionId()).toBe('us-default');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {createTestAccount, unclaimAccount} from '@fluxer/api/src/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
acceptInvite,
|
||||
createChannelInvite,
|
||||
createDmChannel,
|
||||
createFriendship,
|
||||
createGuild,
|
||||
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
|
||||
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
|
||||
import {createBuilder} from '@fluxer/api/src/test/TestRequestBuilder';
|
||||
import {updateUserSettings} from '@fluxer/api/src/user/tests/UserTestUtils';
|
||||
import {IncomingCallFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
describe('Voice Unclaimed Account Restrictions', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
describe('DM call restrictions', () => {
|
||||
it('unclaimed account cannot initiate DM call', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await unclaimAccount(harness, user1.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData.ringable).toBe(false);
|
||||
});
|
||||
|
||||
it('claimed account can initiate DM call with unclaimed recipient', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
const guild = await createGuild(harness, user1.token, 'Mutual Guild');
|
||||
const invite = await createChannelInvite(harness, user1.token, guild.system_channel_id!);
|
||||
await acceptInvite(harness, user2.token, invite.code);
|
||||
|
||||
await updateUserSettings(harness, user2.token, {
|
||||
incoming_call_flags: IncomingCallFlags.GUILD_MEMBERS,
|
||||
});
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await unclaimAccount(harness, user2.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData.ringable).toBe(true);
|
||||
});
|
||||
|
||||
it('unclaimed account cannot call friend', async () => {
|
||||
const user1 = await createTestAccount(harness);
|
||||
const user2 = await createTestAccount(harness);
|
||||
|
||||
await ensureSessionStarted(harness, user1.token);
|
||||
await ensureSessionStarted(harness, user2.token);
|
||||
|
||||
await createFriendship(harness, user1, user2);
|
||||
|
||||
const dmChannel = await createDmChannel(harness, user1.token, user2.userId);
|
||||
|
||||
await unclaimAccount(harness, user1.userId);
|
||||
|
||||
const callData = await createBuilder<{ringable: boolean}>(harness, user1.token)
|
||||
.get(`/channels/${dmChannel.id}/call`)
|
||||
.execute();
|
||||
|
||||
expect(callData.ringable).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user