refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,25 @@
/*
* 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/>.
*/
export const Config = {
apiUrl: process.env.FLUXER_API_URL ?? 'http://localhost:18088/api/v1',
gatewayUrl: process.env.FLUXER_GATEWAY_URL ?? 'ws://localhost:18088/gateway',
webAppOrigin: process.env.FLUXER_WEBAPP_ORIGIN ?? 'http://localhost:5173',
testToken: process.env.FLUXER_TEST_TOKEN ?? '',
};

View File

@@ -0,0 +1,30 @@
/*
* 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 {Config} from '@fluxer/integration/Config';
import {beforeAll} from 'vitest';
beforeAll(async () => {
const response = await fetch(`${Config.apiUrl}/_health`, {
headers: {'X-Forwarded-For': '127.0.0.1'},
});
if (!response.ok) {
throw new Error('API health check failed - globalSetup should have started the server');
}
});

View File

@@ -0,0 +1,398 @@
/*
* 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 {Config} from '@fluxer/integration/Config';
import type {
GatewayDispatch,
GatewayHelloPayload,
GatewayPayload,
GatewayResumeState,
ReadyPayload,
VoiceServerUpdate,
VoiceStateUpdate,
} from '@fluxer/integration/gateway/GatewayTypes';
import {GatewayOpcode} from '@fluxer/integration/gateway/GatewayTypes';
import WebSocket from 'ws';
export type EventMatcher = (data: unknown) => boolean;
export class GatewayClient {
private ws: WebSocket | null = null;
private token: string;
private gatewayUrl: string;
private sessionId: string | null = null;
private sequence: number = 0;
private heartbeatInterval: NodeJS.Timeout | null = null;
private events: Array<GatewayDispatch> = [];
private eventListeners: Map<string, Array<(dispatch: GatewayDispatch) => void>> = new Map();
private readyPromise: Promise<ReadyPayload> | null = null;
private readyResolve: ((payload: ReadyPayload) => void) | null = null;
private resumedPromise: Promise<void> | null = null;
private resumedResolve: (() => void) | null = null;
private closed: boolean = false;
constructor(token: string) {
this.token = token;
this.gatewayUrl = Config.gatewayUrl;
}
async connect(): Promise<ReadyPayload> {
return new Promise((resolve, reject) => {
this.setupWebSocket(reject, undefined);
this.readyPromise!.then(resolve).catch(reject);
});
}
async resume(resumeState: GatewayResumeState): Promise<void> {
return new Promise((resolve, reject) => {
this.sessionId = resumeState.sessionId;
this.sequence = resumeState.sequence;
this.resumedPromise = new Promise((res) => {
this.resumedResolve = res;
});
this.setupWebSocket(reject, resumeState);
this.resumedPromise.then(resolve).catch(reject);
});
}
private setupWebSocket(reject: (error: Error) => void, resumeState?: GatewayResumeState): void {
const url = `${this.gatewayUrl}?v=1&encoding=json`;
const headers: Record<string, string> = {
'User-Agent': 'FluxerIntegrationTests/1.0',
};
if (Config.webAppOrigin) {
headers['Origin'] = Config.webAppOrigin;
}
if (Config.testToken) {
headers['X-Test-Token'] = Config.testToken;
}
this.ws = new WebSocket(url, {headers});
this.readyPromise = new Promise((res) => {
this.readyResolve = res;
});
this.ws.on('open', () => {});
this.ws.on('message', (data: WebSocket.Data) => {
this.handleMessage(data.toString(), resumeState);
});
this.ws.on('error', (error) => {
reject(error);
});
this.ws.on('close', (code, reason) => {
this.closed = true;
this.stopHeartbeat();
if (!this.readyResolve && !this.resumedResolve) {
reject(new Error(`WebSocket closed: ${code} ${reason.toString()}`));
}
});
}
private handleMessage(data: string, resume?: GatewayResumeState): void {
const payload: GatewayPayload = JSON.parse(data);
if (payload.s != null) {
this.sequence = payload.s;
}
switch (payload.op) {
case GatewayOpcode.HELLO:
this.handleHello(payload.d as GatewayHelloPayload, resume);
break;
case GatewayOpcode.DISPATCH:
this.handleDispatch(payload);
break;
case GatewayOpcode.HEARTBEAT_ACK:
break;
case GatewayOpcode.HEARTBEAT:
this.sendHeartbeat();
break;
case GatewayOpcode.INVALID_SESSION:
console.error('Invalid session received');
break;
case GatewayOpcode.RECONNECT:
console.log('Reconnect requested');
break;
}
}
private handleHello(hello: GatewayHelloPayload, resume?: GatewayResumeState): void {
this.startHeartbeat(hello.heartbeat_interval);
if (resume) {
this.sendResume(resume);
} else {
this.sendIdentify();
}
}
private handleDispatch(payload: GatewayPayload): void {
const dispatch: GatewayDispatch = {
type: payload.t!,
data: payload.d,
sequence: payload.s ?? 0,
};
this.events.push(dispatch);
const listeners = this.eventListeners.get(dispatch.type);
if (listeners) {
for (const listener of listeners) {
listener(dispatch);
}
}
if (dispatch.type === 'READY') {
const ready = dispatch.data as ReadyPayload;
this.sessionId = ready.session_id;
if (this.readyResolve) {
this.readyResolve(ready);
this.readyResolve = null;
}
} else if (dispatch.type === 'RESUMED') {
if (this.resumedResolve) {
this.resumedResolve();
this.resumedResolve = null;
}
}
}
private sendIdentify(): void {
this.send({
op: GatewayOpcode.IDENTIFY,
d: {
token: this.token,
properties: {
os: 'linux',
browser: 'FluxerIntegrationTests',
device: 'FluxerIntegrationTests',
},
compress: false,
large_threshold: 250,
},
});
}
private sendResume(resume: GatewayResumeState): void {
this.send({
op: GatewayOpcode.RESUME,
d: {
token: this.token,
session_id: resume.sessionId,
seq: resume.sequence,
},
});
}
private sendHeartbeat(): void {
this.send({
op: GatewayOpcode.HEARTBEAT,
d: this.sequence,
});
}
private startHeartbeat(interval: number): void {
this.heartbeatInterval = setInterval(() => {
if (!this.closed) {
this.sendHeartbeat();
}
}, interval);
}
private stopHeartbeat(): void {
if (this.heartbeatInterval) {
clearInterval(this.heartbeatInterval);
this.heartbeatInterval = null;
}
}
send(payload: Omit<GatewayPayload, 's' | 't'>): void {
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(payload));
}
}
sendVoiceStateUpdate(
guildId: string | null,
channelId: string | null,
connectionId?: string | null,
selfMute: boolean = false,
selfDeaf: boolean = false,
selfVideo: boolean = false,
selfStream: boolean = false,
): void {
this.send({
op: GatewayOpcode.VOICE_STATE_UPDATE,
d: {
guild_id: guildId,
channel_id: channelId,
connection_id: connectionId,
self_mute: selfMute,
self_deaf: selfDeaf,
self_video: selfVideo,
self_stream: selfStream,
},
});
}
activateGuild(guildId: string): void {
this.send({
op: GatewayOpcode.LAZY_REQUEST,
d: {
subscriptions: {
[guildId]: {
active: true,
},
},
},
});
}
waitForEvent(eventType: string, timeoutMs: number = 5000, matcher?: EventMatcher): Promise<GatewayDispatch> {
return new Promise((resolve, reject) => {
const existingIndex = this.events.findIndex((e) => e.type === eventType && (!matcher || matcher(e.data)));
if (existingIndex !== -1) {
const event = this.events.splice(existingIndex, 1)[0]!;
resolve(event);
return;
}
const timeout = setTimeout(() => {
cleanup();
reject(new Error(`Timeout waiting for event: ${eventType}`));
}, timeoutMs);
const listener = (dispatch: GatewayDispatch) => {
if (!matcher || matcher(dispatch.data)) {
cleanup();
const index = this.events.indexOf(dispatch);
if (index !== -1) {
this.events.splice(index, 1);
}
resolve(dispatch);
}
};
const cleanup = () => {
clearTimeout(timeout);
const listeners = this.eventListeners.get(eventType);
if (listeners) {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
};
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, []);
}
this.eventListeners.get(eventType)!.push(listener);
});
}
waitForVoiceServerUpdate(
timeoutMs: number = 5000,
matcher?: (vsu: VoiceServerUpdate) => boolean,
): Promise<VoiceServerUpdate> {
return this.waitForEvent('VOICE_SERVER_UPDATE', timeoutMs, matcher as EventMatcher).then(
(dispatch) => dispatch.data as VoiceServerUpdate,
);
}
waitForVoiceStateUpdate(
timeoutMs: number = 5000,
matcher?: (vs: VoiceStateUpdate) => boolean,
): Promise<VoiceStateUpdate> {
return this.waitForEvent('VOICE_STATE_UPDATE', timeoutMs, matcher as EventMatcher).then(
(dispatch) => dispatch.data as VoiceStateUpdate,
);
}
assertNoEvent(eventType: string, waitMs: number = 500): Promise<void> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
cleanup();
resolve();
}, waitMs);
const listener = (dispatch: GatewayDispatch) => {
cleanup();
reject(new Error(`Unexpected event received: ${eventType} - ${JSON.stringify(dispatch.data)}`));
};
const cleanup = () => {
clearTimeout(timeout);
const listeners = this.eventListeners.get(eventType);
if (listeners) {
const index = listeners.indexOf(listener);
if (index !== -1) {
listeners.splice(index, 1);
}
}
};
if (!this.eventListeners.has(eventType)) {
this.eventListeners.set(eventType, []);
}
this.eventListeners.get(eventType)!.push(listener);
});
}
getSessionId(): string | null {
return this.sessionId;
}
getSequence(): number {
return this.sequence;
}
getResumeState(): GatewayResumeState | null {
if (!this.sessionId) return null;
return {
sessionId: this.sessionId,
sequence: this.sequence,
};
}
close(): void {
this.closed = true;
this.stopHeartbeat();
if (this.ws) {
this.ws.close(1000, 'Test complete');
this.ws = null;
}
}
}
export async function createGatewayClient(token: string): Promise<GatewayClient> {
const client = new GatewayClient(token);
await client.connect();
return client;
}

View File

@@ -0,0 +1,86 @@
/*
* 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 {createGatewayClient, GatewayClient} from '@fluxer/integration/gateway/GatewayClient';
import type {TestAccount} from '@fluxer/integration/helpers/AccountHelper';
import {createTestAccount, ensureSessionStarted} from '@fluxer/integration/helpers/AccountHelper';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Gateway Connection', () => {
let account: TestAccount;
let gateway: GatewayClient | null = null;
beforeEach(async () => {
account = await createTestAccount();
await ensureSessionStarted(account.token);
});
afterEach(() => {
if (gateway) {
gateway.close();
gateway = null;
}
});
test('should connect to gateway and receive READY', async () => {
gateway = await createGatewayClient(account.token);
expect(gateway.getSessionId()).toBeTruthy();
expect(gateway.getSequence()).toBeGreaterThan(0);
});
test('should receive session_id in READY payload', async () => {
gateway = await createGatewayClient(account.token);
const sessionId = gateway.getSessionId();
expect(sessionId).toBeTruthy();
expect(typeof sessionId).toBe('string');
expect(sessionId!.length).toBeGreaterThan(0);
});
test('should be able to resume session', async () => {
gateway = await createGatewayClient(account.token);
const resumeState = gateway.getResumeState();
expect(resumeState).toBeTruthy();
gateway.close();
gateway = new GatewayClient(account.token);
await gateway.resume(resumeState!);
expect(gateway.getSessionId()).toBe(resumeState!.sessionId);
});
test('should handle multiple concurrent connections for different users', async () => {
const account2 = await createTestAccount();
await ensureSessionStarted(account2.token);
gateway = await createGatewayClient(account.token);
const gateway2 = await createGatewayClient(account2.token);
try {
expect(gateway.getSessionId()).toBeTruthy();
expect(gateway2.getSessionId()).toBeTruthy();
expect(gateway.getSessionId()).not.toBe(gateway2.getSessionId());
} finally {
gateway2.close();
}
});
});

View File

@@ -0,0 +1,192 @@
/*
* 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 {createGatewayClient, type GatewayClient} from '@fluxer/integration/gateway/GatewayClient';
import type {TestAccount} from '@fluxer/integration/helpers/AccountHelper';
import {createTestAccount, ensureSessionStarted} from '@fluxer/integration/helpers/AccountHelper';
import {apiClient} from '@fluxer/integration/helpers/ApiClient';
import type {GuildResponse} from '@fluxer/integration/helpers/GuildHelper';
import {createGuild, createTextChannel} from '@fluxer/integration/helpers/GuildHelper';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ChannelCreate {
id: string;
name: string;
type: number;
guild_id: string;
}
interface ChannelUpdate {
id: string;
name?: string;
topic?: string;
guild_id: string;
}
interface ChannelDelete {
id: string;
guild_id: string;
}
interface MessageCreate {
id: string;
channel_id: string;
content: string;
author: {
id: string;
};
}
describe('Gateway Guild Events', () => {
let account: TestAccount;
let guild: GuildResponse;
let gateway: GatewayClient | null = null;
beforeEach(async () => {
account = await createTestAccount();
await ensureSessionStarted(account.token);
guild = await createGuild(account.token, 'Events Test Guild');
});
afterEach(() => {
if (gateway) {
gateway.close();
gateway = null;
}
});
test('should receive CHANNEL_CREATE when creating a channel', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
const channelPromise = gateway.waitForEvent('CHANNEL_CREATE', 10000, (data) => {
const channel = data as ChannelCreate;
return channel.guild_id === guild.id && channel.name === 'new-channel';
});
await createTextChannel(account.token, guild.id, 'new-channel');
const event = await channelPromise;
const channel = event.data as ChannelCreate;
expect(channel.name).toBe('new-channel');
expect(channel.guild_id).toBe(guild.id);
expect(channel.type).toBe(0);
});
test('should receive CHANNEL_UPDATE when updating a channel', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
const channel = await createTextChannel(account.token, guild.id, 'update-test');
const updatePromise = gateway.waitForEvent('CHANNEL_UPDATE', 10000, (data) => {
const update = data as ChannelUpdate;
return update.id === channel.id;
});
await apiClient.patch(
`/channels/${channel.id}`,
{
name: 'updated-channel',
topic: 'New topic',
},
account.token,
);
const event = await updatePromise;
const updated = event.data as ChannelUpdate;
expect(updated.id).toBe(channel.id);
expect(updated.name).toBe('updated-channel');
});
test('should receive CHANNEL_DELETE when deleting a channel', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
const channel = await createTextChannel(account.token, guild.id, 'delete-test');
const deletePromise = gateway.waitForEvent('CHANNEL_DELETE', 10000, (data) => {
const deleted = data as ChannelDelete;
return deleted.id === channel.id;
});
await apiClient.delete(`/channels/${channel.id}`, account.token);
const event = await deletePromise;
const deleted = event.data as ChannelDelete;
expect(deleted.id).toBe(channel.id);
});
test('should receive MESSAGE_CREATE when sending a message', async () => {
gateway = await createGatewayClient(account.token);
const channel = await createTextChannel(account.token, guild.id, 'message-test');
const messagePromise = gateway.waitForEvent('MESSAGE_CREATE', 10000, (data) => {
const msg = data as MessageCreate;
return msg.channel_id === channel.id && msg.content === 'Hello from integration test';
});
await apiClient.post(
`/channels/${channel.id}/messages`,
{
content: 'Hello from integration test',
},
account.token,
);
const event = await messagePromise;
const message = event.data as MessageCreate;
expect(message.content).toBe('Hello from integration test');
expect(message.channel_id).toBe(channel.id);
expect(message.author.id).toBe(account.userId);
});
test('should receive GUILD_UPDATE when updating guild', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
const updatePromise = gateway.waitForEvent('GUILD_UPDATE', 10000, (data) => {
const update = data as {id: string; name: string};
return update.id === guild.id && update.name === 'Updated Guild Name';
});
await apiClient.patch(
`/guilds/${guild.id}`,
{
name: 'Updated Guild Name',
},
account.token,
);
const event = await updatePromise;
const updated = event.data as {id: string; name: string};
expect(updated.id).toBe(guild.id);
expect(updated.name).toBe('Updated Guild Name');
});
});

View File

@@ -0,0 +1,146 @@
/*
* 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 {ValueOf} from '@fluxer/constants/src/ValueOf';
export const GatewayOpcode = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
PRESENCE_UPDATE: 3,
VOICE_STATE_UPDATE: 4,
RESUME: 6,
RECONNECT: 7,
REQUEST_GUILD_MEMBERS: 8,
INVALID_SESSION: 9,
HELLO: 10,
HEARTBEAT_ACK: 11,
GATEWAY_ERROR: 12,
LAZY_REQUEST: 14,
} as const;
export type GatewayOpcodeType = ValueOf<typeof GatewayOpcode>;
export interface GatewayPayload {
op: GatewayOpcodeType;
d: unknown;
s?: number | null;
t?: string | null;
}
export interface GatewayHelloPayload {
heartbeat_interval: number;
}
export interface GatewayIdentifyPayload {
token: string;
properties: {
os: string;
browser: string;
device: string;
};
compress?: boolean;
large_threshold?: number;
presence?: {
status: string;
since?: number | null;
activities?: Array<unknown>;
afk?: boolean;
};
}
export interface GatewayResumePayload {
token: string;
session_id: string;
seq: number;
}
export interface GatewayVoiceStateUpdatePayload {
guild_id: string | null;
channel_id: string | null;
connection_id?: string | null;
self_mute: boolean;
self_deaf: boolean;
self_video?: boolean;
self_stream?: boolean;
}
export interface GatewayDispatch {
type: string;
data: unknown;
sequence: number;
}
export interface VoiceServerUpdate {
token: string;
guild_id?: string | null;
channel_id?: string | null;
endpoint: string;
connection_id: string;
}
export interface VoiceStateUpdate {
guild_id?: string | null;
channel_id?: string | null;
user_id: string;
member?: unknown;
session_id: string;
deaf: boolean;
mute: boolean;
self_deaf: boolean;
self_mute: boolean;
self_stream?: boolean;
self_video?: boolean;
suppress: boolean;
connection_id?: string | null;
}
export interface ReadyPayload {
v: number;
user: {
id: string;
username: string;
discriminator: string;
};
guilds: Array<{
id: string;
unavailable?: boolean;
}>;
session_id: string;
resume_gateway_url?: string;
private_channels: Array<unknown>;
relationships: Array<unknown>;
}
export interface MessageCreatePayload {
id: string;
channel_id: string;
guild_id?: string;
author: {
id: string;
username: string;
};
content: string;
timestamp: string;
}
export interface GatewayResumeState {
sessionId: string;
sequence: number;
}

View File

@@ -0,0 +1,216 @@
/*
* 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 {createGatewayClient, type GatewayClient} from '@fluxer/integration/gateway/GatewayClient';
import type {VoiceStateUpdate} from '@fluxer/integration/gateway/GatewayTypes';
import type {TestAccount} from '@fluxer/integration/helpers/AccountHelper';
import {createTestAccount, ensureSessionStarted} from '@fluxer/integration/helpers/AccountHelper';
import type {ChannelResponse, GuildResponse} from '@fluxer/integration/helpers/GuildHelper';
import {createGuild, createVoiceChannel} from '@fluxer/integration/helpers/GuildHelper';
import {confirmVoiceConnectionForTest} from '@fluxer/integration/helpers/VoiceHelper';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
describe('Gateway Voice State', () => {
let account: TestAccount;
let guild: GuildResponse;
let voiceChannel: ChannelResponse;
let gateway: GatewayClient | null = null;
beforeEach(async () => {
account = await createTestAccount();
await ensureSessionStarted(account.token);
guild = await createGuild(account.token, 'Voice Test Guild');
voiceChannel = await createVoiceChannel(account.token, guild.id, 'test-voice');
});
afterEach(() => {
if (gateway) {
gateway.close();
gateway = null;
}
});
test('should join guild voice channel and receive VOICE_SERVER_UPDATE', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, false, false, false, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
expect(serverUpdate.token).toBeTruthy();
expect(serverUpdate.endpoint).toBeTruthy();
expect(serverUpdate.guild_id).toBe(guild.id);
expect(serverUpdate.connection_id).toBeTruthy();
});
test('should receive VOICE_STATE_UPDATE when joining voice channel', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, false, false, false, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
await confirmVoiceConnectionForTest({
guildId: guild.id,
channelId: voiceChannel.id,
connectionId: serverUpdate.connection_id,
});
const stateUpdate = await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return (
vs.user_id === account.userId &&
vs.channel_id === voiceChannel.id &&
vs.connection_id === serverUpdate.connection_id
);
});
expect(stateUpdate.user_id).toBe(account.userId);
expect(stateUpdate.channel_id).toBe(voiceChannel.id);
expect(stateUpdate.guild_id).toBe(guild.id);
expect(stateUpdate.self_mute).toBe(false);
expect(stateUpdate.self_deaf).toBe(false);
});
test('should disconnect from voice channel when sending null channel_id', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, false, false, false, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
const connectionId = serverUpdate.connection_id;
await confirmVoiceConnectionForTest({
guildId: guild.id,
channelId: voiceChannel.id,
connectionId,
});
await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return vs.user_id === account.userId && vs.channel_id === voiceChannel.id && vs.connection_id === connectionId;
});
gateway.sendVoiceStateUpdate(guild.id, null, connectionId, false, false, false, false);
const disconnectUpdate = await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return vs.user_id === account.userId && vs.channel_id === null;
});
expect(disconnectUpdate.channel_id).toBeNull();
});
test('should update self_mute state', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, true, false, false, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
await confirmVoiceConnectionForTest({
guildId: guild.id,
channelId: voiceChannel.id,
connectionId: serverUpdate.connection_id,
});
const stateUpdate = await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return (
vs.user_id === account.userId &&
vs.channel_id === voiceChannel.id &&
vs.connection_id === serverUpdate.connection_id
);
});
expect(stateUpdate.self_mute).toBe(true);
expect(stateUpdate.self_deaf).toBe(false);
});
test('should update self_deaf state', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, false, true, false, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
await confirmVoiceConnectionForTest({
guildId: guild.id,
channelId: voiceChannel.id,
connectionId: serverUpdate.connection_id,
});
const stateUpdate = await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return (
vs.user_id === account.userId &&
vs.channel_id === voiceChannel.id &&
vs.connection_id === serverUpdate.connection_id
);
});
expect(stateUpdate.self_deaf).toBe(true);
});
test('should update self_video state', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, false, false, true, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
await confirmVoiceConnectionForTest({
guildId: guild.id,
channelId: voiceChannel.id,
connectionId: serverUpdate.connection_id,
});
const stateUpdate = await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return (
vs.user_id === account.userId &&
vs.channel_id === voiceChannel.id &&
vs.connection_id === serverUpdate.connection_id
);
});
expect(stateUpdate.self_video).toBe(true);
});
test('should include connection_id in voice updates', async () => {
gateway = await createGatewayClient(account.token);
gateway.activateGuild(guild.id);
await new Promise((r) => setTimeout(r, 100));
gateway.sendVoiceStateUpdate(guild.id, voiceChannel.id, null, false, false, false, false);
const serverUpdate = await gateway.waitForVoiceServerUpdate(10000);
expect(serverUpdate.connection_id).toBeTruthy();
expect(typeof serverUpdate.connection_id).toBe('string');
await confirmVoiceConnectionForTest({
guildId: guild.id,
channelId: voiceChannel.id,
connectionId: serverUpdate.connection_id,
});
const stateUpdate = await gateway.waitForVoiceStateUpdate(10000, (vs: VoiceStateUpdate) => {
return vs.user_id === account.userId && vs.connection_id === serverUpdate.connection_id;
});
expect(stateUpdate.connection_id).toBe(serverUpdate.connection_id);
});
});

View File

@@ -0,0 +1,156 @@
/*
* 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 {execSync} from 'node:child_process';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
import WebSocket from 'ws';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const composeFile = path.resolve(__dirname, '../docker/compose.yaml');
const PROJECT_NAME = 'fluxer-integration';
const API_URL = 'http://localhost:18088/api/v1';
const GATEWAY_URL = 'ws://localhost:18088/gateway';
const HEALTH_CHECK_URL = `${API_URL}/_health`;
const MAX_WAIT_SECONDS = 120;
const POLL_INTERVAL_MS = 2000;
const GATEWAY_OPCODE_HELLO = 10;
async function waitForApi(): Promise<void> {
const startTime = Date.now();
const maxWaitMs = MAX_WAIT_SECONDS * 1000;
while (Date.now() - startTime < maxWaitMs) {
try {
const response = await fetch(HEALTH_CHECK_URL, {
headers: {'X-Forwarded-For': '127.0.0.1'},
});
if (response.ok) {
console.log('API server is ready');
return;
}
} catch {}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(`API server did not become ready within ${MAX_WAIT_SECONDS} seconds`);
}
async function probeGateway(): Promise<void> {
return new Promise((resolve, reject) => {
const url = `${GATEWAY_URL}?v=1&encoding=json`;
const ws = new WebSocket(url, {
headers: {
'User-Agent': 'FluxerIntegrationTests/1.0',
Origin: 'http://localhost:5173',
},
});
const timeout = setTimeout(() => {
ws.close();
reject(new Error('Gateway WebSocket probe timed out'));
}, 10000);
ws.on('message', (data: WebSocket.Data) => {
try {
const payload = JSON.parse(data.toString());
if (payload.op === GATEWAY_OPCODE_HELLO) {
clearTimeout(timeout);
ws.close(1000, 'Probe complete');
resolve();
}
} catch {
clearTimeout(timeout);
ws.close();
reject(new Error('Failed to parse gateway message'));
}
});
ws.on('error', (error) => {
clearTimeout(timeout);
reject(error);
});
ws.on('close', (code, reason) => {
clearTimeout(timeout);
if (code !== 1000) {
reject(new Error(`Gateway WebSocket closed unexpectedly: ${code} ${reason.toString()}`));
}
});
});
}
async function waitForGateway(): Promise<void> {
const startTime = Date.now();
const maxWaitMs = MAX_WAIT_SECONDS * 1000;
while (Date.now() - startTime < maxWaitMs) {
try {
await probeGateway();
console.log('Gateway is ready');
return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
}
throw new Error(`Gateway did not become ready within ${MAX_WAIT_SECONDS} seconds`);
}
function startContainers(): void {
console.log('Starting integration test infrastructure...');
try {
execSync(`docker compose -p ${PROJECT_NAME} -f "${composeFile}" up -d --build --wait`, {
stdio: 'inherit',
});
} catch (error) {
console.error('Failed to start Docker containers:', error);
throw error;
}
}
function showContainerLogs(): void {
try {
console.log('\n=== Container logs ===');
execSync(`docker compose -p ${PROJECT_NAME} -f "${composeFile}" logs --tail=100 fluxer_server`, {
stdio: 'inherit',
});
} catch {}
}
export default async function globalSetup(): Promise<void> {
console.log('\n=== Integration Test Global Setup ===\n');
startContainers();
try {
await waitForApi();
await waitForGateway();
} catch (error) {
showContainerLogs();
throw error;
}
console.log('\n=== Infrastructure ready ===\n');
}

View File

@@ -0,0 +1,47 @@
/*
* 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 {execSync} from 'node:child_process';
import path from 'node:path';
import {fileURLToPath} from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const composeFile = path.resolve(__dirname, '../docker/compose.yaml');
const PROJECT_NAME = 'fluxer-integration';
function stopContainers(): void {
console.log('Stopping integration test infrastructure...');
try {
execSync(`docker compose -p ${PROJECT_NAME} -f "${composeFile}" down -v --remove-orphans`, {
stdio: 'inherit',
});
} catch (error) {
console.error('Failed to stop Docker containers:', error);
}
}
export default async function globalTeardown(): Promise<void> {
console.log('\n=== Integration Test Global Teardown ===\n');
stopContainers();
console.log('\n=== Infrastructure stopped ===\n');
}

View File

@@ -0,0 +1,78 @@
/*
* 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 {randomUUID} from 'node:crypto';
import {apiClient} from '@fluxer/integration/helpers/ApiClient';
export interface TestAccount {
userId: string;
token: string;
email: string;
username: string;
}
interface RegisterResponse {
token: string;
}
interface UsersMeResponse {
id: string;
username: string;
email: string;
}
let accountCounter = 0;
export async function createTestAccount(): Promise<TestAccount> {
const uniqueId = `${Date.now()}_${++accountCounter}_${randomUUID().slice(0, 8)}`;
const email = `test_${uniqueId}@integration.test`;
const username = `tu_${uniqueId.slice(0, 12)}`;
const password = `Str0ng$Pass_${randomUUID()}_X2y!Z3`;
const registerResponse = await apiClient.post<RegisterResponse>('/auth/register', {
email,
username,
global_name: 'Test User',
password,
date_of_birth: '2000-01-01',
consent: true,
});
if (!registerResponse.ok) {
throw new Error(`Failed to register test account: ${JSON.stringify(registerResponse.data)}`);
}
const token = registerResponse.data.token;
const meResponse = await apiClient.get<UsersMeResponse>('/users/@me', token);
if (!meResponse.ok) {
throw new Error(`Failed to get user info: ${JSON.stringify(meResponse.data)}`);
}
return {
userId: meResponse.data.id,
token,
email,
username: meResponse.data.username,
};
}
export async function ensureSessionStarted(token: string): Promise<void> {
await apiClient.post('/users/@me/sessions', {session_id: randomUUID()}, token);
}

View File

@@ -0,0 +1,85 @@
/*
* 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 {Config} from '@fluxer/integration/Config';
export interface ApiResponse<T> {
status: number;
data: T;
ok: boolean;
}
export class ApiClient {
private baseUrl: string;
private headers: Record<string, string>;
constructor() {
this.baseUrl = Config.apiUrl;
this.headers = {
'Content-Type': 'application/json',
'User-Agent': 'FluxerIntegrationTests/1.0',
'X-Forwarded-For': '127.0.0.1',
};
if (Config.testToken) {
this.headers['X-Test-Token'] = Config.testToken;
}
if (Config.webAppOrigin) {
this.headers['Origin'] = Config.webAppOrigin;
}
}
private async request<T>(method: string, path: string, body?: unknown, token?: string): Promise<ApiResponse<T>> {
const headers = {...this.headers};
if (token) {
headers['Authorization'] = token;
}
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
const data = response.headers.get('content-type')?.includes('application/json') ? await response.json() : null;
return {
status: response.status,
data: data as T,
ok: response.ok,
};
}
async get<T>(path: string, token?: string): Promise<ApiResponse<T>> {
return this.request<T>('GET', path, undefined, token);
}
async post<T>(path: string, body: unknown, token?: string): Promise<ApiResponse<T>> {
return this.request<T>('POST', path, body, token);
}
async patch<T>(path: string, body: unknown, token?: string): Promise<ApiResponse<T>> {
return this.request<T>('PATCH', path, body, token);
}
async delete<T>(path: string, token?: string): Promise<ApiResponse<T>> {
return this.request<T>('DELETE', path, undefined, token);
}
}
export const apiClient = new ApiClient();

View File

@@ -0,0 +1,78 @@
/*
* 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 {apiClient} from '@fluxer/integration/helpers/ApiClient';
export interface GuildResponse {
id: string;
name: string;
owner_id: string;
system_channel_id: string | null;
}
export interface ChannelResponse {
id: string;
name: string;
type: number;
guild_id?: string;
}
export async function createGuild(token: string, name: string): Promise<GuildResponse> {
const response = await apiClient.post<GuildResponse>('/guilds', {name}, token);
if (!response.ok) {
throw new Error(`Failed to create guild: ${JSON.stringify(response.data)}`);
}
return response.data;
}
export async function createVoiceChannel(token: string, guildId: string, name: string): Promise<ChannelResponse> {
const response = await apiClient.post<ChannelResponse>(
`/guilds/${guildId}/channels`,
{
name,
type: 2,
},
token,
);
if (!response.ok) {
throw new Error(`Failed to create voice channel: ${JSON.stringify(response.data)}`);
}
return response.data;
}
export async function createTextChannel(token: string, guildId: string, name: string): Promise<ChannelResponse> {
const response = await apiClient.post<ChannelResponse>(
`/guilds/${guildId}/channels`,
{
name,
type: 0,
},
token,
);
if (!response.ok) {
throw new Error(`Failed to create text channel: ${JSON.stringify(response.data)}`);
}
return response.data;
}

View File

@@ -0,0 +1,41 @@
/*
* 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 {apiClient} from '@fluxer/integration/helpers/ApiClient';
interface ConfirmVoiceConnectionResponse {
success: boolean;
error?: string;
}
export async function confirmVoiceConnectionForTest(params: {
guildId: string;
channelId: string;
connectionId: string;
}): Promise<void> {
const response = await apiClient.post<ConfirmVoiceConnectionResponse>('/test/voice/confirm-connection', {
guild_id: params.guildId,
channel_id: params.channelId,
connection_id: params.connectionId,
});
if (!response.ok || !response.data.success) {
throw new Error(`Failed to confirm voice connection: ${JSON.stringify(response.data)}`);
}
}