refactor progress
This commit is contained in:
59
fluxer_integration/docker/compose.yaml
Normal file
59
fluxer_integration/docker/compose.yaml
Normal file
@@ -0,0 +1,59 @@
|
||||
services:
|
||||
fluxer_server:
|
||||
build:
|
||||
context: /root/fluxer
|
||||
dockerfile: fluxer_server/Dockerfile.dev
|
||||
working_dir: /workspace/fluxer_server
|
||||
command: >
|
||||
sh -lc "corepack enable pnpm && pnpm install --frozen-lockfile && pnpm start"
|
||||
ports:
|
||||
- '18088:8088'
|
||||
environment:
|
||||
- FLUXER_CONFIG=/workspace/config/config.json
|
||||
- NODE_ENV=development
|
||||
- FLUXER_CONFIG__ENV=development
|
||||
volumes:
|
||||
- /root/fluxer/packages:/workspace/packages
|
||||
- /root/fluxer/pnpm-workspace.yaml:/workspace/pnpm-workspace.yaml:ro
|
||||
- /root/fluxer/pnpm-lock.yaml:/workspace/pnpm-lock.yaml:ro
|
||||
- /root/fluxer/package.json:/workspace/package.json:ro
|
||||
- /root/fluxer/tsconfigs:/workspace/tsconfigs:ro
|
||||
- /root/fluxer/patches:/workspace/patches:ro
|
||||
- corepack_cache:/root/.cache/node/corepack
|
||||
- /root/fluxer/fluxer_integration/docker/config.json:/workspace/config/config.json:ro
|
||||
- integration_node_modules:/workspace/node_modules
|
||||
- /root/fluxer/fluxer_server:/workspace/fluxer_server
|
||||
- /root/fluxer/fluxer_media_proxy/data/model.onnx:/workspace/fluxer_server/data/model.onnx:ro
|
||||
networks:
|
||||
- integration
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
livekit:
|
||||
condition: service_healthy
|
||||
|
||||
livekit:
|
||||
image: livekit/livekit-server:latest
|
||||
command: --config /etc/livekit.yaml --dev
|
||||
volumes:
|
||||
- /root/fluxer/fluxer_integration/docker/livekit.yaml:/etc/livekit.yaml:ro
|
||||
ports:
|
||||
- '17880:7880'
|
||||
- '17882:7882/udp'
|
||||
- '17999:7999/udp'
|
||||
networks:
|
||||
- integration
|
||||
restart: on-failure
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'wget --no-verbose --tries=1 --spider http://localhost:7880 || exit 1']
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 5s
|
||||
|
||||
networks:
|
||||
integration:
|
||||
name: fluxer-integration-isolated
|
||||
|
||||
volumes:
|
||||
corepack_cache:
|
||||
integration_node_modules:
|
||||
74
fluxer_integration/docker/config.json
Normal file
74
fluxer_integration/docker/config.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"$schema": "../../packages/config/src/ConfigSchema.json",
|
||||
"env": "development",
|
||||
"domain": {
|
||||
"base_domain": "localhost"
|
||||
},
|
||||
"database": {
|
||||
"backend": "sqlite",
|
||||
"sqlite_path": "./data/integration.db"
|
||||
},
|
||||
"s3": {
|
||||
"access_key_id": "integration-test-access-key",
|
||||
"secret_access_key": "integration-test-secret-key"
|
||||
},
|
||||
"services": {
|
||||
"media_proxy": {
|
||||
"secret_key": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||
},
|
||||
"admin": {
|
||||
"secret_key_base": "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
"oauth_client_secret": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"
|
||||
},
|
||||
"gateway": {
|
||||
"port": 8080,
|
||||
"api_host": "http://127.0.0.1:8088/api",
|
||||
"admin_reload_secret": "deadbeef0123456789abcdef0123456789abcdef0123456789abcdef01234567",
|
||||
"media_proxy_endpoint": "http://localhost:8088/media"
|
||||
},
|
||||
"server": {
|
||||
"port": 8088
|
||||
}
|
||||
},
|
||||
"gateway": {
|
||||
"rpc_endpoint": "http://127.0.0.1:8081",
|
||||
"rpc_secret": "cafebabe0123456789abcdef0123456789abcdef0123456789abcdef01234567"
|
||||
},
|
||||
"auth": {
|
||||
"sudo_mode_secret": "c0ffee000123456789abcdef0123456789abcdef0123456789abcdef01234567",
|
||||
"connection_initiation_secret": "f00dbabe0123456789abcdef0123456789abcdef0123456789abcdef01234567",
|
||||
"vapid": {
|
||||
"public_key": "face0000123456789abcdef0123456789abcdef0123456789abcdef0123456789",
|
||||
"private_key": "beef0000123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
},
|
||||
"bluesky": {
|
||||
"enabled": false
|
||||
}
|
||||
},
|
||||
"dev": {
|
||||
"disable_rate_limits": true,
|
||||
"relax_registration_rate_limits": true,
|
||||
"test_mode_enabled": true
|
||||
},
|
||||
"integrations": {
|
||||
"voice": {
|
||||
"enabled": true,
|
||||
"api_key": "devkey",
|
||||
"api_secret": "devsecret",
|
||||
"url": "ws://livekit:7880",
|
||||
"webhook_url": "",
|
||||
"default_region": {
|
||||
"id": "default",
|
||||
"name": "Default",
|
||||
"emoji": "🌐",
|
||||
"latitude": 0.0,
|
||||
"longitude": 0.0
|
||||
}
|
||||
},
|
||||
"cloudflare": {
|
||||
"purge_enabled": false,
|
||||
"zone_id": "",
|
||||
"api_token": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
19
fluxer_integration/docker/livekit.yaml
Normal file
19
fluxer_integration/docker/livekit.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
port: 7880
|
||||
|
||||
keys:
|
||||
'devkey': 'devsecret'
|
||||
|
||||
rtc:
|
||||
tcp_port: 7881
|
||||
|
||||
webhook:
|
||||
api_key: 'devkey'
|
||||
urls:
|
||||
- ''
|
||||
|
||||
room:
|
||||
auto_create: true
|
||||
max_participants: 100
|
||||
empty_timeout: 300
|
||||
|
||||
development: true
|
||||
25
fluxer_integration/package.json
Normal file
25
fluxer_integration/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@fluxer/integration",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"integration": "./scripts/run_integration.sh",
|
||||
"server:logs": "./scripts/server_logs.sh",
|
||||
"server:start": "./scripts/server_start.sh",
|
||||
"server:stop": "./scripts/server_stop.sh",
|
||||
"test": "vitest run",
|
||||
"test:ui": "vitest --ui",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluxer/constants": "workspace:*",
|
||||
"ws": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "catalog:",
|
||||
"@types/ws": "catalog:",
|
||||
"vite-tsconfig-paths": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
39
fluxer_integration/scripts/run_integration.sh
Executable file
39
fluxer_integration/scripts/run_integration.sh
Executable file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# 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/>.
|
||||
|
||||
# Run integration tests with server management
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "=== Starting Integration Tests ==="
|
||||
|
||||
# Start server
|
||||
"$SCRIPT_DIR/server_start.sh"
|
||||
|
||||
# Run tests
|
||||
cd "$SCRIPT_DIR/.."
|
||||
pnpm test
|
||||
|
||||
TEST_EXIT_CODE=$?
|
||||
|
||||
# Stop server
|
||||
"$SCRIPT_DIR/server_stop.sh"
|
||||
|
||||
exit $TEST_EXIT_CODE
|
||||
29
fluxer_integration/scripts/server_logs.sh
Executable file
29
fluxer_integration/scripts/server_logs.sh
Executable file
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# 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/>.
|
||||
|
||||
# Show Fluxer server logs
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
COMPOSE_FILE="$PROJECT_ROOT/fluxer_integration/docker/compose.yaml"
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" logs -f fluxer_server livekit
|
||||
49
fluxer_integration/scripts/server_start.sh
Executable file
49
fluxer_integration/scripts/server_start.sh
Executable file
@@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# 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/>.
|
||||
|
||||
# Start the Fluxer server for integration testing
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
COMPOSE_FILE="$PROJECT_ROOT/fluxer_integration/docker/compose.yaml"
|
||||
|
||||
echo "Starting Fluxer server for integration tests..."
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" up -d fluxer_server livekit
|
||||
|
||||
echo "Waiting for server to be ready..."
|
||||
MAX_ATTEMPTS=60
|
||||
ATTEMPT=0
|
||||
|
||||
while [ $ATTEMPT -lt $MAX_ATTEMPTS ]; do
|
||||
if curl -sf -H "X-Forwarded-For: 127.0.0.1" http://localhost:8088/api/v1/_health > /dev/null 2>&1; then
|
||||
echo "Server is ready!"
|
||||
exit 0
|
||||
fi
|
||||
ATTEMPT=$((ATTEMPT + 1))
|
||||
echo "Waiting for server... (attempt $ATTEMPT/$MAX_ATTEMPTS)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Server failed to start within timeout"
|
||||
docker compose -f "$COMPOSE_FILE" logs fluxer_server
|
||||
exit 1
|
||||
33
fluxer_integration/scripts/server_stop.sh
Executable file
33
fluxer_integration/scripts/server_stop.sh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
# 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/>.
|
||||
|
||||
# Stop the Fluxer server
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
COMPOSE_FILE="$PROJECT_ROOT/fluxer_integration/docker/compose.yaml"
|
||||
|
||||
echo "Stopping Fluxer server..."
|
||||
|
||||
cd "$PROJECT_ROOT"
|
||||
|
||||
docker compose -f "$COMPOSE_FILE" down fluxer_server livekit
|
||||
|
||||
echo "Server stopped."
|
||||
25
fluxer_integration/src/Config.tsx
Normal file
25
fluxer_integration/src/Config.tsx
Normal 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 ?? '',
|
||||
};
|
||||
30
fluxer_integration/src/Setup.tsx
Normal file
30
fluxer_integration/src/Setup.tsx
Normal 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');
|
||||
}
|
||||
});
|
||||
398
fluxer_integration/src/gateway/GatewayClient.tsx
Normal file
398
fluxer_integration/src/gateway/GatewayClient.tsx
Normal 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;
|
||||
}
|
||||
86
fluxer_integration/src/gateway/GatewayConnection.test.tsx
Normal file
86
fluxer_integration/src/gateway/GatewayConnection.test.tsx
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
192
fluxer_integration/src/gateway/GatewayGuildEvents.test.tsx
Normal file
192
fluxer_integration/src/gateway/GatewayGuildEvents.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
146
fluxer_integration/src/gateway/GatewayTypes.tsx
Normal file
146
fluxer_integration/src/gateway/GatewayTypes.tsx
Normal 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;
|
||||
}
|
||||
216
fluxer_integration/src/gateway/GatewayVoiceState.test.tsx
Normal file
216
fluxer_integration/src/gateway/GatewayVoiceState.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
156
fluxer_integration/src/globalSetup.tsx
Normal file
156
fluxer_integration/src/globalSetup.tsx
Normal 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');
|
||||
}
|
||||
47
fluxer_integration/src/globalTeardown.tsx
Normal file
47
fluxer_integration/src/globalTeardown.tsx
Normal 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');
|
||||
}
|
||||
78
fluxer_integration/src/helpers/AccountHelper.tsx
Normal file
78
fluxer_integration/src/helpers/AccountHelper.tsx
Normal 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);
|
||||
}
|
||||
85
fluxer_integration/src/helpers/ApiClient.tsx
Normal file
85
fluxer_integration/src/helpers/ApiClient.tsx
Normal 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();
|
||||
78
fluxer_integration/src/helpers/GuildHelper.tsx
Normal file
78
fluxer_integration/src/helpers/GuildHelper.tsx
Normal 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;
|
||||
}
|
||||
41
fluxer_integration/src/helpers/VoiceHelper.tsx
Normal file
41
fluxer_integration/src/helpers/VoiceHelper.tsx
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
13
fluxer_integration/tsconfig.json
Normal file
13
fluxer_integration/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "../tsconfigs/base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"types": ["node", "vitest/globals"],
|
||||
"paths": {
|
||||
"@fluxer/integration/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
54
fluxer_integration/vitest.config.ts
Normal file
54
fluxer_integration/vitest.config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* 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 tsconfigPaths from 'vite-tsconfig-paths';
|
||||
import {defineConfig} from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
root: process.cwd(),
|
||||
plugins: [tsconfigPaths()],
|
||||
cacheDir: './node_modules/.vitest',
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'node',
|
||||
globalSetup: ['./src/globalSetup.tsx'],
|
||||
globalTeardown: ['./src/globalTeardown.tsx'],
|
||||
setupFiles: ['./src/Setup.tsx'],
|
||||
|
||||
pool: 'threads',
|
||||
fileParallelism: false,
|
||||
maxConcurrency: 2,
|
||||
|
||||
testTimeout: 30000,
|
||||
hookTimeout: 15000,
|
||||
|
||||
isolate: true,
|
||||
|
||||
reporters: ['default', 'json'],
|
||||
outputFile: './test-results.json',
|
||||
|
||||
include: ['src/**/*.test.tsx'],
|
||||
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: ['**/*.test.tsx', '**/*.spec.tsx', 'node_modules/'],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user