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,189 @@
/*
* 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 {createAPIApp} from '@fluxer/api/src/App';
import {Config} from '@fluxer/api/src/Config';
import {clearSqliteStore} from '@fluxer/api/src/database/SqliteKV';
import {MeilisearchSearchProvider} from '@fluxer/api/src/infrastructure/MeilisearchSearchProvider';
import {NullSearchProvider} from '@fluxer/api/src/infrastructure/NullSearchProvider';
import {
setInjectedBlueskyOAuthService,
setInjectedGatewayService,
setInjectedKVProvider,
setInjectedMediaService,
setInjectedS3Service,
setInjectedSearchProviderService,
setInjectedWorkerService,
} from '@fluxer/api/src/middleware/ServiceRegistry';
import type {ISearchProvider} from '@fluxer/api/src/search/ISearchProvider';
import {acquireMeilisearchTestServer} from '@fluxer/api/src/test/meilisearch/MeilisearchTestServer';
import {MockBlueskyOAuthService} from '@fluxer/api/src/test/mocks/MockBlueskyOAuthService';
import {MockKVProvider} from '@fluxer/api/src/test/mocks/MockKVProvider';
import {NoopLogger} from '@fluxer/api/src/test/mocks/NoopLogger';
import {NoopGatewayService} from '@fluxer/api/src/test/NoopGatewayService';
import {NoopWorkerService} from '@fluxer/api/src/test/NoopWorkerService';
import {TestMediaService} from '@fluxer/api/src/test/TestMediaService';
import {TestS3Service} from '@fluxer/api/src/test/TestS3Service';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {DEFAULT_SEARCH_CLIENT_TIMEOUT_MS} from '@fluxer/constants/src/Timeouts';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
export interface ApiTestHarness {
app: HonoApp;
kvProvider: IKVProvider;
mockBlueskyOAuthService: MockBlueskyOAuthService;
reset: () => Promise<void>;
resetData: () => Promise<void>;
shutdown: () => Promise<void>;
requestJson: (params: {
path: string;
method?: string;
body?: unknown;
headers?: Record<string, string>;
}) => Promise<Response>;
}
export interface CreateApiTestHarnessOptions {
search?: 'disabled' | 'meilisearch';
}
export async function createApiTestHarness(options: CreateApiTestHarnessOptions = {}): Promise<ApiTestHarness> {
const kvProvider = new MockKVProvider();
setInjectedKVProvider(kvProvider);
setInjectedGatewayService(new NoopGatewayService());
setInjectedWorkerService(new NoopWorkerService());
const s3Service = new TestS3Service();
await s3Service.initialize();
setInjectedS3Service(s3Service);
const mediaService = new TestMediaService();
mediaService.setS3Service(s3Service);
setInjectedMediaService(mediaService);
const harnessLogger = new NoopLogger();
let searchProvider: ISearchProvider | null = null;
let releaseMeilisearch: (() => Promise<void>) | null = null;
if (options.search === 'meilisearch') {
const server = await acquireMeilisearchTestServer();
releaseMeilisearch = server.release;
searchProvider = new MeilisearchSearchProvider({
config: {
url: server.url,
apiKey: server.apiKey,
timeoutMs: DEFAULT_SEARCH_CLIENT_TIMEOUT_MS,
taskWaitTimeoutMs: DEFAULT_SEARCH_CLIENT_TIMEOUT_MS,
taskPollIntervalMs: 50,
},
logger: harnessLogger,
});
await searchProvider.initialize();
} else {
searchProvider = new NullSearchProvider();
await searchProvider.initialize();
}
setInjectedSearchProviderService(searchProvider);
const mockBlueskyOAuthService = new MockBlueskyOAuthService();
setInjectedBlueskyOAuthService(mockBlueskyOAuthService);
const {
app,
initialize: initializeApp,
shutdown: shutdownApp,
} = await createAPIApp({
config: Config,
logger: harnessLogger,
});
try {
await initializeApp();
} catch (error) {
console.error('Failed to initialize API app for tests:', error);
throw error;
}
async function reset(): Promise<void> {
clearSqliteStore();
kvProvider.reset();
mockBlueskyOAuthService.reset();
}
async function resetData(): Promise<void> {
kvProvider.reset();
}
async function shutdown(): Promise<void> {
try {
await shutdownApp();
} catch (_error) {}
if (searchProvider) {
try {
await searchProvider.shutdown();
} catch (_error) {}
}
setInjectedWorkerService(undefined);
setInjectedGatewayService(undefined);
setInjectedKVProvider(undefined);
setInjectedMediaService(undefined);
setInjectedS3Service(undefined);
setInjectedSearchProviderService(undefined);
setInjectedBlueskyOAuthService(undefined);
await s3Service.cleanup();
if (releaseMeilisearch) {
await releaseMeilisearch();
}
}
async function requestJson(params: {
path: string;
method?: string;
body?: unknown;
headers?: Record<string, string>;
}): Promise<Response> {
const {path, body, method = 'GET', headers} = params;
const mergedHeaders = new Headers(headers);
if (!mergedHeaders.has('content-type')) {
mergedHeaders.set('content-type', 'application/json');
}
if (!mergedHeaders.has('x-forwarded-for')) {
mergedHeaders.set('x-forwarded-for', '127.0.0.1');
}
const contentType = mergedHeaders.get('content-type');
let requestBody: string | undefined;
if (body !== undefined) {
if (typeof body === 'string') {
requestBody = body;
} else if (contentType === 'application/json') {
requestBody = JSON.stringify(body);
} else {
requestBody = JSON.stringify(body);
}
}
return app.request(path, {
method,
headers: mergedHeaders,
body: requestBody,
});
}
return {app, kvProvider, mockBlueskyOAuthService, reset, resetData, shutdown, requestJson};
}

View File

@@ -0,0 +1,739 @@
/*
* 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 ChannelID, type GuildID, guildIdToRoleId, type RoleID, type UserID} from '@fluxer/api/src/BrandedTypes';
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
import {mapGuildToGuildResponse} from '@fluxer/api/src/guild/GuildModel';
import {GuildMemberRepository} from '@fluxer/api/src/guild/repositories/GuildMemberRepository';
import {GuildRepository} from '@fluxer/api/src/guild/repositories/GuildRepository';
import {GuildRoleRepository} from '@fluxer/api/src/guild/repositories/GuildRoleRepository';
import {type CallData, IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
const guildOwners = new Map<string, UserID>();
const guildMembers = new Map<string, Set<UserID>>();
const guildRepository = new GuildRepository();
const guildMemberRepository = new GuildMemberRepository();
const roleRepository = new GuildRoleRepository();
function createDummyGuildResponse(params: {guildId: GuildID; userId: UserID}): GuildResponse {
const ownerId = guildOwners.get(params.guildId.toString()) ?? params.userId;
return {
id: params.guildId.toString(),
name: 'Test Guild',
icon: null,
banner: null,
banner_width: null,
banner_height: null,
splash: null,
splash_width: null,
splash_height: null,
splash_card_alignment: 0,
embed_splash: null,
embed_splash_width: null,
embed_splash_height: null,
vanity_url_code: null,
owner_id: ownerId.toString(),
system_channel_id: null,
system_channel_flags: 0,
rules_channel_id: null,
afk_channel_id: null,
afk_timeout: 60,
features: [],
verification_level: 0,
mfa_level: 0,
nsfw_level: 0,
explicit_content_filter: 0,
default_message_notifications: 0,
disabled_operations: 0,
message_history_cutoff: null,
permissions: null,
};
}
export class NoopGatewayService extends IGatewayService {
constructor() {
super();
guildOwners.clear();
guildMembers.clear();
}
setGuildOwner(guildId: GuildID, ownerId: UserID): void {
guildOwners.set(guildId.toString(), ownerId);
let members = guildMembers.get(guildId.toString());
if (!members) {
members = new Set();
guildMembers.set(guildId.toString(), members);
}
members.add(ownerId);
}
addGuildMember(guildId: GuildID, userId: UserID): void {
let members = guildMembers.get(guildId.toString());
if (!members) {
members = new Set();
guildMembers.set(guildId.toString(), members);
}
members.add(userId);
}
async dispatchGuild(_params: {guildId: GuildID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
async getGuildCounts(guildId: GuildID): Promise<{memberCount: number; presenceCount: number}> {
const members = guildMembers.get(guildId.toString());
return {memberCount: members?.size ?? 0, presenceCount: 0};
}
async getChannelCount(_params: {guildId: GuildID}): Promise<number> {
return 0;
}
async startGuild(guildId: GuildID): Promise<void> {
const guild = await guildRepository.findUnique(guildId);
if (guild) {
this.setGuildOwner(guildId, guild.ownerId);
}
}
async stopGuild(_guildId: GuildID): Promise<void> {}
async reloadGuild(_guildId: GuildID): Promise<void> {}
async reloadAllGuilds(_guildIds: Array<GuildID>): Promise<{count: number}> {
return {count: 0};
}
async shutdownGuild(_guildId: GuildID): Promise<void> {}
async getGuildMemoryStats(_limit: number): Promise<{
guilds: Array<{
guild_id: string | null;
guild_name: string;
guild_icon: string | null;
memory: string;
member_count: number;
session_count: number;
presence_count: number;
}>;
}> {
return {guilds: []};
}
async getUsersToMentionByRoles(_params: {
guildId: GuildID;
channelId: ChannelID;
roleIds: Array<RoleID>;
authorId: UserID;
}): Promise<Array<UserID>> {
return [];
}
async getUsersToMentionByUserIds(_params: {
guildId: GuildID;
channelId: ChannelID;
userIds: Array<UserID>;
authorId: UserID;
}): Promise<Array<UserID>> {
return [];
}
async getAllUsersToMention(_params: {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
}): Promise<Array<UserID>> {
return [];
}
async resolveAllMentions(_params: {
guildId: GuildID;
channelId: ChannelID;
authorId: UserID;
mentionEveryone: boolean;
mentionHere: boolean;
roleIds: Array<RoleID>;
userIds: Array<UserID>;
}): Promise<Array<UserID>> {
return [];
}
async getUserPermissions(params: {guildId: GuildID; userId: UserID; channelId?: ChannelID}): Promise<bigint> {
const {guildId, userId} = params;
const guild = await guildRepository.findUnique(guildId);
if (!guild) {
return 0n;
}
if (guild.ownerId === userId) {
return ALL_PERMISSIONS;
}
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return 0n;
}
const roles = await roleRepository.listRoles(guildId);
return this.calculatePermissions(Array.from(member.roleIds), roles, userId === guild.ownerId, guildId);
}
async getUserPermissionsBatch(_params: {
guildIds: Array<GuildID>;
userId: UserID;
channelId?: ChannelID;
}): Promise<Map<GuildID, bigint>> {
return new Map();
}
async canManageRoles(_params: {
guildId: GuildID;
userId: UserID;
targetUserId: UserID;
roleId: RoleID;
}): Promise<boolean> {
return false;
}
async canManageRole(params: {guildId: GuildID; userId: UserID; roleId: RoleID}): Promise<boolean> {
const {guildId, userId, roleId} = params;
const guild = await guildRepository.findUnique(guildId);
if (!guild) {
return false;
}
if (guild.ownerId === userId) {
return true;
}
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return false;
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((userPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
}
const targetRole = roles.find((r) => r.id === roleId);
if (!targetRole) {
return false;
}
let userHighestPosition = -1;
for (const roleId of member.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
userHighestPosition = Math.max(userHighestPosition, (role as {position: number}).position);
}
}
const targetPosition = (targetRole as {position?: number}).position ?? 0;
return userHighestPosition > targetPosition;
}
async getAssignableRoles(_params: {guildId: GuildID; userId: UserID}): Promise<Array<RoleID>> {
return [];
}
async getUserMaxRolePosition(_params: {guildId: GuildID; userId: UserID}): Promise<number> {
return 0;
}
async checkTargetMember(params: {guildId: GuildID; userId: UserID; targetUserId: UserID}): Promise<boolean> {
const {guildId, userId, targetUserId} = params;
const guild = await guildRepository.findUnique(guildId);
if (!guild) {
return false;
}
if (guild.ownerId === userId) {
return true;
}
const member = await guildMemberRepository.getMember(guildId, userId);
const targetMember = await guildMemberRepository.getMember(guildId, targetUserId);
if (!member || !targetMember) {
return false;
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if ((userPermissions & Permissions.ADMINISTRATOR) !== 0n) {
return true;
}
let targetHighestPosition = -1;
for (const roleId of targetMember.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
targetHighestPosition = Math.max(targetHighestPosition, (role as {position: number}).position);
}
}
let userHighestPosition = -1;
for (const roleId of member.roleIds) {
const role = roles.find((r) => r.id === roleId);
if (role && (role as {position?: number}).position !== undefined) {
userHighestPosition = Math.max(userHighestPosition, (role as {position: number}).position);
}
}
return userHighestPosition > targetHighestPosition;
}
async getViewableChannels(params: {guildId: GuildID; userId: UserID}): Promise<Array<ChannelID>> {
const {guildId, userId} = params;
const guild = await guildRepository.findUnique(guildId);
const {ChannelDataRepository} = await import('@fluxer/api/src/channel/repositories/ChannelDataRepository');
const channelRepo = new ChannelDataRepository();
const channels = await channelRepo.listGuildChannels(guildId);
if (guild?.ownerId === userId) {
return channels.map((ch) => ch.id);
}
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return [];
}
const roles = await roleRepository.listRoles(guildId);
const userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild?.ownerId,
guildId,
);
const everyoneRoleId = guildIdToRoleId(guildId);
const viewable: Array<ChannelID> = [];
for (const channel of channels) {
let channelPermissions = userPermissions;
if (channel.permissionOverwrites) {
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
channelPermissions = (channelPermissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
for (const roleId of member.roleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
channelPermissions = (channelPermissions & ~overwrite.deny) | overwrite.allow;
}
}
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
channelPermissions = (channelPermissions & ~userOverwrite.deny) | userOverwrite.allow;
}
}
if ((channelPermissions & Permissions.VIEW_CHANNEL) !== 0n) {
viewable.push(channel.id);
}
}
return viewable;
}
async getCategoryChannelCount(_params: {guildId: GuildID; categoryId: ChannelID}): Promise<number> {
return 0;
}
async getMembersWithRole(_params: {guildId: GuildID; roleId: RoleID}): Promise<Array<UserID>> {
return [];
}
async getGuildData(params: {
guildId: GuildID;
userId: UserID;
skipMembershipCheck?: boolean;
}): Promise<GuildResponse> {
if (!params.skipMembershipCheck) {
const isMember = await this.hasGuildMember({guildId: params.guildId, userId: params.userId});
if (!isMember) {
throw new UnknownGuildError();
}
}
const guild = await guildRepository.findUnique(params.guildId);
if (guild) {
const ownerId = guild.ownerId;
guildOwners.set(params.guildId.toString(), ownerId);
this.setGuildOwner(params.guildId, ownerId);
return mapGuildToGuildResponse(guild);
}
return createDummyGuildResponse({guildId: params.guildId, userId: params.userId});
}
async getGuildMember(params: {
guildId: GuildID;
userId: UserID;
}): Promise<{success: boolean; memberData?: GuildMemberResponse}> {
const members = guildMembers.get(params.guildId.toString());
const isMember = members?.has(params.userId) ?? false;
if (!isMember) {
return {success: false};
}
return {
success: true,
memberData: {
user: {
id: params.userId.toString(),
username: 'testuser',
discriminator: '0000',
global_name: null,
avatar: null,
avatar_color: null,
bot: false,
system: false,
flags: 0,
},
nick: null,
avatar: null,
banner: null,
accent_color: null,
roles: [],
joined_at: '2024-01-01T00:00:00.000Z',
deaf: false,
mute: false,
communication_disabled_until: null,
profile_flags: null,
},
};
}
async hasGuildMember(params: {guildId: GuildID; userId: UserID}): Promise<boolean> {
const members = guildMembers.get(params.guildId.toString());
return members?.has(params.userId) ?? false;
}
async listGuildMembers(_params: {guildId: GuildID; limit: number; offset: number}): Promise<{
members: Array<GuildMemberResponse>;
total: number;
}> {
return {members: [], total: 0};
}
async listGuildMembersCursor(_params: {guildId: GuildID; limit: number; after?: UserID}): Promise<{
members: Array<GuildMemberResponse>;
total: number;
}> {
return {members: [], total: 0};
}
async checkPermission(params: {
guildId: GuildID;
userId: UserID;
permission: bigint;
channelId?: ChannelID;
}): Promise<boolean> {
const {guildId, userId, permission, channelId} = params;
const guild = await guildRepository.findUnique(guildId);
if (!guild) {
return false;
}
if (guild.ownerId === userId) {
return true;
}
const member = await guildMemberRepository.getMember(guildId, userId);
if (!member) {
return false;
}
const roles = await roleRepository.listRoles(guildId);
let userPermissions = this.calculatePermissions(
Array.from(member.roleIds),
roles,
userId === guild.ownerId,
guildId,
);
if (channelId) {
const {ChannelDataRepository} = await import('@fluxer/api/src/channel/repositories/ChannelDataRepository');
const channelRepo = new ChannelDataRepository();
const channel = await channelRepo.findUnique(channelId);
if (channel?.permissionOverwrites) {
const everyoneRoleId = guildIdToRoleId(guildId);
const everyoneOverwrite = channel.permissionOverwrites.get(everyoneRoleId);
if (everyoneOverwrite) {
userPermissions = (userPermissions & ~everyoneOverwrite.deny) | everyoneOverwrite.allow;
}
for (const roleId of member.roleIds) {
const overwrite = channel.permissionOverwrites.get(roleId);
if (overwrite) {
userPermissions = (userPermissions & ~overwrite.deny) | overwrite.allow;
}
}
const userOverwrite = channel.permissionOverwrites.get(userId);
if (userOverwrite) {
userPermissions = (userPermissions & ~userOverwrite.deny) | userOverwrite.allow;
}
}
}
return (userPermissions & permission) === permission;
}
private calculatePermissions(
memberRoleIds: Array<RoleID>,
allRoles: Array<{id: RoleID; permissions: bigint}>,
isOwner: boolean,
guildId: GuildID,
): bigint {
if (isOwner) {
return ALL_PERMISSIONS;
}
let permissions = 0n;
const everyoneRoleId = guildIdToRoleId(guildId);
const everyoneRole = allRoles.find((r) => r.id === everyoneRoleId);
if (everyoneRole) {
permissions |= everyoneRole.permissions;
}
for (const roleId of memberRoleIds) {
const role = allRoles.find((r) => r.id === roleId);
if (role) {
const rolePermissions = role.permissions;
permissions |= rolePermissions;
if ((rolePermissions & Permissions.ADMINISTRATOR) !== 0n) {
return Permissions.ADMINISTRATOR;
}
}
}
return permissions;
}
async getVanityUrlChannel(_guildId: GuildID): Promise<ChannelID | null> {
return null;
}
async getFirstViewableTextChannel(_guildId: GuildID): Promise<ChannelID | null> {
return null;
}
async dispatchPresence(_params: {userId: UserID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
async invalidatePushBadgeCount(_params: {userId: UserID}): Promise<void> {}
async joinGuild(params: {userId: UserID; guildId: GuildID}): Promise<void> {
this.addGuildMember(params.guildId, params.userId);
}
async leaveGuild(params: {userId: UserID; guildId: GuildID}): Promise<void> {
const members = guildMembers.get(params.guildId.toString());
if (members) {
members.delete(params.userId);
}
}
async terminateSession(_params: {userId: UserID; sessionIdHashes: Array<string>}): Promise<void> {}
async terminateAllSessionsForUser(_params: {userId: UserID}): Promise<void> {}
async updateMemberVoice(_params: {
guildId: GuildID;
userId: UserID;
mute: boolean;
deaf: boolean;
}): Promise<{success: boolean}> {
return {success: false};
}
async disconnectVoiceUser(_params: {guildId: GuildID; userId: UserID; connectionId: string}): Promise<void> {}
async disconnectVoiceUserIfInChannel(_params: {
guildId?: GuildID;
channelId: ChannelID;
userId: UserID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}> {
return {success: false, ignored: true};
}
async disconnectAllVoiceUsersInChannel(_params: {
guildId: GuildID;
channelId: ChannelID;
}): Promise<{success: boolean; disconnectedCount: number}> {
return {success: false, disconnectedCount: 0};
}
async confirmVoiceConnection(_params: {
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
tokenNonce: string;
}): Promise<{success: boolean; error?: string}> {
return {success: false};
}
async getVoiceStatesForChannel(_params: {
guildId?: GuildID;
channelId: ChannelID;
}): Promise<{voiceStates: Array<{connectionId: string; userId: string; channelId: string}>}> {
return {voiceStates: []};
}
async getPendingJoinsForChannel(_params: {
guildId?: GuildID;
channelId: ChannelID;
}): Promise<{pendingJoins: Array<{connectionId: string; userId: string; tokenNonce: string; expiresAt: number}>}> {
return {pendingJoins: []};
}
async getVoiceState(_params: {guildId: GuildID; userId: UserID}): Promise<{channel_id: string | null} | null> {
return null;
}
async moveMember(_params: {
guildId: GuildID;
moderatorId: UserID;
userId: UserID;
channelId: ChannelID | null;
connectionId: string | null;
}): Promise<{success?: boolean; error?: string}> {
return {success: false};
}
async hasActivePresence(_userId: UserID): Promise<boolean> {
return false;
}
async addTemporaryGuild(_params: {userId: UserID; guildId: GuildID}): Promise<void> {}
async removeTemporaryGuild(_params: {userId: UserID; guildId: GuildID}): Promise<void> {}
async syncGroupDmRecipients(_params: {
userId: UserID;
recipientsByChannel: Record<string, Array<string>>;
}): Promise<void> {}
async switchVoiceRegion(_params: {guildId: GuildID; channelId: ChannelID}): Promise<void> {}
async getCall(_channelId: ChannelID): Promise<CallData | null> {
return null;
}
async createCall(
_channelId: ChannelID,
_messageId: string,
_region: string,
_ringing: Array<string>,
_recipients: Array<string>,
): Promise<CallData> {
return {
channel_id: _channelId.toString(),
message_id: _messageId,
region: _region,
ringing: _ringing,
recipients: _recipients,
voice_states: [],
};
}
async updateCallRegion(_channelId: ChannelID, _region: string | null): Promise<boolean> {
return true;
}
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
return true;
}
async stopRingingCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
return true;
}
async deleteCall(_channelId: ChannelID): Promise<boolean> {
return true;
}
async getDiscoveryOnlineCounts(_guildIds: Array<GuildID>): Promise<Map<GuildID, number>> {
return new Map();
}
async getNodeStats(): Promise<{
status: string;
sessions: number;
guilds: number;
presences: number;
calls: number;
memory: {
total: string;
processes: string;
system: string;
};
process_count: number;
process_limit: number;
uptime_seconds: number;
}> {
return {
status: 'ok',
sessions: 0,
guilds: 0,
presences: 0,
calls: 0,
memory: {total: '0', processes: '0', system: '0'},
process_count: 0,
process_limit: 0,
uptime_seconds: 0,
};
}
}

View File

@@ -0,0 +1,32 @@
/*
* 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 {IWorkerService} from '@fluxer/worker/src/contracts/IWorkerService';
export class NoopWorkerService implements IWorkerService {
async addJob(): Promise<void> {}
async cancelJob(_jobId: string): Promise<boolean> {
return false;
}
async retryDeadLetterJob(_jobId: string): Promise<boolean> {
return false;
}
}

View File

@@ -0,0 +1,70 @@
/*
* 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 path from 'node:path';
import {fileURLToPath} from 'node:url';
import {buildAPIConfigFromMaster, initializeConfig} from '@fluxer/api/src/Config';
import {initializeLogger} from '@fluxer/api/src/Logger';
import {drainSearchTasks, enableSearchTaskTracking} from '@fluxer/api/src/search/SearchTaskTracker';
import {NoopLogger} from '@fluxer/api/src/test/mocks/NoopLogger';
import {resetNcmecState} from '@fluxer/api/src/test/msw/handlers/NcmecHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {loadConfig} from '@fluxer/config/src/ConfigLoader';
import {afterAll, afterEach, beforeAll} from 'vitest';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const testConfigPath = path.resolve(__dirname, '../../../..', 'config/config.test.json');
const master = await loadConfig([testConfigPath]);
const apiConfig = buildAPIConfigFromMaster(master);
initializeConfig({
...apiConfig,
database: {
...apiConfig.database,
sqlitePath: ':memory:',
},
auth: {
...apiConfig.auth,
passkeys: {
...apiConfig.auth.passkeys,
rpId: 'localhost',
allowedOrigins: ['http://localhost'],
},
},
voice: {
...apiConfig.voice,
enabled: false,
},
});
initializeLogger(new NoopLogger());
enableSearchTaskTracking();
beforeAll(async () => {
server.listen({onUnhandledRequest: 'error'});
});
afterEach(async () => {
await drainSearchTasks();
server.resetHandlers();
resetNcmecState();
});
afterAll(() => {
server.close();
});

View File

@@ -0,0 +1,175 @@
/*
* 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 TEST_TIMEOUTS = {
IMMEDIATE: 20,
QUICK: 100,
DEFAULT: 1000,
MEDIUM: 2000,
LONG: 5000,
TICKET_EXPIRY_GRACE: 2000,
COOLDOWN_WAIT: 31000,
HARVEST_EXPIRY_BOUNDARY: 7000,
MAX: 10000,
} as const;
export const TEST_CREDENTIALS = {
STRONG_PASSWORD: 'a-strong-password',
ALT_PASSWORD_1: 'AnotherStrongPassword123!',
ALT_PASSWORD_2: 'SecurePass-2024!',
WEAK_PASSWORD: 'weak',
EMPTY_PASSWORD: '',
} as const;
export const TEST_USER_DATA = {
DEFAULT_DATE_OF_BIRTH: '2000-01-01',
DEFAULT_GLOBAL_NAME: 'Test User',
REGISTER_GLOBAL_NAME: 'Register User',
LOGIN_GLOBAL_NAME: 'Login Test User',
EMAIL_DOMAIN: 'example.com',
USERNAME_PREFIX: 'itest',
EMAIL_PREFIX: 'integration',
} as const;
export const TEST_GUILD_DATA = {
DEFAULT_NAME: 'Test Guild',
VALIDATION_NAME: 'Operation Test Guild',
SCHEDULING_NAME: 'sched-validation',
} as const;
export const TEST_CHANNEL_DATA = {
DEFAULT_NAME: 'test',
DEFAULT_NAME_ALT: 'general',
} as const;
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
INTERNAL_SERVER_ERROR: 500,
ACCEPTED: 202,
} as const;
export const SUCCESS_CODES = [200, 201, 204] as const;
export const CLIENT_ERROR_CODES = [400, 401, 403, 404, 409] as const;
export const EXPECTED_RESPONSES = {
UNAUTHORIZED_OR_BAD_REQUEST: [400, 401] as const,
SUCCESS_OR_NO_CONTENT: [200, 204] as const,
DUPLICATE_EMAIL_OR_CONFLICT: [400, 409] as const,
EMAIL_SENT: [200, 202, 204] as const,
} as const;
export const TEST_IDS = {
NONEXISTENT_GUILD: '999999999999999999',
NONEXISTENT_CHANNEL: '999999999999999999',
NONEXISTENT_USER: '999999999999999999',
NONEXISTENT_MESSAGE: '999999999999999999',
NONEXISTENT_WEBHOOK: '999999999999999999',
} as const;
export const TEST_LIMITS = {
SCHEDULED_MESSAGE_MAX_DAYS: 30,
SCHEDULED_MESSAGE_MIN_DELAY_MS: 5 * 60 * 1000,
SCHEDULED_MESSAGE_MAX_DELAY_MS: 31 * 24 * 60 * 60 * 1000,
MFA_TICKET_SHORT_TTL: 1,
MFA_TICKET_LONG_TTL: 300,
PASSWORD_RESET_TOKEN_LENGTH: 64,
} as const;
export function generateTimestampedValue(prefix = 'test'): string {
return `${prefix}-${Date.now()}`;
}
export function generateUniquePassword(): string {
return `SecurePass-${Date.now()}!`;
}
export function generateTestEmail(prefix = 'test', domain = 'example.com'): string {
return `${prefix}-${Date.now()}@${domain}`;
}
export function generateFutureTimestamp(minutesInFuture = 5): string {
return new Date(Date.now() + minutesInFuture * 60 * 1000).toISOString();
}
export function generatePastTimestamp(hoursInPast = 1): string {
return new Date(Date.now() - hoursInPast * 60 * 60 * 1000).toISOString();
}
export function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function waitImmediate(): Promise<void> {
await wait(TEST_TIMEOUTS.IMMEDIATE);
}
export async function waitDefault(): Promise<void> {
await wait(TEST_TIMEOUTS.DEFAULT);
}
export async function waitTicketExpiry(): Promise<void> {
await wait(TEST_TIMEOUTS.TICKET_EXPIRY_GRACE);
}
export async function waitCooldown(): Promise<void> {
await wait(TEST_TIMEOUTS.COOLDOWN_WAIT);
}
export function isSuccessCode(status: number): boolean {
return status >= 200 && status < 300;
}
export function isClientErrorCode(status: number): boolean {
return status >= 400 && status < 500;
}
export function isServerErrorCode(status: number): boolean {
return status >= 500 && status < 600;
}
export function assertStatusCode(actual: number, expected: ReadonlyArray<number>, description?: string): void {
if (!expected.includes(actual)) {
const message = description
? `${description}: Expected status ${expected.join(' or ')}, got ${actual}`
: `Expected status ${expected.join(' or ')}, got ${actual}`;
throw new Error(message);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
/*
* 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 {clearSqliteStore} from '@fluxer/api/src/database/SqliteKV';
import {Logger} from '@fluxer/api/src/Logger';
import {resetSearchServices} from '@fluxer/api/src/SearchFactory';
import type {IKVProvider} from '@fluxer/kv_client/src/IKVProvider';
import type {QueueEngine} from '@fluxer/queue/src/engine/QueueEngine';
import type {S3Service} from '@fluxer/s3/src/s3/S3Service';
export type TestHarnessResetHandler = () => Promise<void>;
let registeredHandler: TestHarnessResetHandler | null = null;
export function registerTestHarnessReset(handler: TestHarnessResetHandler | null): void {
registeredHandler = handler;
}
export async function resetTestHarnessState(): Promise<void> {
if (!registeredHandler) {
throw new Error('Test harness reset handler not registered');
}
await registeredHandler();
}
interface CreateTestHarnessResetOptions {
kvProvider?: IKVProvider;
queueEngine?: QueueEngine;
s3Service?: S3Service;
}
export function createTestHarnessResetHandler(options: CreateTestHarnessResetOptions): TestHarnessResetHandler {
return async () => {
Logger.info('Resetting test harness state');
clearSqliteStore();
if (options.kvProvider) {
Logger.info('Clearing KV storage');
const keys = await options.kvProvider.scan('*', 100000);
if (keys.length > 0) {
await options.kvProvider.del(...keys);
}
}
if (options.queueEngine) {
Logger.info('Resetting queue engine');
await options.queueEngine.resetState();
}
if (options.s3Service) {
Logger.info('Wiping S3 storage');
await options.s3Service.clearAll();
}
resetSearchServices();
Logger.info('Test harness state reset complete');
};
}

View File

@@ -0,0 +1,209 @@
/*
* 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 crypto from 'node:crypto';
import {Config} from '@fluxer/api/src/Config';
import {
IMediaService,
type MediaProxyFrameRequest,
type MediaProxyFrameResponse,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
} from '@fluxer/api/src/infrastructure/IMediaService';
import type {S3Service} from '@fluxer/s3/src/s3/S3Service';
export class TestMediaService extends IMediaService {
private s3Service: S3Service | null = null;
setS3Service(s3Service: S3Service): void {
this.s3Service = s3Service;
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
if (request.type === 'base64') {
return this.analyzeBase64Image(request.base64);
}
if (request.type === 's3') {
return {
format: 'png',
content_type: 'image/png',
content_hash: crypto.createHash('md5').update(request.key).digest('hex'),
size: 1024,
width: 128,
height: 128,
animated: false,
nsfw: false,
};
}
if (request.type === 'upload') {
const filename = request.upload_filename.toLowerCase();
const format = this.getFormatFromFilename(filename);
let size = 1024;
if (this.s3Service) {
try {
const metadata = await this.s3Service.headObject(Config.s3.buckets.uploads, request.upload_filename);
size = metadata.size;
} catch {
size = 1024;
}
}
return {
format,
content_type: `image/${format}`,
content_hash: crypto.createHash('md5').update(request.upload_filename).digest('hex'),
size,
width: 128,
height: 128,
animated: format === 'gif',
nsfw: false,
};
}
if (request.type === 'external') {
return {
format: 'png',
content_type: 'image/png',
content_hash: crypto.createHash('md5').update(request.url).digest('hex'),
size: 1024,
width: 128,
height: 128,
animated: false,
nsfw: false,
};
}
return null;
}
getExternalMediaProxyURL(): string {
return 'https://media-proxy.test';
}
async getThumbnail(): Promise<Buffer | null> {
return Buffer.alloc(1024);
}
async extractFrames(_request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
return {
frames: [
{
timestamp: 0,
mime_type: 'image/png',
base64: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
},
],
};
}
private analyzeBase64Image(base64: string): MediaProxyMetadataResponse | null {
try {
const buffer = Buffer.from(base64, 'base64');
if (buffer.length === 0) {
return null;
}
const format = this.detectImageFormat(buffer);
if (!format) {
return null;
}
return {
format,
content_type: `image/${format}`,
content_hash: crypto.createHash('md5').update(buffer).digest('hex'),
size: buffer.length,
width: 128,
height: 128,
animated: format === 'gif',
nsfw: false,
};
} catch (_error) {
return null;
}
}
private detectImageFormat(buffer: Buffer): string | null {
if (buffer.length < 12) {
return null;
}
const first12Bytes = buffer.subarray(0, 12);
if (this.isPng(first12Bytes)) {
return 'png';
}
if (this.isGif(first12Bytes)) {
return 'gif';
}
if (this.isWebP(first12Bytes)) {
return 'webp';
}
if (this.isJpeg(first12Bytes)) {
return 'jpeg';
}
return null;
}
private isPng(bytes: Buffer): boolean {
return bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47;
}
private isGif(bytes: Buffer): boolean {
return (
bytes[0] === 0x47 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x38 &&
(bytes[4] === 0x37 || bytes[4] === 0x39) &&
bytes[5] === 0x61
);
}
private isWebP(bytes: Buffer): boolean {
return (
bytes[0] === 0x52 &&
bytes[1] === 0x49 &&
bytes[2] === 0x46 &&
bytes[3] === 0x46 &&
bytes[8] === 0x57 &&
bytes[9] === 0x45 &&
bytes[10] === 0x42 &&
bytes[11] === 0x50
);
}
private isJpeg(bytes: Buffer): boolean {
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
}
private getFormatFromFilename(filename: string): string {
const ext = filename.split('.').pop()?.toLowerCase() ?? 'png';
if (['png', 'gif', 'webp', 'jpeg', 'jpg'].includes(ext)) {
return ext === 'jpg' ? 'jpeg' : ext;
}
return 'png';
}
}

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 {drainSearchTasks} from '@fluxer/api/src/search/SearchTaskTracker';
import type {ApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
export class TestRequestBuilder<TResponse = unknown> {
private path: string = '';
private method: string = 'GET';
private requestBody: unknown = undefined;
private headers: Record<string, string> = {};
private allowedStatuses: Set<number> = new Set([200]);
private expectedErrorCode: string | null = null;
constructor(
private harness: ApiTestHarness,
token: string,
) {
if (token) {
this.headers.Authorization = token;
}
}
get(path: string): this {
this.path = path;
this.method = 'GET';
return this;
}
post(path: string): this {
this.path = path;
this.method = 'POST';
return this;
}
put(path: string): this {
this.path = path;
this.method = 'PUT';
return this;
}
delete(path: string): this {
this.path = path;
this.method = 'DELETE';
return this;
}
patch(path: string): this {
this.path = path;
this.method = 'PATCH';
return this;
}
body<T>(data: T): this {
this.requestBody = data;
return this;
}
expect(status: number, errorCode?: string): this {
this.allowedStatuses = new Set([status]);
this.expectedErrorCode = errorCode ?? null;
return this;
}
header(key: string, value: string): this {
this.headers[key] = value;
return this;
}
async execute(): Promise<TResponse> {
const response = await this.harness.requestJson({
path: this.path,
method: this.method,
body: this.requestBody,
headers: this.headers,
});
try {
if (!this.allowedStatuses.has(response.status)) {
const text = await response.text();
const expected = Array.from(this.allowedStatuses).join(', ');
throw new Error(`Expected ${expected}, got ${response.status}: ${text}`);
}
const text = await response.text();
if (text.length === 0) {
return undefined as TResponse;
}
let json: TResponse;
try {
json = JSON.parse(text) as TResponse;
} catch {
throw new Error(`Failed to parse response as JSON: ${text}`);
}
if (this.expectedErrorCode !== null) {
const actualCode = (json as {code?: string}).code;
if (actualCode !== this.expectedErrorCode) {
throw new Error(`Expected error code '${this.expectedErrorCode}', got '${actualCode}'`);
}
}
return json;
} finally {
// Make fire-and-forget indexing deterministic in integration tests.
await drainSearchTasks();
}
}
async executeWithResponse(): Promise<{response: Response; json: TResponse}> {
const response = await this.harness.requestJson({
path: this.path,
method: this.method,
body: this.requestBody,
headers: this.headers,
});
try {
if (!this.allowedStatuses.has(response.status)) {
const text = await response.text();
const expected = Array.from(this.allowedStatuses).join(', ');
throw new Error(`Expected ${expected}, got ${response.status}: ${text}`);
}
const text = await response.text();
let json: TResponse;
if (text.length === 0) {
json = undefined as TResponse;
} else {
try {
json = JSON.parse(text) as TResponse;
} catch {
throw new Error(`Failed to parse response as JSON: ${text}`);
}
}
return {response, json};
} finally {
await drainSearchTasks();
}
}
async executeRaw(): Promise<{response: Response; text: string; json: TResponse}> {
const response = await this.harness.requestJson({
path: this.path,
method: this.method,
body: this.requestBody,
headers: this.headers,
});
try {
const text = await response.text();
let json: TResponse = undefined as TResponse;
if (text.length > 0) {
try {
json = JSON.parse(text) as TResponse;
} catch {}
}
return {response, text, json};
} finally {
await drainSearchTasks();
}
}
}
export function createBuilder<TResponse = unknown>(
harness: ApiTestHarness,
token: string,
): TestRequestBuilder<TResponse> {
return new TestRequestBuilder<TResponse>(harness, token);
}
export function createBuilderWithoutAuth<TResponse = unknown>(harness: ApiTestHarness): TestRequestBuilder<TResponse> {
return new TestRequestBuilder<TResponse>(harness, '');
}

View File

@@ -0,0 +1,45 @@
/*
* 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 {rm} from 'node:fs/promises';
import {Config} from '@fluxer/api/src/Config';
import {NoopLogger} from '@fluxer/api/src/test/mocks/NoopLogger';
import type {S3ServiceConfig} from '@fluxer/s3/src/s3/S3Service';
import {S3Service} from '@fluxer/s3/src/s3/S3Service';
import {temporaryDirectory} from 'tempy';
export class TestS3Service extends S3Service {
private dataDir: string;
constructor() {
const dataDir = temporaryDirectory({prefix: 'fluxer_test_s3_'});
const buckets = Object.values(Config.s3.buckets).filter((bucket): bucket is string => bucket !== '');
const config: S3ServiceConfig = {
root: dataDir,
buckets,
};
const logger = new NoopLogger();
super(config, logger);
this.dataDir = dataDir;
}
async cleanup(): Promise<void> {
await rm(this.dataDir, {recursive: true, force: true});
}
}

View File

@@ -0,0 +1,58 @@
%PDF-1.4
1 0 obj
<<
/Type /Catalog
/Pages 2 0 R
>>
endobj
2 0 obj
<<
/Type /Pages
/Kids [3 0 R]
/Count 1
>>
endobj
3 0 obj
<<
/Type /Page
/Parent 2 0 R
/Resources <<
/Font <<
/F1 <<
/Type /Font
/Subtype /Type1
/BaseFont /Helvetica
>>
>>
>>
/MediaBox [0 0 612 792]
/Contents 4 0 R
>>
endobj
4 0 obj
<<
/Length 44
>>
stream
BT
/F1 12 Tf
100 700 Td
(Test PDF) Tj
ET
endstream
endobj
xref
0 5
0000000000 65535 f
0000000009 00000 n
0000000058 00000 n
0000000115 00000 n
0000000317 00000 n
trailer
<<
/Size 5
/Root 1 0 R
>>
startxref
410
%%EOF

View File

@@ -0,0 +1,75 @@
/*
* 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 SAMPLE_REPORT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<report xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://report.cybertip.org/ispws/xsd">
<incidentSummary>
<incidentType>Child Pornography (possession, manufacture, and distribution)</incidentType>
<reportAnnotations>
<sextortion />
<csamSolicitation />
<minorToMinorInteraction />
<spam />
<sadisticOnlineExploitation />
</reportAnnotations>
<incidentDateTime>2012-10-15T08:00:00-07:00</incidentDateTime>
</incidentSummary>
<internetDetails>
<webPageIncident>
<url>http://badsite.com/baduri.html</url>
</webPageIncident>
</internetDetails>
<reporter>
<reportingPerson>
<firstName>John</firstName>
<lastName>Smith</lastName>
<email>jsmith@example.com</email>
</reportingPerson>
</reporter>
</report>`;
export const INVALID_REPORT_XML = `<?xml version="1.0" encoding="UTF-8"?>
<report xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://report.cybertip.org/ispws/xsd">
<reporter>
<reportingPerson>
<firstName>John</firstName>
<lastName>Smith</lastName>
<email>jsmith@example.com</email>
</reportingPerson>
</reporter>
</report>`;
export const SAMPLE_FILE_DETAILS_XML = (
reportId: string,
fileId: string,
): string => `<?xml version="1.0" encoding="UTF-8"?>
<fileDetails xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://report.cybertip.org/ispws/xsd">
<reportId>${reportId}</reportId>
<fileId>${fileId}</fileId>
<originalFileName>mypic.jpg</originalFileName>
<ipCaptureEvent>
<ipAddress>63.116.246.17</ipAddress>
<eventName>Upload</eventName>
<dateTime>2011-10-31T12:00:00Z</dateTime>
</ipCaptureEvent>
<additionalInfo>File was originally posted with 6 others</additionalInfo>
</fileDetails>`;

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

BIN
packages/api/src/test/fixtures/yeah.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1,67 @@
/*
* 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 {GenericContainer, type StartedTestContainer, Wait} from 'testcontainers';
const MEILISEARCH_DOCKER_IMAGE = 'getmeili/meilisearch:v1.16.0';
const MEILISEARCH_PORT = 7700;
const TEST_MEILISEARCH_MASTER_KEY = 'test-meilisearch-master-key';
let startedContainer: StartedTestContainer | null = null;
let referenceCount = 0;
export interface MeilisearchTestServer {
url: string;
apiKey: string;
release: () => Promise<void>;
}
export async function acquireMeilisearchTestServer(): Promise<MeilisearchTestServer> {
referenceCount += 1;
if (!startedContainer) {
const container = new GenericContainer(MEILISEARCH_DOCKER_IMAGE)
.withExposedPorts(MEILISEARCH_PORT)
.withEnvironment({
MEILI_ENV: 'development',
MEILI_NO_ANALYTICS: 'true',
MEILI_MASTER_KEY: TEST_MEILISEARCH_MASTER_KEY,
})
.withWaitStrategy(Wait.forHttp('/health', MEILISEARCH_PORT));
startedContainer = await container.start();
}
const url = `http://${startedContainer.getHost()}:${startedContainer.getMappedPort(MEILISEARCH_PORT)}`;
return {
url,
apiKey: TEST_MEILISEARCH_MASTER_KEY,
release: async () => {
referenceCount -= 1;
if (referenceCount <= 0 && startedContainer) {
const containerToStop = startedContainer;
startedContainer = null;
referenceCount = 0;
await containerToStop.stop();
}
},
};
}

View File

@@ -0,0 +1,108 @@
/*
* 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 {IAssetDeletionQueue, QueuedAssetDeletion} from '@fluxer/api/src/infrastructure/IAssetDeletionQueue';
import {vi} from 'vitest';
export interface MockAssetDeletionQueueConfig {
shouldFailQueue?: boolean;
shouldFailPurge?: boolean;
shouldFailGetBatch?: boolean;
}
export class MockAssetDeletionQueue implements IAssetDeletionQueue {
readonly queueDeletionSpy = vi.fn();
readonly queueCdnPurgeSpy = vi.fn();
readonly getBatchSpy = vi.fn();
readonly requeueItemSpy = vi.fn();
readonly getQueueSizeSpy = vi.fn();
readonly clearSpy = vi.fn();
private config: MockAssetDeletionQueueConfig;
private queue: Array<QueuedAssetDeletion> = [];
private purges: Array<string> = [];
constructor(config: MockAssetDeletionQueueConfig = {}) {
this.config = config;
}
configure(config: MockAssetDeletionQueueConfig): void {
this.config = {...this.config, ...config};
}
async queueDeletion(item: Omit<QueuedAssetDeletion, 'queuedAt' | 'retryCount'>): Promise<void> {
this.queueDeletionSpy(item);
if (this.config.shouldFailQueue) {
throw new Error('Mock queue deletion failure');
}
this.queue.push({...item, queuedAt: Date.now(), retryCount: 0});
}
async queueCdnPurge(cdnUrl: string): Promise<void> {
this.queueCdnPurgeSpy(cdnUrl);
if (this.config.shouldFailPurge) {
throw new Error('Mock CDN purge failure');
}
this.purges.push(cdnUrl);
}
async getBatch(count: number): Promise<Array<QueuedAssetDeletion>> {
this.getBatchSpy(count);
if (this.config.shouldFailGetBatch) {
throw new Error('Mock get batch failure');
}
return this.queue.splice(0, count);
}
async requeueItem(item: QueuedAssetDeletion): Promise<void> {
this.requeueItemSpy(item);
this.queue.push({...item, retryCount: (item.retryCount ?? 0) + 1});
}
async getQueueSize(): Promise<number> {
this.getQueueSizeSpy();
return this.queue.length;
}
async clear(): Promise<void> {
this.clearSpy();
this.queue = [];
this.purges = [];
}
getQueuedItems(): Array<QueuedAssetDeletion> {
return [...this.queue];
}
getPurges(): Array<string> {
return [...this.purges];
}
reset(): void {
this.config = {};
this.queue = [];
this.purges = [];
this.queueDeletionSpy.mockClear();
this.queueCdnPurgeSpy.mockClear();
this.getBatchSpy.mockClear();
this.requeueItemSpy.mockClear();
this.getQueueSizeSpy.mockClear();
this.clearSpy.mockClear();
}
}

View File

@@ -0,0 +1,106 @@
/*
* 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 {UserID} from '@fluxer/api/src/BrandedTypes';
import type {
BlueskyAuthorizeResult,
BlueskyCallbackResult,
IBlueskyOAuthService,
} from '@fluxer/api/src/bluesky/IBlueskyOAuthService';
import {vi} from 'vitest';
export interface MockBlueskyOAuthServiceOptions {
authorizeResult?: BlueskyAuthorizeResult;
callbackResult?: BlueskyCallbackResult;
restoreAndVerifyResult?: {handle: string} | null;
shouldFailAuthorize?: boolean;
shouldFailCallback?: boolean;
}
export class MockBlueskyOAuthService implements IBlueskyOAuthService {
readonly authorizeSpy = vi.fn();
readonly callbackSpy = vi.fn();
readonly restoreAndVerifySpy = vi.fn();
readonly revokeSpy = vi.fn();
readonly clientMetadata: Record<string, unknown> = {client_id: 'https://test/metadata.json'};
readonly jwks: Record<string, unknown> = {keys: []};
private options: MockBlueskyOAuthServiceOptions;
constructor(options: MockBlueskyOAuthServiceOptions = {}) {
this.options = options;
this.setupDefaults();
}
private setupDefaults(): void {
this.authorizeSpy.mockImplementation(async () => {
if (this.options.shouldFailAuthorize) {
throw new Error('Mock authorise failure');
}
return this.options.authorizeResult ?? {authorizeUrl: 'https://bsky.social/oauth/authorize?mock=true'};
});
this.callbackSpy.mockImplementation(async () => {
if (this.options.shouldFailCallback) {
throw new Error('Mock callback failure');
}
if (!this.options.callbackResult) {
throw new Error('No callbackResult configured in mock');
}
return this.options.callbackResult;
});
this.restoreAndVerifySpy.mockImplementation(async () => {
return this.options.restoreAndVerifyResult ?? null;
});
this.revokeSpy.mockResolvedValue(undefined);
}
async authorize(handle: string, userId: UserID): Promise<BlueskyAuthorizeResult> {
return this.authorizeSpy(handle, userId);
}
async callback(params: URLSearchParams): Promise<BlueskyCallbackResult> {
return this.callbackSpy(params);
}
async restoreAndVerify(did: string): Promise<{handle: string} | null> {
return this.restoreAndVerifySpy(did);
}
async revoke(did: string): Promise<void> {
return this.revokeSpy(did);
}
configure(options: Partial<MockBlueskyOAuthServiceOptions>): void {
this.options = {...this.options, ...options};
this.setupDefaults();
}
reset(): void {
this.authorizeSpy.mockReset();
this.callbackSpy.mockReset();
this.restoreAndVerifySpy.mockReset();
this.revokeSpy.mockReset();
this.options = {};
this.setupDefaults();
}
}

View File

@@ -0,0 +1,75 @@
/*
* 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 type {
ICsamEvidenceService,
StoreEvidenceArgs,
StoreEvidenceResult,
} from '@fluxer/api/src/csam/ICsamEvidenceService';
import {vi} from 'vitest';
export interface MockCsamEvidenceServiceConfig {
shouldFail?: boolean;
integrityHash?: string;
evidenceZipKey?: string;
assetCopyKey?: string;
}
export class MockCsamEvidenceService implements ICsamEvidenceService {
readonly storeEvidenceSpy = vi.fn();
private config: MockCsamEvidenceServiceConfig;
private storedEvidence: Array<{args: StoreEvidenceArgs; result: StoreEvidenceResult; timestamp: Date}> = [];
constructor(config: MockCsamEvidenceServiceConfig = {}) {
this.config = config;
}
configure(config: MockCsamEvidenceServiceConfig): void {
this.config = {...this.config, ...config};
}
async storeEvidence(args: StoreEvidenceArgs): Promise<StoreEvidenceResult> {
this.storeEvidenceSpy(args);
if (this.config.shouldFail) {
throw new Error('Mock evidence service failure');
}
const result: StoreEvidenceResult = {
integrityHash: this.config.integrityHash ?? `integrity-${randomUUID()}`,
evidenceZipKey: this.config.evidenceZipKey ?? `evidence/report-${args.reportId}/evidence.zip`,
assetCopyKey: this.config.assetCopyKey ?? `evidence/report-${args.reportId}/asset-copy.dat`,
};
this.storedEvidence.push({args, result, timestamp: new Date()});
return result;
}
getStoredEvidence(): Array<{args: StoreEvidenceArgs; result: StoreEvidenceResult; timestamp: Date}> {
return [...this.storedEvidence];
}
reset(): void {
this.config = {};
this.storedEvidence = [];
this.storeEvidenceSpy.mockClear();
}
}

View File

@@ -0,0 +1,68 @@
/*
* 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 {CreateSnapshotParams} from '@fluxer/api/src/csam/CsamReportSnapshotService';
import type {ICsamReportSnapshotService} from '@fluxer/api/src/csam/ICsamReportSnapshotService';
import {vi} from 'vitest';
export interface MockCsamReportSnapshotServiceConfig {
shouldFail?: boolean;
reportId?: bigint;
}
export interface StoredSnapshot {
reportId: bigint;
params: CreateSnapshotParams;
}
export class MockCsamReportSnapshotService implements ICsamReportSnapshotService {
readonly createSnapshotSpy = vi.fn();
private config: MockCsamReportSnapshotServiceConfig;
private snapshots: Array<StoredSnapshot> = [];
private nextReportId = 1000000000000000000n;
constructor(config: MockCsamReportSnapshotServiceConfig = {}) {
this.config = config;
}
configure(config: MockCsamReportSnapshotServiceConfig): void {
this.config = {...this.config, ...config};
}
async createSnapshot(params: CreateSnapshotParams): Promise<bigint> {
this.createSnapshotSpy(params);
if (this.config.shouldFail) {
throw new Error('Mock snapshot service failure');
}
const reportId = this.config.reportId ?? this.nextReportId++;
this.snapshots.push({reportId, params});
return reportId;
}
getSnapshots(): ReadonlyArray<StoredSnapshot> {
return [...this.snapshots];
}
reset(): void {
this.config = {};
this.snapshots = [];
this.nextReportId = 1000000000000000000n;
this.createSnapshotSpy.mockClear();
}
}

View File

@@ -0,0 +1,69 @@
/*
* 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 type {
CsamScanQueueResult,
CsamScanSubmitParams,
ICsamScanQueueService,
} from '@fluxer/api/src/csam/CsamScanQueueService';
import type {PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import {vi} from 'vitest';
export interface MockCsamScanQueueServiceConfig {
shouldFail?: boolean;
matchResult?: PhotoDnaMatchResult;
}
export class MockCsamScanQueueService implements ICsamScanQueueService {
readonly submitScanSpy = vi.fn();
private config: MockCsamScanQueueServiceConfig;
private readonly defaultNoMatchResult: PhotoDnaMatchResult = {
isMatch: false,
trackingId: randomUUID(),
matchDetails: [],
timestamp: new Date().toISOString(),
};
constructor(config: MockCsamScanQueueServiceConfig = {}) {
this.config = config;
}
configure(config: MockCsamScanQueueServiceConfig): void {
this.config = {...this.config, ...config};
}
async submitScan(params: CsamScanSubmitParams): Promise<CsamScanQueueResult> {
this.submitScanSpy(params);
if (this.config.shouldFail) {
throw new Error('Mock queue service failure');
}
const result = this.config.matchResult ?? this.defaultNoMatchResult;
return {
isMatch: result.isMatch,
matchResult: result,
};
}
reset(): void {
this.config = {};
this.submitScanSpy.mockClear();
}
}

View File

@@ -0,0 +1,300 @@
/*
* 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 {ChannelID, GuildID, RoleID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {GatewayDispatchEvent} from '@fluxer/api/src/constants/Gateway';
import type {CallData, IGatewayService} from '@fluxer/api/src/infrastructure/IGatewayService';
import type {GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {vi} from 'vitest';
export interface MockGatewayServiceConfig {
confirmVoiceConnectionResult?: {success: boolean; error?: string};
confirmVoiceConnectionThrows?: Error;
disconnectVoiceUserIfInChannelResult?: {success: boolean; ignored?: boolean};
disconnectVoiceUserIfInChannelThrows?: Error;
getVoiceStatesForChannelResult?: Array<{connectionId: string; userId: string; channelId: string}>;
getPendingJoinsForChannelResult?: Array<{
connectionId: string;
userId: string;
tokenNonce: string;
expiresAt: number;
}>;
}
interface GatewayGuildMemoryStat {
guild_id: string | null;
guild_name: string;
guild_icon: string | null;
memory: string;
member_count: number;
session_count: number;
presence_count: number;
}
export class MockGatewayService implements IGatewayService {
readonly confirmVoiceConnectionSpy = vi.fn();
readonly disconnectVoiceUserIfInChannelSpy = vi.fn();
readonly getVoiceStatesForChannelSpy = vi.fn();
readonly getPendingJoinsForChannelSpy = vi.fn();
readonly disconnectVoiceUserSpy = vi.fn();
readonly moveMemberSpy = vi.fn();
readonly updateMemberVoiceSpy = vi.fn();
private config: MockGatewayServiceConfig;
constructor(config: MockGatewayServiceConfig = {}) {
this.config = config;
}
configure(config: MockGatewayServiceConfig): void {
this.config = {...this.config, ...config};
}
async confirmVoiceConnection(params: {
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
tokenNonce: string;
}): Promise<{success: boolean; error?: string}> {
this.confirmVoiceConnectionSpy(params);
if (this.config.confirmVoiceConnectionThrows) {
throw this.config.confirmVoiceConnectionThrows;
}
return this.config.confirmVoiceConnectionResult ?? {success: true};
}
async disconnectVoiceUserIfInChannel(params: {
guildId?: GuildID;
channelId: ChannelID;
userId: UserID;
connectionId?: string;
}): Promise<{success: boolean; ignored?: boolean}> {
this.disconnectVoiceUserIfInChannelSpy(params);
if (this.config.disconnectVoiceUserIfInChannelThrows) {
throw this.config.disconnectVoiceUserIfInChannelThrows;
}
return this.config.disconnectVoiceUserIfInChannelResult ?? {success: true};
}
async getVoiceStatesForChannel(params: {
guildId?: GuildID;
channelId: ChannelID;
}): Promise<{voiceStates: Array<{connectionId: string; userId: string; channelId: string}>}> {
this.getVoiceStatesForChannelSpy(params);
return {voiceStates: this.config.getVoiceStatesForChannelResult ?? []};
}
async getPendingJoinsForChannel(params: {
guildId?: GuildID;
channelId: ChannelID;
}): Promise<{pendingJoins: Array<{connectionId: string; userId: string; tokenNonce: string; expiresAt: number}>}> {
this.getPendingJoinsForChannelSpy(params);
return {pendingJoins: this.config.getPendingJoinsForChannelResult ?? []};
}
async disconnectVoiceUser(params: {guildId: GuildID; userId: UserID; connectionId: string}): Promise<void> {
this.disconnectVoiceUserSpy(params);
}
async moveMember(params: {
guildId: GuildID;
moderatorId: UserID;
userId: UserID;
channelId: ChannelID | null;
connectionId: string | null;
}): Promise<{success?: boolean; error?: string}> {
this.moveMemberSpy(params);
return {success: true};
}
async updateMemberVoice(params: {
guildId: GuildID;
userId: UserID;
mute: boolean;
deaf: boolean;
}): Promise<{success: boolean}> {
this.updateMemberVoiceSpy(params);
return {success: true};
}
async dispatchGuild(_params: {guildId: GuildID; event: GatewayDispatchEvent; data: unknown}): Promise<void> {}
async getGuildCounts(_guildId: GuildID): Promise<{memberCount: number; presenceCount: number}> {
return {memberCount: 0, presenceCount: 0};
}
async getChannelCount(_params: {guildId: GuildID}): Promise<number> {
return 0;
}
async startGuild(_guildId: GuildID): Promise<void> {}
async stopGuild(_guildId: GuildID): Promise<void> {}
async reloadGuild(_guildId: GuildID): Promise<void> {}
async reloadAllGuilds(_guildIds: Array<GuildID>): Promise<{count: number}> {
return {count: 0};
}
async shutdownGuild(_guildId: GuildID): Promise<void> {}
async getGuildMemoryStats(_limit: number): Promise<{guilds: Array<GatewayGuildMemoryStat>}> {
return {guilds: []};
}
async getUsersToMentionByRoles(_params: unknown): Promise<Array<UserID>> {
return [];
}
async getUsersToMentionByUserIds(_params: unknown): Promise<Array<UserID>> {
return [];
}
async getAllUsersToMention(_params: unknown): Promise<Array<UserID>> {
return [];
}
async resolveAllMentions(_params: unknown): Promise<Array<UserID>> {
return [];
}
async getUserPermissions(_params: unknown): Promise<bigint> {
return 0n;
}
async getUserPermissionsBatch(_params: unknown): Promise<Map<GuildID, bigint>> {
return new Map();
}
async canManageRoles(_params: unknown): Promise<boolean> {
return false;
}
async canManageRole(_params: unknown): Promise<boolean> {
return false;
}
async getAssignableRoles(_params: unknown): Promise<Array<RoleID>> {
return [];
}
async getUserMaxRolePosition(_params: unknown): Promise<number> {
return 0;
}
async checkTargetMember(_params: unknown): Promise<boolean> {
return false;
}
async getViewableChannels(_params: unknown): Promise<Array<ChannelID>> {
return [];
}
async getCategoryChannelCount(_params: unknown): Promise<number> {
return 0;
}
async getMembersWithRole(_params: unknown): Promise<Array<UserID>> {
return [];
}
async getGuildData(_params: unknown): Promise<GuildResponse> {
throw new Error('Not implemented');
}
async getGuildMember(_params: unknown): Promise<{success: boolean; memberData?: GuildMemberResponse}> {
return {success: false};
}
async hasGuildMember(_params: unknown): Promise<boolean> {
return false;
}
async listGuildMembers(_params: unknown): Promise<{members: Array<GuildMemberResponse>; total: number}> {
return {members: [], total: 0};
}
async listGuildMembersCursor(_params: unknown): Promise<{members: Array<GuildMemberResponse>; total: number}> {
return {members: [], total: 0};
}
async checkPermission(_params: unknown): Promise<boolean> {
return false;
}
async getVanityUrlChannel(_guildId: GuildID): Promise<ChannelID | null> {
return null;
}
async getFirstViewableTextChannel(_guildId: GuildID): Promise<ChannelID | null> {
return null;
}
async dispatchPresence(_params: unknown): Promise<void> {}
async invalidatePushBadgeCount(_params: unknown): Promise<void> {}
async joinGuild(_params: unknown): Promise<void> {}
async leaveGuild(_params: unknown): Promise<void> {}
async terminateSession(_params: unknown): Promise<void> {}
async terminateAllSessionsForUser(_params: unknown): Promise<void> {}
async disconnectAllVoiceUsersInChannel(_params: unknown): Promise<{success: boolean; disconnectedCount: number}> {
return {success: true, disconnectedCount: 0};
}
async getVoiceState(_params: unknown): Promise<{channel_id: string | null} | null> {
return null;
}
async hasActivePresence(_userId: UserID): Promise<boolean> {
return false;
}
async addTemporaryGuild(_params: unknown): Promise<void> {}
async removeTemporaryGuild(_params: unknown): Promise<void> {}
async syncGroupDmRecipients(_params: unknown): Promise<void> {}
async switchVoiceRegion(_params: unknown): Promise<void> {}
async getCall(_channelId: ChannelID): Promise<CallData | null> {
return null;
}
async createCall(
_channelId: ChannelID,
_messageId: string,
_region: string,
_ringing: Array<string>,
_recipients: Array<string>,
): Promise<CallData> {
throw new Error('Not implemented');
}
async updateCallRegion(_channelId: ChannelID, _region: string | null): Promise<boolean> {
return false;
}
async ringCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
return false;
}
async stopRingingCallRecipients(_channelId: ChannelID, _recipients: Array<string>): Promise<boolean> {
return false;
}
async deleteCall(_channelId: ChannelID): Promise<boolean> {
return false;
}
async getDiscoveryOnlineCounts(_guildIds: Array<GuildID>): Promise<Map<GuildID, number>> {
return new Map();
}
async getNodeStats(): Promise<{
status: string;
sessions: number;
guilds: number;
presences: number;
calls: number;
memory: {total: string; processes: string; system: string};
process_count: number;
process_limit: number;
uptime_seconds: number;
}> {
return {
status: 'ok',
sessions: 0,
guilds: 0,
presences: 0,
calls: 0,
memory: {total: '0', processes: '0', system: '0'},
process_count: 0,
process_limit: 0,
uptime_seconds: 0,
};
}
reset(): void {
this.confirmVoiceConnectionSpy.mockClear();
this.disconnectVoiceUserIfInChannelSpy.mockClear();
this.getVoiceStatesForChannelSpy.mockClear();
this.getPendingJoinsForChannelSpy.mockClear();
this.disconnectVoiceUserSpy.mockClear();
this.moveMemberSpy.mockClear();
this.updateMemberVoiceSpy.mockClear();
this.config = {};
}
}

View File

@@ -0,0 +1,922 @@
/*
* 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 {IKVPipeline, IKVProvider, IKVSubscription} from '@fluxer/kv_client/src/IKVProvider';
import {vi} from 'vitest';
type MessageCallback = (channel: string, message: string) => void;
type ErrorCallback = (error: Error) => void;
export interface MockKVSubscriptionOptions {
shouldFailConnect?: boolean;
shouldFailSubscribe?: boolean;
}
export class MockKVSubscription implements IKVSubscription {
private messageCallbacks: Array<MessageCallback> = [];
private errorCallbacks: Array<ErrorCallback> = [];
private options: MockKVSubscriptionOptions;
connectCalled = false;
subscribedChannels: Array<string> = [];
quitCalled = false;
removeAllListenersCalled = false;
constructor(options: MockKVSubscriptionOptions = {}) {
this.options = options;
}
async connect(): Promise<void> {
if (this.options.shouldFailConnect) {
throw new Error('Mock connection failure');
}
this.connectCalled = true;
}
on(event: 'message', callback: MessageCallback): void;
on(event: 'error', callback: ErrorCallback): void;
on(event: 'message' | 'error', callback: MessageCallback | ErrorCallback): void {
if (event === 'message') {
this.messageCallbacks.push(callback as MessageCallback);
} else if (event === 'error') {
this.errorCallbacks.push(callback as ErrorCallback);
}
}
async subscribe(...channels: Array<string>): Promise<void> {
if (this.options.shouldFailSubscribe) {
throw new Error('Mock subscribe failure');
}
this.subscribedChannels.push(...channels);
}
async unsubscribe(..._channels: Array<string>): Promise<void> {}
async quit(): Promise<void> {
this.quitCalled = true;
}
async disconnect(): Promise<void> {
this.quitCalled = true;
}
removeAllListeners(event?: 'message' | 'error'): void {
this.removeAllListenersCalled = true;
if (event === 'message') {
this.messageCallbacks = [];
} else if (event === 'error') {
this.errorCallbacks = [];
} else {
this.messageCallbacks = [];
this.errorCallbacks = [];
}
}
simulateMessage(channel: string, message: string): void {
for (const callback of this.messageCallbacks) {
callback(channel, message);
}
}
simulateError(error: Error): void {
for (const callback of this.errorCallbacks) {
callback(error);
}
}
reset(): void {
this.messageCallbacks = [];
this.errorCallbacks = [];
this.connectCalled = false;
this.subscribedChannels = [];
this.quitCalled = false;
this.removeAllListenersCalled = false;
}
}
export interface MockKVProviderOptions {
subscriptionOptions?: MockKVSubscriptionOptions;
}
export class MockKVProvider implements IKVProvider {
rpushCalls: Array<{key: string; values: Array<string>}> = [];
private subscription: MockKVSubscription;
private readonly stringStore = new Map<string, string>();
private readonly setStore = new Map<string, Set<string>>();
private readonly zsetStore = new Map<string, Map<string, number>>();
private readonly listStore = new Map<string, Array<string>>();
private readonly hashStore = new Map<string, Map<string, string>>();
private readonly expiries = new Map<string, number>();
readonly getSpy = vi.fn();
readonly setSpy = vi.fn();
readonly setexSpy = vi.fn();
readonly setnxSpy = vi.fn();
readonly mgetSpy = vi.fn();
readonly msetSpy = vi.fn();
readonly delSpy = vi.fn();
readonly existsSpy = vi.fn();
readonly expireSpy = vi.fn();
readonly ttlSpy = vi.fn();
readonly incrSpy = vi.fn();
readonly getexSpy = vi.fn();
readonly getdelSpy = vi.fn();
readonly saddSpy = vi.fn();
readonly sremSpy = vi.fn();
readonly smembersSpy = vi.fn();
readonly sismemberSpy = vi.fn();
readonly scardSpy = vi.fn();
readonly spopSpy = vi.fn();
readonly zaddSpy = vi.fn();
readonly zremSpy = vi.fn();
readonly zcardSpy = vi.fn();
readonly zrangebyscoreSpy = vi.fn();
readonly rpushSpy = vi.fn();
readonly lpopSpy = vi.fn();
readonly llenSpy = vi.fn();
readonly hsetSpy = vi.fn();
readonly hdelSpy = vi.fn();
readonly hgetSpy = vi.fn();
readonly hgetallSpy = vi.fn();
readonly publishSpy = vi.fn();
readonly releaseLockSpy = vi.fn();
readonly renewSnowflakeNodeSpy = vi.fn();
readonly tryConsumeTokensSpy = vi.fn();
readonly scheduleBulkDeletionSpy = vi.fn();
readonly removeBulkDeletionSpy = vi.fn();
readonly scanSpy = vi.fn();
readonly healthSpy = vi.fn();
constructor(options: MockKVProviderOptions = {}) {
this.subscription = new MockKVSubscription(options.subscriptionOptions);
}
async get(key: string): Promise<string | null> {
this.getSpy(key);
this.evictIfExpired(key);
return this.stringStore.get(key) ?? null;
}
async set(key: string, value: string, ...args: Array<string | number>): Promise<string | null> {
this.setSpy(key, value, ...args);
const useNx = args.includes('NX');
if (useNx && this.keyExists(key)) {
return null;
}
this.ensureType(key, 'string');
this.stringStore.set(key, value);
this.setExpiryFromArgs(key, args);
return 'OK';
}
async setex(key: string, ttlSeconds: number, value: string): Promise<void> {
this.setexSpy(key, ttlSeconds, value);
this.ensureType(key, 'string');
this.stringStore.set(key, value);
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
}
async setnx(key: string, value: string, ttlSeconds?: number): Promise<boolean> {
this.setnxSpy(key, value, ttlSeconds);
if (this.keyExists(key)) {
return false;
}
this.ensureType(key, 'string');
this.stringStore.set(key, value);
if (ttlSeconds !== undefined) {
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
} else {
this.expiries.delete(key);
}
return true;
}
async mget(...keys: Array<string>): Promise<Array<string | null>> {
this.mgetSpy(...keys);
return keys.map((key) => {
this.evictIfExpired(key);
return this.stringStore.get(key) ?? null;
});
}
async mset(...args: Array<string>): Promise<void> {
this.msetSpy(...args);
for (let i = 0; i < args.length; i += 2) {
const key = args[i];
const value = args[i + 1];
if (value === undefined) {
continue;
}
this.ensureType(key, 'string');
this.stringStore.set(key, value);
this.expiries.delete(key);
}
}
async del(...keys: Array<string>): Promise<number> {
this.delSpy(...keys);
let deleted = 0;
for (const key of keys) {
if (this.deleteKey(key)) {
deleted++;
}
}
return deleted;
}
async exists(key: string): Promise<number> {
this.existsSpy(key);
return this.keyExists(key) ? 1 : 0;
}
async expire(key: string, ttlSeconds: number): Promise<number> {
this.expireSpy(key, ttlSeconds);
if (!this.keyExists(key)) {
return 0;
}
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
return 1;
}
async ttl(key: string): Promise<number> {
this.ttlSpy(key);
this.evictIfExpired(key);
if (!this.keyExists(key)) {
return -2;
}
const expiry = this.expiries.get(key);
if (expiry === undefined) {
return -1;
}
const remainingSeconds = Math.floor((expiry - Date.now()) / 1000);
if (remainingSeconds < 0) {
this.deleteKey(key);
return -2;
}
return remainingSeconds;
}
async incr(key: string): Promise<number> {
this.incrSpy(key);
this.evictIfExpired(key);
const value = Number.parseInt(this.stringStore.get(key) ?? '0', 10) + 1;
this.ensureType(key, 'string');
this.stringStore.set(key, String(value));
return value;
}
async getex(key: string, ttlSeconds: number): Promise<string | null> {
this.getexSpy(key, ttlSeconds);
this.evictIfExpired(key);
const value = this.stringStore.get(key) ?? null;
if (value !== null) {
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
}
return value;
}
async getdel(key: string): Promise<string | null> {
this.getdelSpy(key);
this.evictIfExpired(key);
const value = this.stringStore.get(key) ?? null;
this.deleteKey(key);
return value;
}
async sadd(key: string, ...members: Array<string>): Promise<number> {
this.saddSpy(key, ...members);
this.evictIfExpired(key);
this.ensureType(key, 'set');
const membersSet = this.setStore.get(key)!;
let added = 0;
for (const member of members) {
if (!membersSet.has(member)) {
membersSet.add(member);
added++;
}
}
return added;
}
async srem(key: string, ...members: Array<string>): Promise<number> {
this.sremSpy(key, ...members);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
if (!membersSet) {
return 0;
}
let removed = 0;
for (const member of members) {
if (membersSet.delete(member)) {
removed++;
}
}
return removed;
}
async smembers(key: string): Promise<Array<string>> {
this.smembersSpy(key);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
return membersSet ? Array.from(membersSet) : [];
}
async sismember(key: string, member: string): Promise<number> {
this.sismemberSpy(key, member);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
return membersSet?.has(member) ? 1 : 0;
}
async scard(key: string): Promise<number> {
this.scardSpy(key);
this.evictIfExpired(key);
return this.setStore.get(key)?.size ?? 0;
}
async spop(key: string, count: number = 1): Promise<Array<string>> {
this.spopSpy(key, count);
this.evictIfExpired(key);
const membersSet = this.setStore.get(key);
if (!membersSet || count <= 0) {
return [];
}
const popped: Array<string> = [];
const iterator = membersSet.values();
for (let i = 0; i < count; i++) {
const next = iterator.next();
if (next.done) {
break;
}
membersSet.delete(next.value);
popped.push(next.value);
}
return popped;
}
async zadd(key: string, ...scoreMembers: Array<number | string>): Promise<number> {
this.zaddSpy(key, ...scoreMembers);
this.evictIfExpired(key);
this.ensureType(key, 'zset');
const members = this.zsetStore.get(key)!;
let added = 0;
for (let i = 0; i < scoreMembers.length; i += 2) {
const scoreInput = scoreMembers[i];
const memberInput = scoreMembers[i + 1];
if (memberInput === undefined) {
continue;
}
const score = Number(scoreInput);
const member = String(memberInput);
if (!Number.isFinite(score)) {
continue;
}
if (!members.has(member)) {
added++;
}
members.set(member, score);
}
return added;
}
async zrem(key: string, ...members: Array<string>): Promise<number> {
this.zremSpy(key, ...members);
this.evictIfExpired(key);
const zset = this.zsetStore.get(key);
if (!zset) {
return 0;
}
let removed = 0;
for (const member of members) {
if (zset.delete(member)) {
removed++;
}
}
return removed;
}
async zcard(key: string): Promise<number> {
this.zcardSpy(key);
this.evictIfExpired(key);
return this.zsetStore.get(key)?.size ?? 0;
}
async zrangebyscore(
key: string,
min: string | number,
max: string | number,
...args: Array<string | number>
): Promise<Array<string>> {
this.zrangebyscoreSpy(key, min, max, ...args);
this.evictIfExpired(key);
const zset = this.zsetStore.get(key);
if (!zset) {
return [];
}
let offset = 0;
let limit = Number.POSITIVE_INFINITY;
for (let i = 0; i < args.length; i++) {
if (args[i] === 'LIMIT') {
const parsedOffset = Number(args[i + 1]);
const parsedLimit = Number(args[i + 2]);
if (Number.isFinite(parsedOffset) && Number.isFinite(parsedLimit)) {
offset = parsedOffset;
limit = parsedLimit;
}
break;
}
}
const minBound = parseScoreBound(min, true);
const maxBound = parseScoreBound(max, false);
const sortedMembers = Array.from(zset.entries()).sort((a, b) => {
if (a[1] === b[1]) {
return a[0].localeCompare(b[0]);
}
return a[1] - b[1];
});
const filtered = sortedMembers
.filter((entry) => isScoreInRange(entry[1], minBound, maxBound))
.map((entry) => entry[0]);
return filtered.slice(offset, offset + limit);
}
async rpush(key: string, ...values: Array<string>): Promise<number> {
this.rpushSpy(key, ...values);
this.rpushCalls.push({key, values});
this.evictIfExpired(key);
this.ensureType(key, 'list');
const list = this.listStore.get(key)!;
list.push(...values);
return list.length;
}
async lpop(key: string, count: number = 1): Promise<Array<string>> {
this.lpopSpy(key, count);
this.evictIfExpired(key);
const list = this.listStore.get(key);
if (!list || count <= 0) {
return [];
}
const popped = list.splice(0, count);
if (list.length === 0) {
this.listStore.delete(key);
}
return popped;
}
async llen(key: string): Promise<number> {
this.llenSpy(key);
this.evictIfExpired(key);
return this.listStore.get(key)?.length ?? 0;
}
async hset(key: string, field: string, value: string): Promise<number> {
this.hsetSpy(key, field, value);
this.evictIfExpired(key);
this.ensureType(key, 'hash');
const hash = this.hashStore.get(key)!;
const isNew = !hash.has(field);
hash.set(field, value);
return isNew ? 1 : 0;
}
async hdel(key: string, ...fields: Array<string>): Promise<number> {
this.hdelSpy(key, ...fields);
this.evictIfExpired(key);
const hash = this.hashStore.get(key);
if (!hash) {
return 0;
}
let removed = 0;
for (const field of fields) {
if (hash.delete(field)) {
removed++;
}
}
return removed;
}
async hget(key: string, field: string): Promise<string | null> {
this.hgetSpy(key, field);
this.evictIfExpired(key);
return this.hashStore.get(key)?.get(field) ?? null;
}
async hgetall(key: string): Promise<Record<string, string>> {
this.hgetallSpy(key);
this.evictIfExpired(key);
const hash = this.hashStore.get(key);
if (!hash) {
return {};
}
return Object.fromEntries(hash.entries());
}
async publish(channel: string, message: string): Promise<number> {
this.publishSpy(channel, message);
this.subscription.simulateMessage(channel, message);
return 1;
}
duplicate(): IKVSubscription {
return this.subscription;
}
async releaseLock(key: string, token: string): Promise<boolean> {
this.releaseLockSpy(key, token);
this.evictIfExpired(key);
const currentToken = this.stringStore.get(key);
if (currentToken !== token) {
return false;
}
this.deleteKey(key);
return true;
}
async renewSnowflakeNode(key: string, instanceId: string, ttlSeconds: number): Promise<boolean> {
this.renewSnowflakeNodeSpy(key, instanceId, ttlSeconds);
this.evictIfExpired(key);
const currentValue = this.stringStore.get(key);
if (currentValue !== instanceId) {
return false;
}
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
return true;
}
async tryConsumeTokens(
key: string,
requested: number,
maxTokens: number,
refillRate: number,
refillIntervalMs: number,
): Promise<number> {
this.tryConsumeTokensSpy(key, requested, maxTokens, refillRate, refillIntervalMs);
this.evictIfExpired(key);
const now = Date.now();
let tokens = maxTokens;
let lastRefill = now;
const rawState = this.stringStore.get(key);
if (rawState !== undefined) {
try {
const parsed = JSON.parse(rawState) as {tokens?: number; lastRefill?: number};
tokens = parsed.tokens ?? maxTokens;
lastRefill = parsed.lastRefill ?? now;
} catch {}
}
const elapsed = now - lastRefill;
if (elapsed >= refillIntervalMs) {
const intervals = Math.floor(elapsed / refillIntervalMs);
const refilled = intervals * refillRate;
tokens = Math.min(maxTokens, tokens + refilled);
lastRefill = now;
}
let consumed = 0;
if (tokens >= requested) {
consumed = requested;
tokens -= requested;
} else if (tokens > 0) {
consumed = tokens;
tokens = 0;
}
this.ensureType(key, 'string');
this.stringStore.set(key, JSON.stringify({tokens, lastRefill}));
this.expiries.set(key, now + 3600 * 1000);
return consumed;
}
async scheduleBulkDeletion(queueKey: string, secondaryKey: string, score: number, value: string): Promise<void> {
this.scheduleBulkDeletionSpy(queueKey, secondaryKey, score, value);
await this.zadd(queueKey, score, value);
this.ensureType(secondaryKey, 'string');
this.stringStore.set(secondaryKey, value);
this.expiries.delete(secondaryKey);
}
async removeBulkDeletion(queueKey: string, secondaryKey: string): Promise<boolean> {
this.removeBulkDeletionSpy(queueKey, secondaryKey);
this.evictIfExpired(secondaryKey);
const value = this.stringStore.get(secondaryKey);
if (value === undefined) {
return false;
}
await this.zrem(queueKey, value);
this.deleteKey(secondaryKey);
return true;
}
async scan(pattern: string, count: number): Promise<Array<string>> {
const result = await this.scanSpy(pattern, count);
if (result !== undefined) {
return result;
}
const keys = this.getAllKeys().filter((key) => this.matchesPattern(key, pattern));
return keys.slice(0, count);
}
pipeline(): IKVPipeline {
return this.createPipeline();
}
multi(): IKVPipeline {
return this.createPipeline();
}
async health(): Promise<boolean> {
this.healthSpy();
return true;
}
getSubscription(): MockKVSubscription {
return this.subscription;
}
setSubscription(subscription: MockKVSubscription): void {
this.subscription = subscription;
}
private createPipeline(): IKVPipeline {
const operations: Array<() => Promise<unknown>> = [];
const pipeline: IKVPipeline = {
get: (key: string) => {
operations.push(async () => await this.get(key));
return pipeline;
},
set: (key: string, value: string) => {
operations.push(async () => await this.set(key, value));
return pipeline;
},
setex: (key: string, ttlSeconds: number, value: string) => {
operations.push(async () => await this.setex(key, ttlSeconds, value));
return pipeline;
},
del: (key: string) => {
operations.push(async () => await this.del(key));
return pipeline;
},
expire: (key: string, ttlSeconds: number) => {
operations.push(async () => await this.expire(key, ttlSeconds));
return pipeline;
},
sadd: (key: string, ...members: Array<string>) => {
operations.push(async () => await this.sadd(key, ...members));
return pipeline;
},
srem: (key: string, ...members: Array<string>) => {
operations.push(async () => await this.srem(key, ...members));
return pipeline;
},
zadd: (key: string, score: number, value: string) => {
operations.push(async () => await this.zadd(key, score, value));
return pipeline;
},
zrem: (key: string, ...members: Array<string>) => {
operations.push(async () => await this.zrem(key, ...members));
return pipeline;
},
mset: (...args: Array<string>) => {
operations.push(async () => await this.mset(...args));
return pipeline;
},
exec: async () => {
const results: Array<[Error | null, unknown]> = [];
for (const operation of operations) {
try {
const value = await operation();
results.push([null, value]);
} catch (error) {
results.push([error as Error, null]);
}
}
return results;
},
};
return pipeline;
}
private keyExists(key: string): boolean {
this.evictIfExpired(key);
return (
this.stringStore.has(key) ||
this.setStore.has(key) ||
this.zsetStore.has(key) ||
this.listStore.has(key) ||
this.hashStore.has(key)
);
}
private evictIfExpired(key: string): void {
const expiry = this.expiries.get(key);
if (expiry === undefined) {
return;
}
if (expiry <= Date.now()) {
this.deleteKey(key);
}
}
private ensureType(key: string, type: 'string' | 'set' | 'zset' | 'list' | 'hash'): void {
this.evictIfExpired(key);
if (type !== 'string') {
this.stringStore.delete(key);
}
if (type !== 'set') {
this.setStore.delete(key);
}
if (type !== 'zset') {
this.zsetStore.delete(key);
}
if (type !== 'list') {
this.listStore.delete(key);
}
if (type !== 'hash') {
this.hashStore.delete(key);
}
if (type === 'set' && !this.setStore.has(key)) {
this.setStore.set(key, new Set());
}
if (type === 'zset' && !this.zsetStore.has(key)) {
this.zsetStore.set(key, new Map());
}
if (type === 'list' && !this.listStore.has(key)) {
this.listStore.set(key, []);
}
if (type === 'hash' && !this.hashStore.has(key)) {
this.hashStore.set(key, new Map());
}
}
private deleteKey(key: string): boolean {
let deleted = false;
if (this.stringStore.delete(key)) {
deleted = true;
}
if (this.setStore.delete(key)) {
deleted = true;
}
if (this.zsetStore.delete(key)) {
deleted = true;
}
if (this.listStore.delete(key)) {
deleted = true;
}
if (this.hashStore.delete(key)) {
deleted = true;
}
if (this.expiries.delete(key)) {
deleted = true;
}
return deleted;
}
private setExpiryFromArgs(key: string, args: Array<string | number>): void {
const exIndex = args.indexOf('EX');
if (exIndex !== -1 && typeof args[exIndex + 1] === 'number') {
const ttlSeconds = args[exIndex + 1] as number;
this.expiries.set(key, Date.now() + ttlSeconds * 1000);
return;
}
this.expiries.delete(key);
}
private getAllKeys(): Array<string> {
for (const key of this.expiries.keys()) {
this.evictIfExpired(key);
}
const keys = new Set<string>();
for (const key of this.stringStore.keys()) keys.add(key);
for (const key of this.setStore.keys()) keys.add(key);
for (const key of this.zsetStore.keys()) keys.add(key);
for (const key of this.listStore.keys()) keys.add(key);
for (const key of this.hashStore.keys()) keys.add(key);
return Array.from(keys);
}
private matchesPattern(value: string, pattern: string): boolean {
if (pattern === '*') {
return true;
}
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
const regex = new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
return regex.test(value);
}
reset(): void {
this.rpushCalls = [];
this.stringStore.clear();
this.setStore.clear();
this.zsetStore.clear();
this.listStore.clear();
this.hashStore.clear();
this.expiries.clear();
this.subscription.reset();
this.getSpy.mockClear();
this.setSpy.mockClear();
this.setexSpy.mockClear();
this.setnxSpy.mockClear();
this.mgetSpy.mockClear();
this.msetSpy.mockClear();
this.delSpy.mockClear();
this.existsSpy.mockClear();
this.expireSpy.mockClear();
this.ttlSpy.mockClear();
this.incrSpy.mockClear();
this.getexSpy.mockClear();
this.getdelSpy.mockClear();
this.saddSpy.mockClear();
this.sremSpy.mockClear();
this.smembersSpy.mockClear();
this.sismemberSpy.mockClear();
this.scardSpy.mockClear();
this.spopSpy.mockClear();
this.zaddSpy.mockClear();
this.zremSpy.mockClear();
this.zcardSpy.mockClear();
this.zrangebyscoreSpy.mockClear();
this.rpushSpy.mockClear();
this.lpopSpy.mockClear();
this.llenSpy.mockClear();
this.hsetSpy.mockClear();
this.hdelSpy.mockClear();
this.hgetSpy.mockClear();
this.hgetallSpy.mockClear();
this.publishSpy.mockClear();
this.releaseLockSpy.mockClear();
this.renewSnowflakeNodeSpy.mockClear();
this.tryConsumeTokensSpy.mockClear();
this.scheduleBulkDeletionSpy.mockClear();
this.removeBulkDeletionSpy.mockClear();
this.scanSpy.mockClear();
this.healthSpy.mockClear();
}
}
interface ScoreBound {
value: number;
exclusive: boolean;
}
function parseScoreBound(bound: string | number, isMin: boolean): ScoreBound {
if (typeof bound === 'number') {
return {value: bound, exclusive: false};
}
if (bound === '-inf') {
return {value: Number.NEGATIVE_INFINITY, exclusive: false};
}
if (bound === '+inf') {
return {value: Number.POSITIVE_INFINITY, exclusive: false};
}
if (bound.startsWith('(')) {
const value = Number(bound.slice(1));
return {
value: Number.isFinite(value) ? value : isMin ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY,
exclusive: true,
};
}
const value = Number(bound);
return {
value: Number.isFinite(value) ? value : isMin ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY,
exclusive: false,
};
}
function isScoreInRange(score: number, min: ScoreBound, max: ScoreBound): boolean {
const minOk = min.exclusive ? score > min.value : score >= min.value;
const maxOk = max.exclusive ? score < max.value : score <= max.value;
return minOk && maxOk;
}

View File

@@ -0,0 +1,141 @@
/*
* 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 {ChannelID, GuildID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {ILiveKitService, ListParticipantsResult} from '@fluxer/api/src/infrastructure/ILiveKitService';
import type {VoiceRegionMetadata, VoiceServerRecord} from '@fluxer/api/src/voice/VoiceModel';
import {vi} from 'vitest';
export interface MockLiveKitServiceConfig {
disconnectParticipantThrows?: Error;
listParticipantsResult?: ListParticipantsResult;
regionMetadata?: Array<VoiceRegionMetadata>;
defaultRegionId?: string | null;
}
export class MockLiveKitService implements ILiveKitService {
readonly createTokenSpy = vi.fn();
readonly updateParticipantSpy = vi.fn();
readonly updateParticipantPermissionsSpy = vi.fn();
readonly disconnectParticipantSpy = vi.fn();
readonly listParticipantsSpy = vi.fn();
private config: MockLiveKitServiceConfig;
constructor(config: MockLiveKitServiceConfig = {}) {
this.config = config;
}
configure(config: MockLiveKitServiceConfig): void {
this.config = {...this.config, ...config};
}
async createToken(params: {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
tokenNonce: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
canSpeak?: boolean;
canStream?: boolean;
canVideo?: boolean;
}): Promise<{token: string; endpoint: string}> {
this.createTokenSpy(params);
return {
token: `token-${params.connectionId}`,
endpoint: 'wss://livekit.example.com',
};
}
async updateParticipant(params: {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
mute?: boolean;
deaf?: boolean;
}): Promise<void> {
this.updateParticipantSpy(params);
}
async updateParticipantPermissions(params: {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
canSpeak: boolean;
canStream: boolean;
canVideo: boolean;
}): Promise<void> {
this.updateParticipantPermissionsSpy(params);
}
async disconnectParticipant(params: {
userId: UserID;
guildId?: GuildID;
channelId: ChannelID;
connectionId: string;
regionId: string;
serverId: string;
}): Promise<void> {
this.disconnectParticipantSpy(params);
if (this.config.disconnectParticipantThrows) {
throw this.config.disconnectParticipantThrows;
}
}
async listParticipants(params: {
guildId?: GuildID;
channelId: ChannelID;
regionId: string;
serverId: string;
}): Promise<ListParticipantsResult> {
this.listParticipantsSpy(params);
return this.config.listParticipantsResult ?? {status: 'ok', participants: []};
}
getDefaultRegionId(): string | null {
return this.config.defaultRegionId ?? null;
}
getRegionMetadata(): Array<VoiceRegionMetadata> {
return this.config.regionMetadata ?? [];
}
getServer(_regionId: string, _serverId: string): VoiceServerRecord | null {
return null;
}
reset(): void {
this.createTokenSpy.mockClear();
this.updateParticipantSpy.mockClear();
this.updateParticipantPermissionsSpy.mockClear();
this.disconnectParticipantSpy.mockClear();
this.listParticipantsSpy.mockClear();
this.config = {};
}
}

View File

@@ -0,0 +1,110 @@
/*
* 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 {
IMediaService,
type MediaProxyFrameRequest,
type MediaProxyFrameResponse,
type MediaProxyMetadataRequest,
type MediaProxyMetadataResponse,
} from '@fluxer/api/src/infrastructure/IMediaService';
import {vi} from 'vitest';
export interface MockMediaServiceConfig {
shouldFailMetadata?: boolean;
shouldFailFrameExtraction?: boolean;
returnNullMetadata?: boolean;
returnEmptyFrames?: boolean;
metadata?: MediaProxyMetadataResponse;
frames?: Array<{timestamp: number; mime_type: string; base64: string}>;
}
export class MockMediaService extends IMediaService {
readonly getMetadataSpy = vi.fn();
readonly extractFramesSpy = vi.fn();
readonly getExternalMediaProxyURLSpy = vi.fn();
readonly getThumbnailSpy = vi.fn();
private config: MockMediaServiceConfig;
private readonly defaultMetadata: MediaProxyMetadataResponse = {
format: 'png',
content_type: 'image/png',
content_hash: 'abc123',
size: 1024,
width: 100,
height: 100,
nsfw: false,
base64: Buffer.from('mock-image-data').toString('base64'),
};
private readonly defaultFrames = [
{timestamp: 0, mime_type: 'image/jpeg', base64: Buffer.from('frame-0').toString('base64')},
{timestamp: 1000, mime_type: 'image/jpeg', base64: Buffer.from('frame-1').toString('base64')},
];
constructor(config: MockMediaServiceConfig = {}) {
super();
this.config = config;
}
configure(config: MockMediaServiceConfig): void {
this.config = {...this.config, ...config};
}
async getMetadata(request: MediaProxyMetadataRequest): Promise<MediaProxyMetadataResponse | null> {
this.getMetadataSpy(request);
if (this.config.shouldFailMetadata) {
throw new Error('Mock metadata fetch failure');
}
if (this.config.returnNullMetadata) {
return null;
}
return this.config.metadata ?? this.defaultMetadata;
}
async extractFrames(request: MediaProxyFrameRequest): Promise<MediaProxyFrameResponse> {
this.extractFramesSpy(request);
if (this.config.shouldFailFrameExtraction) {
throw new Error('Mock frame extraction failure');
}
if (this.config.returnEmptyFrames) {
return {frames: []};
}
return {frames: this.config.frames ?? this.defaultFrames};
}
getExternalMediaProxyURL(url: string): string {
this.getExternalMediaProxyURLSpy(url);
return url;
}
async getThumbnail(uploadFilename: string): Promise<Buffer | null> {
this.getThumbnailSpy(uploadFilename);
return null;
}
reset(): void {
this.config = {};
this.getMetadataSpy.mockClear();
this.extractFramesSpy.mockClear();
this.getExternalMediaProxyURLSpy.mockClear();
this.getThumbnailSpy.mockClear();
}
}

View File

@@ -0,0 +1,126 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {randomUUID} from 'node:crypto';
import {vi} from 'vitest';
export interface MockNcmecReporterConfig {
reportId?: string;
fileId?: string;
md5?: string;
submitShouldFail?: boolean;
uploadShouldFail?: boolean;
fileInfoShouldFail?: boolean;
finishShouldFail?: boolean;
retractShouldFail?: boolean;
}
export class MockNcmecReporter {
readonly submitReportSpy = vi.fn();
readonly uploadEvidenceSpy = vi.fn();
readonly submitFileDetailsSpy = vi.fn();
readonly finishSpy = vi.fn();
readonly retractSpy = vi.fn();
private config: MockNcmecReporterConfig;
private createdReports: Array<{reportId: string; payload: string}> = [];
private uploadedFiles: Array<{reportId: string; filename: string; size: number}> = [];
constructor(config: MockNcmecReporterConfig = {}) {
this.config = config;
}
configure(config: MockNcmecReporterConfig): void {
this.config = {...this.config, ...config};
}
async submitReport(reportXml: string): Promise<string> {
this.submitReportSpy(reportXml);
if (this.config.submitShouldFail) {
throw new Error('Mock NCMEC report submission failed');
}
const reportId = this.config.reportId ?? randomUUID();
this.createdReports.push({reportId, payload: reportXml});
return reportId;
}
async uploadEvidence(
reportId: string,
buffer: Uint8Array,
filename: string,
): Promise<{fileId: string; md5: string | null}> {
this.uploadEvidenceSpy(reportId, filename, buffer);
if (this.config.uploadShouldFail) {
throw new Error('Mock NCMEC evidence upload failed');
}
const fileId = this.config.fileId ?? randomUUID();
this.uploadedFiles.push({reportId, filename, size: buffer.byteLength});
return {fileId, md5: this.config.md5 ?? null};
}
async submitFileDetails(fileDetailsXml: string): Promise<void> {
this.submitFileDetailsSpy(fileDetailsXml);
if (this.config.fileInfoShouldFail) {
throw new Error('Mock NCMEC file details submission failed');
}
}
async finish(reportId: string): Promise<{reportId: string; fileIds: Array<string>}> {
this.finishSpy(reportId);
if (this.config.finishShouldFail) {
throw new Error('Mock NCMEC finish failed');
}
const fileIds = this.config.fileId ? [this.config.fileId] : [];
return {reportId, fileIds};
}
async retract(reportId: string): Promise<void> {
this.retractSpy(reportId);
if (this.config.retractShouldFail) {
throw new Error('Mock NCMEC retract failed');
}
}
getReports(): Array<{reportId: string; payload: string}> {
return [...this.createdReports];
}
getUploads(): Array<{reportId: string; filename: string; size: number}> {
return [...this.uploadedFiles];
}
reset(): void {
this.config = {};
this.createdReports = [];
this.uploadedFiles = [];
this.submitReportSpy.mockClear();
this.uploadEvidenceSpy.mockClear();
this.submitFileDetailsSpy.mockClear();
this.finishSpy.mockClear();
this.retractSpy.mockClear();
}
}

View File

@@ -0,0 +1,61 @@
/*
* 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 type {FrameSample} from '@fluxer/api/src/csam/CsamTypes';
import type {IPhotoDnaHashClient} from '@fluxer/api/src/csam/PhotoDnaHashClient';
import {vi} from 'vitest';
export interface MockPhotoDnaHashClientConfig {
shouldFail?: boolean;
returnEmpty?: boolean;
hashes?: Array<string>;
}
export class MockPhotoDnaHashClient implements IPhotoDnaHashClient {
readonly hashFramesSpy = vi.fn();
private config: MockPhotoDnaHashClientConfig;
constructor(config: MockPhotoDnaHashClientConfig = {}) {
this.config = config;
}
configure(config: MockPhotoDnaHashClientConfig): void {
this.config = {...this.config, ...config};
}
async hashFrames(frames: Array<FrameSample>): Promise<Array<string>> {
this.hashFramesSpy(frames);
if (this.config.shouldFail) {
throw new Error('Mock hash client failure');
}
if (this.config.returnEmpty) {
return [];
}
if (this.config.hashes) {
return this.config.hashes;
}
return frames.map((_, index) => `mock-hash-${randomUUID()}-${index}`);
}
reset(): void {
this.config = {};
this.hashFramesSpy.mockClear();
}
}

View File

@@ -0,0 +1,132 @@
/*
* 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 {ISnowflakeService} from '@fluxer/api/src/infrastructure/ISnowflakeService';
import {vi} from 'vitest';
export interface MockSnowflakeServiceConfig {
initialCounter?: bigint;
nodeId?: number;
shouldFailInitialize?: boolean;
shouldFailGenerate?: boolean;
}
export class MockSnowflakeService implements ISnowflakeService {
private counter: bigint;
private nodeId: number | null;
private initialized: boolean = false;
private config: MockSnowflakeServiceConfig;
private generatedIds: Array<bigint> = [];
readonly initializeSpy = vi.fn();
readonly reinitializeSpy = vi.fn();
readonly shutdownSpy = vi.fn();
readonly generateSpy = vi.fn();
readonly getNodeIdForTestingSpy = vi.fn();
readonly renewNodeIdForTestingSpy = vi.fn();
constructor(config: MockSnowflakeServiceConfig = {}) {
this.config = config;
this.counter = config.initialCounter ?? 1n;
this.nodeId = config.nodeId ?? 0;
}
configure(config: MockSnowflakeServiceConfig): void {
this.config = {...this.config, ...config};
if (config.initialCounter !== undefined) {
this.counter = config.initialCounter;
}
if (config.nodeId !== undefined) {
this.nodeId = config.nodeId;
}
}
async initialize(): Promise<void> {
this.initializeSpy();
if (this.config.shouldFailInitialize) {
throw new Error('Mock snowflake initialization failure');
}
this.initialized = true;
}
async reinitialize(): Promise<void> {
this.reinitializeSpy();
this.initialized = false;
await this.initialize();
}
async shutdown(): Promise<void> {
this.shutdownSpy();
this.initialized = false;
this.nodeId = null;
}
async generate(): Promise<bigint> {
this.generateSpy();
if (this.config.shouldFailGenerate) {
throw new Error('Mock snowflake generation failure');
}
if (!this.initialized) {
throw new Error('SnowflakeService not initialized - call initialize() first');
}
const id = this.counter;
this.counter += 1n;
this.generatedIds.push(id);
return id;
}
getNodeIdForTesting(): number | null {
this.getNodeIdForTestingSpy();
return this.nodeId;
}
async renewNodeIdForTesting(): Promise<void> {
this.renewNodeIdForTestingSpy();
}
setCounter(value: bigint): void {
this.counter = value;
}
getCounter(): bigint {
return this.counter;
}
getGeneratedIds(): Array<bigint> {
return [...this.generatedIds];
}
isInitialized(): boolean {
return this.initialized;
}
reset(): void {
this.counter = this.config.initialCounter ?? 1n;
this.nodeId = this.config.nodeId ?? 0;
this.initialized = false;
this.generatedIds = [];
this.config = {};
this.initializeSpy.mockClear();
this.reinitializeSpy.mockClear();
this.shutdownSpy.mockClear();
this.generateSpy.mockClear();
this.getNodeIdForTestingSpy.mockClear();
this.renewNodeIdForTestingSpy.mockClear();
}
}

View File

@@ -0,0 +1,268 @@
/*
* 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 {Readable} from 'node:stream';
import {S3ServiceException} from '@aws-sdk/client-s3';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import {vi} from 'vitest';
export interface MockStorageServiceConfig {
fileData?: Uint8Array | null;
shouldFail?: boolean;
shouldFailRead?: boolean;
shouldFailUpload?: boolean;
shouldFailDelete?: boolean;
shouldFailCopy?: boolean;
}
export class MockStorageService implements IStorageService {
private objects: Map<string, {data: Uint8Array; contentType?: string}> = new Map();
private deletedObjects: Array<{bucket: string; key: string}> = [];
private copiedObjects: Array<{
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
}> = [];
readonly uploadObjectSpy = vi.fn();
readonly deleteObjectSpy = vi.fn();
readonly getObjectMetadataSpy = vi.fn();
readonly readObjectSpy = vi.fn();
readonly streamObjectSpy = vi.fn();
readonly writeObjectToDiskSpy = vi.fn();
readonly copyObjectSpy = vi.fn();
readonly copyObjectWithJpegProcessingSpy = vi.fn();
readonly moveObjectSpy = vi.fn();
readonly getPresignedDownloadURLSpy = vi.fn();
readonly purgeBucketSpy = vi.fn();
readonly uploadAvatarSpy = vi.fn();
readonly deleteAvatarSpy = vi.fn();
readonly listObjectsSpy = vi.fn();
readonly deleteObjectsSpy = vi.fn();
private config: MockStorageServiceConfig;
constructor(config: MockStorageServiceConfig = {}) {
this.config = config;
}
configure(config: MockStorageServiceConfig): void {
this.config = {...this.config, ...config};
}
async uploadObject(params: {
bucket: string;
key: string;
body: Uint8Array;
contentType?: string;
expiresAt?: Date;
}): Promise<void> {
this.uploadObjectSpy(params);
if (this.config.shouldFail || this.config.shouldFailUpload) {
throw new Error('Mock storage upload failure');
}
this.objects.set(params.key, {data: params.body, contentType: params.contentType});
}
async deleteObject(bucket: string, key: string): Promise<void> {
this.deleteObjectSpy(bucket, key);
if (this.config.shouldFail || this.config.shouldFailDelete) {
throw new Error('Mock storage delete failure');
}
this.deletedObjects.push({bucket, key});
this.objects.delete(key);
}
async getObjectMetadata(bucket: string, key: string): Promise<{contentLength: number; contentType: string} | null> {
this.getObjectMetadataSpy(bucket, key);
const obj = this.objects.get(key);
if (!obj) return null;
return {contentLength: obj.data.length, contentType: obj.contentType ?? 'application/octet-stream'};
}
async readObject(bucket: string, key: string): Promise<Uint8Array> {
this.readObjectSpy(bucket, key);
if (this.config.shouldFail || this.config.shouldFailRead) {
throw new Error('Mock storage read failure');
}
if (this.config.fileData !== undefined) {
if (this.config.fileData === null) {
const error = new S3ServiceException({
name: 'NoSuchKey',
$fault: 'client',
$metadata: {},
message: `The specified key does not exist: ${key}`,
});
throw error;
}
return this.config.fileData;
}
const obj = this.objects.get(key);
if (!obj) {
const error = new S3ServiceException({
name: 'NoSuchKey',
$fault: 'client',
$metadata: {},
message: `The specified key does not exist: ${key}`,
});
throw error;
}
return obj.data;
}
async streamObject(_params: {bucket: string; key: string; range?: string}): Promise<{
body: Readable;
contentLength: number;
contentRange?: string | null;
contentType?: string | null;
cacheControl?: string | null;
contentDisposition?: string | null;
expires?: Date | null;
etag?: string | null;
lastModified?: Date | null;
} | null> {
this.streamObjectSpy(_params);
return null;
}
async writeObjectToDisk(_bucket: string, _key: string, _filePath: string): Promise<void> {
this.writeObjectToDiskSpy(_bucket, _key, _filePath);
}
async copyObject(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
this.copyObjectSpy(params);
if (this.config.shouldFail || this.config.shouldFailCopy) {
throw new Error('Mock storage copy failure');
}
this.copiedObjects.push({
sourceBucket: params.sourceBucket,
sourceKey: params.sourceKey,
destinationBucket: params.destinationBucket,
destinationKey: params.destinationKey,
});
const sourceObj = this.objects.get(params.sourceKey);
if (sourceObj) {
this.objects.set(params.destinationKey, {
data: sourceObj.data,
contentType: params.newContentType ?? sourceObj.contentType,
});
}
}
async copyObjectWithJpegProcessing(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
contentType: string;
}): Promise<{width: number; height: number} | null> {
this.copyObjectWithJpegProcessingSpy(params);
await this.copyObject(params);
return {width: 100, height: 100};
}
async moveObject(params: {
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
newContentType?: string;
}): Promise<void> {
this.moveObjectSpy(params);
await this.copyObject(params);
await this.deleteObject(params.sourceBucket, params.sourceKey);
}
async getPresignedDownloadURL(_params: {bucket: string; key: string; expiresIn?: number}): Promise<string> {
this.getPresignedDownloadURLSpy(_params);
return 'https://presigned.url/test';
}
async purgeBucket(_bucket: string): Promise<void> {
this.purgeBucketSpy(_bucket);
}
async uploadAvatar(params: {prefix: string; key: string; body: Uint8Array}): Promise<void> {
this.uploadAvatarSpy(params);
await this.uploadObject({bucket: 'cdn', key: `${params.prefix}/${params.key}`, body: params.body});
}
async deleteAvatar(params: {prefix: string; key: string}): Promise<void> {
this.deleteAvatarSpy(params);
await this.deleteObject('cdn', `${params.prefix}/${params.key}`);
}
async listObjects(_params: {
bucket: string;
prefix: string;
}): Promise<ReadonlyArray<{key: string; lastModified?: Date}>> {
this.listObjectsSpy(_params);
return [];
}
async deleteObjects(_params: {bucket: string; objects: ReadonlyArray<{Key: string}>}): Promise<void> {
this.deleteObjectsSpy(_params);
}
getDeletedObjects(): Array<{bucket: string; key: string}> {
return [...this.deletedObjects];
}
getCopiedObjects(): Array<{
sourceBucket: string;
sourceKey: string;
destinationBucket: string;
destinationKey: string;
}> {
return [...this.copiedObjects];
}
hasObject(_bucket: string, key: string): boolean {
return this.objects.has(key);
}
reset(): void {
this.objects.clear();
this.deletedObjects = [];
this.copiedObjects = [];
this.config = {};
this.uploadObjectSpy.mockClear();
this.deleteObjectSpy.mockClear();
this.getObjectMetadataSpy.mockClear();
this.readObjectSpy.mockClear();
this.streamObjectSpy.mockClear();
this.writeObjectToDiskSpy.mockClear();
this.copyObjectSpy.mockClear();
this.copyObjectWithJpegProcessingSpy.mockClear();
this.moveObjectSpy.mockClear();
this.getPresignedDownloadURLSpy.mockClear();
this.purgeBucketSpy.mockClear();
this.uploadAvatarSpy.mockClear();
this.deleteAvatarSpy.mockClear();
this.listObjectsSpy.mockClear();
this.deleteObjectsSpy.mockClear();
}
}

View File

@@ -0,0 +1,123 @@
/*
* 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 type {PhotoDnaMatchResult} from '@fluxer/api/src/csam/CsamTypes';
import type {ISynchronousCsamScanner} from '@fluxer/api/src/csam/ISynchronousCsamScanner';
import type {
ScanBase64Params,
ScanMediaParams,
SynchronousCsamScanResult,
} from '@fluxer/api/src/csam/SynchronousCsamScanner';
import {vi} from 'vitest';
export interface MockSynchronousCsamScannerConfig {
shouldMatch?: boolean;
matchResult?: PhotoDnaMatchResult;
omitMatchResult?: boolean;
shouldFail?: boolean;
}
export interface ScanCall {
readonly type: 'media' | 'base64';
readonly contentType: string;
}
function createDefaultMatchResult(): PhotoDnaMatchResult {
return {
isMatch: true,
trackingId: randomUUID(),
matchDetails: [
{
source: 'test-database',
violations: ['CSAM'],
matchDistance: 0.01,
matchId: randomUUID(),
},
],
timestamp: new Date().toISOString(),
};
}
export class MockSynchronousCsamScanner implements ISynchronousCsamScanner {
readonly scanMediaSpy = vi.fn();
readonly scanBase64Spy = vi.fn();
private config: MockSynchronousCsamScannerConfig;
private scanCalls: Array<ScanCall> = [];
constructor(config: MockSynchronousCsamScannerConfig = {}) {
this.config = config;
}
configure(config: MockSynchronousCsamScannerConfig): void {
this.config = {...this.config, ...config};
}
async scanMedia(params: ScanMediaParams): Promise<SynchronousCsamScanResult> {
this.scanMediaSpy(params);
this.scanCalls.push({type: 'media', contentType: params.contentType ?? 'unknown'});
if (this.config.shouldFail) {
throw new Error('Mock CSAM scan failure');
}
if (this.config.shouldMatch) {
if (this.config.omitMatchResult) {
return {isMatch: true};
}
return {
isMatch: true,
matchResult: this.config.matchResult ?? createDefaultMatchResult(),
};
}
return {isMatch: false};
}
async scanBase64(params: ScanBase64Params): Promise<SynchronousCsamScanResult> {
this.scanBase64Spy(params);
this.scanCalls.push({type: 'base64', contentType: params.mimeType});
if (this.config.shouldFail) {
throw new Error('Mock CSAM scan failure');
}
if (this.config.shouldMatch) {
if (this.config.omitMatchResult) {
return {isMatch: true};
}
return {
isMatch: true,
matchResult: this.config.matchResult ?? createDefaultMatchResult(),
};
}
return {isMatch: false};
}
getScanCalls(): Array<ScanCall> {
return [...this.scanCalls];
}
reset(): void {
this.config = {};
this.scanCalls = [];
this.scanMediaSpy.mockClear();
this.scanBase64Spy.mockClear();
}
}

View File

@@ -0,0 +1,99 @@
/*
* 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 type {IVirusScanService} from '@fluxer/virus_scan/src/IVirusScanService';
import type {VirusScanResult} from '@fluxer/virus_scan/src/VirusScanResult';
import {vi} from 'vitest';
export interface MockVirusScanServiceConfig {
shouldFailInitialize?: boolean;
shouldFailScan?: boolean;
isClean?: boolean;
threat?: string;
fileHash?: string;
isHashCached?: boolean;
}
export class MockVirusScanService implements IVirusScanService {
readonly initializeSpy = vi.fn();
readonly scanFileSpy = vi.fn();
readonly scanBufferSpy = vi.fn();
readonly isVirusHashCachedSpy = vi.fn();
readonly cacheVirusHashSpy = vi.fn();
private config: MockVirusScanServiceConfig;
constructor(config: MockVirusScanServiceConfig = {}) {
this.config = config;
}
configure(config: MockVirusScanServiceConfig): void {
this.config = {...this.config, ...config};
}
async initialize(): Promise<void> {
this.initializeSpy();
if (this.config.shouldFailInitialize) {
throw new Error('Mock virus scan initialization failure');
}
}
async scanFile(filePath: string): Promise<VirusScanResult> {
this.scanFileSpy(filePath);
if (this.config.shouldFailScan) {
throw new Error('Mock virus scan failure');
}
return {
isClean: this.config.isClean ?? true,
threat: this.config.threat,
fileHash: this.config.fileHash ?? randomUUID(),
};
}
async scanBuffer(buffer: Buffer, filename: string): Promise<VirusScanResult> {
this.scanBufferSpy(buffer, filename);
if (this.config.shouldFailScan) {
throw new Error('Mock virus scan failure');
}
return {
isClean: this.config.isClean ?? true,
threat: this.config.threat,
fileHash: this.config.fileHash ?? randomUUID(),
};
}
async isVirusHashCached(fileHash: string): Promise<boolean> {
this.isVirusHashCachedSpy(fileHash);
return this.config.isHashCached ?? false;
}
async cacheVirusHash(fileHash: string): Promise<void> {
this.cacheVirusHashSpy(fileHash);
}
reset(): void {
this.config = {};
this.initializeSpy.mockClear();
this.scanFileSpy.mockClear();
this.scanBufferSpy.mockClear();
this.isVirusHashCachedSpy.mockClear();
this.cacheVirusHashSpy.mockClear();
}
}

View File

@@ -0,0 +1,36 @@
/*
* 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 {ILogger} from '@fluxer/api/src/ILogger';
import type {LoggerInterface} from '@fluxer/logger/src/LoggerInterface';
function noop(): void {}
export class NoopLogger implements ILogger, LoggerInterface {
trace = noop;
debug = noop;
info = noop;
warn = noop;
error = noop;
fatal = noop;
child(): this {
return this;
}
}

View File

@@ -0,0 +1,138 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {HttpResponse, http} from 'msw';
export interface ArachnidShieldResponse {
classification: 'no-known-match' | 'csam' | 'harmful-abusive-material' | 'test';
sha256_hex: string;
sha1_base32: string;
match_type: 'exact' | 'near' | null;
size_bytes: number;
match_id?: string;
near_match_details?: Array<{
classification: string;
sha1_base32: string;
sha256_hex: string;
timestamp: number;
}>;
}
export interface ArachnidShieldMockConfig {
classification?: ArachnidShieldResponse['classification'];
sha256Hex?: string;
sha1Base32?: string;
matchType?: ArachnidShieldResponse['match_type'];
sizeBytes?: number;
matchId?: string;
nearMatchDetails?: ArachnidShieldResponse['near_match_details'];
}
export interface ArachnidShieldRequestCapture {
headers: Headers;
body: ArrayBuffer;
url: string;
}
export function createArachnidShieldHandler(
config: ArachnidShieldMockConfig = {},
requestCapture?: {current: ArachnidShieldRequestCapture | null},
) {
return http.post('https://shield.projectarachnid.com/v1/media', async ({request}) => {
if (requestCapture) {
requestCapture.current = {
headers: request.headers,
body: await request.clone().arrayBuffer(),
url: request.url,
};
}
const response: ArachnidShieldResponse = {
classification: config.classification ?? 'no-known-match',
sha256_hex: config.sha256Hex ?? 'abc123def456',
sha1_base32: config.sha1Base32 ?? 'test-sha1',
match_type: config.matchType ?? null,
size_bytes: config.sizeBytes ?? 1024,
};
if (config.matchId) {
response.match_id = config.matchId;
}
if (config.nearMatchDetails) {
response.near_match_details = config.nearMatchDetails;
}
return HttpResponse.json(response);
});
}
export function createArachnidShieldErrorHandler(status: number, body?: unknown, headers?: Record<string, string>) {
return http.post('https://shield.projectarachnid.com/v1/media', () => {
return new HttpResponse(body != null ? JSON.stringify(body) : null, {
status,
headers: {
'Content-Type': 'application/json',
...headers,
},
});
});
}
export function createArachnidShieldRateLimitHandler(resetSeconds = 1) {
return http.post('https://shield.projectarachnid.com/v1/media', () => {
return new HttpResponse(null, {
status: 429,
headers: {
ratelimit: `"burst";r=0;t=${resetSeconds}`,
},
});
});
}
export function createArachnidShieldSequenceHandler(
responses: Array<{
status?: number;
body?: ArachnidShieldResponse | unknown;
headers?: Record<string, string>;
}>,
) {
let callCount = 0;
return http.post('https://shield.projectarachnid.com/v1/media', () => {
const responseConfig = responses[callCount] ?? responses[responses.length - 1];
callCount++;
const status = responseConfig?.status ?? 200;
const body = responseConfig?.body;
const headers = responseConfig?.headers ?? {};
if (status >= 200 && status < 300 && body != null) {
return HttpResponse.json(body, {status, headers});
}
return new HttpResponse(body != null ? JSON.stringify(body) : null, {
status,
headers: {
'Content-Type': 'application/json',
...headers,
},
});
});
}

View File

@@ -0,0 +1,220 @@
/*
* 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 {
BlueskyPostEmbed,
BlueskyPostThread,
BlueskyProfile,
} from '@fluxer/api/src/unfurler/resolvers/bluesky/BlueskyTypes';
import {HttpResponse, http} from 'msw';
const API_BASES = ['https://api.bsky.app/xrpc', 'https://public.api.bsky.app/xrpc'];
export interface BlueskyApiMockConfig {
handles?: Map<string, string>;
posts?: Map<string, BlueskyPostThread>;
profiles?: Map<string, BlueskyProfile>;
profileDescriptions?: Map<string, string>;
error404Handles?: Set<string>;
error404Profiles?: Set<string>;
error500?: boolean;
}
export function createBlueskyApiHandlers(config: BlueskyApiMockConfig = {}) {
const handles = config.handles ?? new Map();
const posts = config.posts ?? new Map();
const profiles = config.profiles ?? new Map();
const profileDescriptions = config.profileDescriptions ?? new Map();
const error404Handles = config.error404Handles ?? new Set();
const error404Profiles = config.error404Profiles ?? new Set();
const error500 = config.error500 ?? false;
const handlers = [];
for (const API_BASE of API_BASES) {
handlers.push(
http.get(`${API_BASE}/com.atproto.identity.resolveHandle`, ({request}) => {
if (error500) {
return HttpResponse.json({error: 'Internal server error'}, {status: 500});
}
const url = new URL(request.url);
const handle = url.searchParams.get('handle');
if (!handle) {
return HttpResponse.json({error: 'Missing handle parameter'}, {status: 400});
}
if (error404Handles.has(handle)) {
return HttpResponse.json({error: 'Handle not found'}, {status: 404});
}
const did = handles.get(handle);
if (!did) {
return HttpResponse.json({error: 'Handle not found'}, {status: 404});
}
return HttpResponse.json({did});
}),
);
handlers.push(
http.get(`${API_BASE}/app.bsky.feed.getPostThread`, ({request}) => {
const url = new URL(request.url);
const uri = url.searchParams.get('uri');
if (!uri) {
return HttpResponse.json({error: 'Missing uri parameter'}, {status: 400});
}
const thread = posts.get(uri);
if (!thread) {
return HttpResponse.json({error: 'Post not found'}, {status: 404});
}
return HttpResponse.json(thread);
}),
);
handlers.push(
http.get(`${API_BASE}/app.bsky.actor.getProfile`, ({request}) => {
if (error500) {
return HttpResponse.json({error: 'Internal server error'}, {status: 500});
}
const url = new URL(request.url);
const actor = url.searchParams.get('actor');
if (!actor) {
return HttpResponse.json({error: 'Missing actor parameter'}, {status: 400});
}
if (error404Profiles.has(actor)) {
return HttpResponse.json({error: 'Profile not found'}, {status: 404});
}
let profile = profiles.get(actor);
if (!profile) {
const description = profileDescriptions.get(actor);
if (description) {
profile = {
did: actor,
handle: actor,
description,
};
} else {
return HttpResponse.json({error: 'Profile not found'}, {status: 404});
}
}
return HttpResponse.json(profile);
}),
);
}
handlers.push(
http.get('https://plc.directory/*', () => {
return HttpResponse.json({
service: [{type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://bsky.social'}],
});
}),
);
return handlers;
}
export function createBlueskyPost(options: {
uri: string;
did: string;
handle: string;
displayName?: string;
avatar?: string;
text: string;
createdAt?: string;
embed?: BlueskyPostEmbed;
replyCount?: number;
repostCount?: number;
likeCount?: number;
quoteCount?: number;
bookmarkCount?: number;
parent?: {
did: string;
handle: string;
displayName?: string;
uri: string;
text: string;
};
}): BlueskyPostThread {
const post: BlueskyPostThread['thread']['post'] = {
uri: options.uri,
author: {
did: options.did,
handle: options.handle,
displayName: options.displayName,
avatar: options.avatar,
},
record: {
text: options.text,
createdAt: options.createdAt ?? new Date().toISOString(),
reply: options.parent
? {
parent: {uri: options.parent.uri, cid: 'parent-cid'},
root: {uri: options.parent.uri, cid: 'root-cid'},
}
: undefined,
},
embed: options.embed,
indexedAt: options.createdAt ?? new Date().toISOString(),
replyCount: options.replyCount ?? 0,
repostCount: options.repostCount ?? 0,
likeCount: options.likeCount ?? 0,
quoteCount: options.quoteCount ?? 0,
bookmarkCount: options.bookmarkCount ?? 0,
};
const thread: BlueskyPostThread = {
thread: {
post,
parent: options.parent
? {
post: {
uri: options.parent.uri,
author: {
did: options.parent.did,
handle: options.parent.handle,
displayName: options.parent.displayName,
},
record: {
text: options.parent.text,
createdAt: new Date().toISOString(),
},
indexedAt: new Date().toISOString(),
replyCount: 0,
repostCount: 0,
likeCount: 0,
quoteCount: 0,
bookmarkCount: 0,
},
}
: undefined,
},
};
return thread;
}

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 {http, passthrough} from 'msw';
// Testcontainers talks to the local Docker Engine API via node http(s) calls.
// MSW intercepts those by default, so we explicitly passthrough Docker API
// requests while keeping strict unhandled-request errors for everything else.
const DOCKER_ENGINE_LOCAL_URL_PATTERN =
/^http:\/\/localhost\/(v[0-9.]+\/)?(images|containers|networks|volumes|exec|build|auth|events|info|version|_ping)(\/.*)?$/;
export function createDockerEnginePassthroughHandlers() {
return [http.all(DOCKER_ENGINE_LOCAL_URL_PATTERN, () => passthrough())];
}

View File

@@ -0,0 +1,141 @@
/*
* 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/api/src/Config';
import {HttpResponse, http} from 'msw';
interface GatewayRpcRequest {
method: string;
params: Record<string, unknown>;
}
interface GatewayRpcResponse {
result?: unknown;
error?: string;
}
export interface GatewayRpcRequestCapture {
method: string;
params: Record<string, unknown>;
authorization?: string;
}
export type GatewayRpcMockResponses = Map<string, unknown>;
export function createGatewayRpcHandler(
mockResponses: GatewayRpcMockResponses = new Map(),
requestCapture?: {current: GatewayRpcRequestCapture | null},
) {
const endpoint = `${Config.gateway.rpcEndpoint}/_rpc`;
return http.post(endpoint, async ({request}) => {
const body = (await request.json()) as GatewayRpcRequest;
const authorization = request.headers.get('authorization') ?? undefined;
if (requestCapture) {
requestCapture.current = {
method: body.method,
params: body.params,
authorization,
};
}
const mockResult = mockResponses.get(body.method);
if (mockResult === undefined) {
const errorResponse: GatewayRpcResponse = {
error: `No mock configured for method: ${body.method}`,
};
return HttpResponse.json(errorResponse, {status: 500});
}
if (mockResult instanceof Error) {
const errorResponse: GatewayRpcResponse = {
error: mockResult.message,
};
return HttpResponse.json(errorResponse, {status: 500});
}
const response: GatewayRpcResponse = {
result: mockResult,
};
return HttpResponse.json(response);
});
}
export function createGatewayRpcErrorHandler(status: number, errorMessage: string) {
const endpoint = `${Config.gateway.rpcEndpoint}/_rpc`;
return http.post(endpoint, () => {
const response: GatewayRpcResponse = {
error: errorMessage,
};
return HttpResponse.json(response, {status});
});
}
export function createGatewayRpcMethodErrorHandler(method: string, errorMessage: string) {
const endpoint = `${Config.gateway.rpcEndpoint}/_rpc`;
return http.post(endpoint, async ({request}) => {
const body = (await request.json()) as GatewayRpcRequest;
if (body.method === method) {
const response: GatewayRpcResponse = {
error: errorMessage,
};
return HttpResponse.json(response, {status: 500});
}
return HttpResponse.json({result: {}});
});
}
export function createGatewayRpcSequenceHandler(
method: string,
responses: Array<{result?: unknown; error?: string; status?: number}>,
) {
const endpoint = `${Config.gateway.rpcEndpoint}/_rpc`;
let callCount = 0;
return http.post(endpoint, async ({request}) => {
const body = (await request.json()) as GatewayRpcRequest;
if (body.method !== method) {
return HttpResponse.json({result: {}});
}
const responseConfig = responses[callCount] ?? responses[responses.length - 1];
callCount++;
if (responseConfig.error) {
const errorResponse: GatewayRpcResponse = {
error: responseConfig.error,
};
return HttpResponse.json(errorResponse, {status: responseConfig.status ?? 500});
}
const response: GatewayRpcResponse = {
result: responseConfig.result,
};
return HttpResponse.json(response, {status: responseConfig.status ?? 200});
});
}

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 {http, passthrough} from 'msw';
// When integration tests spin up a real Meilisearch instance (Testcontainers),
// we want those requests to hit the network while still treating all other
// unhandled requests as errors.
const MEILISEARCH_LOCAL_URL_PATTERN =
/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\/(health|info|indexes|tasks|keys|stats|version)(\/.*)?$/;
export function createMeilisearchPassthroughHandlers() {
return [http.all(MEILISEARCH_LOCAL_URL_PATTERN, () => passthrough())];
}

View File

@@ -0,0 +1,207 @@
/*
* 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 {HttpResponse, http} from 'msw';
const NCMEC_BASE_URL = 'https://exttest.cybertip.org/ispws';
type SubmissionRecord = {
reportId: string;
fileIds: Array<string>;
retracted: boolean;
};
let reportCounter = 4564654;
let fileCounter = 1;
let requestCounter = 1;
const submissions = new Map<string, SubmissionRecord>();
export function resetNcmecState(): void {
reportCounter = 4564654;
fileCounter = 1;
requestCounter = 1;
submissions.clear();
}
export function createNcmecHandlers() {
return [
http.get(`${NCMEC_BASE_URL}/status`, ({request}) => {
if (!hasBasicAuth(request)) {
return HttpResponse.text('', {status: 401});
}
return xmlResponse(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>0</responseCode>
<responseDescription>Remote User : test-user, Remote Ip : 127.0.0.1</responseDescription>
</reportResponse>`,
);
}),
http.get(`${NCMEC_BASE_URL}/xsd`, () => {
return HttpResponse.text('<schema></schema>', {status: 200});
}),
http.post(`${NCMEC_BASE_URL}/submit`, async ({request}) => {
if (!hasBasicAuth(request)) {
return HttpResponse.text('', {status: 401});
}
if (!hasXmlContentType(request)) {
return xmlResponse(reportErrorResponse(1001, 'Invalid content type'));
}
const body = await request.text();
if (!isValidReportXml(body)) {
return xmlResponse(reportErrorResponse(1000, 'Invalid report XML'));
}
const reportId = String(reportCounter++);
submissions.set(reportId, {reportId, fileIds: [], retracted: false});
return xmlResponse(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>0</responseCode>
<responseDescription>Success</responseDescription>
<reportId>${reportId}</reportId>
</reportResponse>`,
);
}),
http.post(`${NCMEC_BASE_URL}/upload`, async ({request}) => {
if (!hasBasicAuth(request)) {
return HttpResponse.text('', {status: 401});
}
const form = await request.formData();
const reportId = String(form.get('id') ?? '');
const file = form.get('file');
if (!reportId || !file || !submissions.has(reportId)) {
return xmlResponse(reportErrorResponse(1002, 'Unknown report'));
}
const record = submissions.get(reportId)!;
const fileId = `file-${fileCounter++}`;
record.fileIds.push(fileId);
return xmlResponse(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>0</responseCode>
<responseDescription>Success</responseDescription>
<reportId>${reportId}</reportId>
<fileId>${fileId}</fileId>
<hash>fafa5efeaf3cbe3b23b2748d13e629a1</hash>
</reportResponse>`,
);
}),
http.post(`${NCMEC_BASE_URL}/fileinfo`, async ({request}) => {
if (!hasBasicAuth(request)) {
return HttpResponse.text('', {status: 401});
}
if (!hasXmlContentType(request)) {
return xmlResponse(reportErrorResponse(1001, 'Invalid content type'));
}
const body = await request.text();
const reportId = extractXmlTag(body, 'reportId');
const fileId = extractXmlTag(body, 'fileId');
if (!reportId || !fileId || !submissions.has(reportId)) {
return xmlResponse(reportErrorResponse(1002, 'Unknown report'));
}
return xmlResponse(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>0</responseCode>
<responseDescription>Success</responseDescription>
<reportId>${reportId}</reportId>
</reportResponse>`,
);
}),
http.post(`${NCMEC_BASE_URL}/finish`, async ({request}) => {
if (!hasBasicAuth(request)) {
return HttpResponse.text('', {status: 401});
}
const form = await request.formData();
const reportId = String(form.get('id') ?? '');
const record = submissions.get(reportId);
if (!record || record.retracted) {
return xmlResponse(reportErrorResponse(1002, 'Unknown report'));
}
const files = record.fileIds.map((id) => ` <fileId>${id}</fileId>`).join('\n');
return xmlResponse(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportDoneResponse>
<responseCode>0</responseCode>
<reportId>${reportId}</reportId>
<files>
${files}
</files>
</reportDoneResponse>`,
);
}),
http.post(`${NCMEC_BASE_URL}/retract`, async ({request}) => {
if (!hasBasicAuth(request)) {
return HttpResponse.text('', {status: 401});
}
const form = await request.formData();
const reportId = String(form.get('id') ?? '');
const record = submissions.get(reportId);
if (!record) {
return xmlResponse(reportErrorResponse(1002, 'Unknown report'));
}
record.retracted = true;
return xmlResponse(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>0</responseCode>
<responseDescription>Success</responseDescription>
<reportId>${reportId}</reportId>
</reportResponse>`,
);
}),
];
}
function xmlResponse(body: string) {
return HttpResponse.text(body, {
status: 200,
headers: {
'content-type': 'application/xml; charset=utf-8',
'Request-ID': `req-${requestCounter++}`,
},
});
}
function reportErrorResponse(code: number, description: string): string {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<reportResponse>
<responseCode>${code}</responseCode>
<responseDescription>${description}</responseDescription>
</reportResponse>`;
}
function hasXmlContentType(request: Request): boolean {
const contentType = request.headers.get('content-type') ?? '';
return contentType.toLowerCase().includes('text/xml');
}
function hasBasicAuth(request: Request): boolean {
const auth = request.headers.get('authorization') ?? '';
return auth.toLowerCase().startsWith('basic ');
}
function isValidReportXml(body: string): boolean {
return body.includes('<report') && body.includes('<incidentSummary>') && body.includes('<incidentType>');
}
function extractXmlTag(body: string, tag: string): string | null {
const match = body.match(new RegExp(`<${tag}>([^<]+)</${tag}>`));
return match?.[1] ?? null;
}

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 {randomUUID} from 'node:crypto';
import {HttpResponse, http} from 'msw';
interface MatchRequestItem {
DataRepresentation: 'Hash';
Value: string;
}
interface MatchFlag {
AdvancedInfo?: Array<{Key: string; Value: string}>;
Source: string;
Violations?: Array<string>;
MatchDistance?: number;
}
interface MatchResponseResult {
Status: {
Code: number;
Description?: string;
};
ContentId?: string | null;
IsMatch: boolean;
MatchDetails?: {
MatchFlags?: Array<MatchFlag>;
};
XPartnerCustomerId?: string | null;
TrackingId?: string | null;
}
interface MatchResponse {
TrackingId: string;
MatchResults: Array<MatchResponseResult>;
}
export interface PhotoDnaMockConfig {
isMatch?: boolean;
source?: string;
violations?: Array<string>;
matchDistance?: number;
matchId?: string;
trackingId?: string;
statusCode?: number;
statusDescription?: string;
}
export interface PhotoDnaRequestCapture {
url: string;
headers: Headers;
body: Array<MatchRequestItem>;
}
export function createPhotoDnaMatchHandler(
config: PhotoDnaMockConfig = {},
requestCapture?: {current: PhotoDnaRequestCapture | null},
) {
return http.post('https://api.microsoftmoderator.com/photodna/v1.0/Match', async ({request}) => {
const body = (await request.json()) as Array<MatchRequestItem>;
if (requestCapture) {
requestCapture.current = {
url: request.url,
headers: request.headers,
body,
};
}
const isMatch = config.isMatch ?? false;
const trackingId = config.trackingId ?? randomUUID();
const matchResults: Array<MatchResponseResult> = body.map((item) => {
const result: MatchResponseResult = {
Status: {
Code: config.statusCode ?? 3000,
Description: config.statusDescription,
},
ContentId: item.Value,
IsMatch: isMatch,
TrackingId: randomUUID(),
};
if (isMatch) {
const matchFlag: MatchFlag = {
Source: config.source ?? 'mock-database',
Violations: config.violations ?? ['CSAM'],
MatchDistance: config.matchDistance ?? 0.01,
};
if (config.matchId) {
matchFlag.AdvancedInfo = [{Key: 'MatchId', Value: config.matchId}];
}
result.MatchDetails = {
MatchFlags: [matchFlag],
};
}
return result;
});
const response: MatchResponse = {
TrackingId: trackingId,
MatchResults: matchResults,
};
return HttpResponse.json(response);
});
}
export function createPhotoDnaErrorHandler(status: number, body?: unknown, headers?: Record<string, string>) {
return http.post('https://api.microsoftmoderator.com/photodna/v1.0/Match', () => {
return new HttpResponse(body != null ? JSON.stringify(body) : null, {
status,
headers: {
'Content-Type': 'application/json',
...headers,
},
});
});
}
export function createPhotoDnaRateLimitHandler(retryAfterSeconds = 1) {
return http.post('https://api.microsoftmoderator.com/photodna/v1.0/Match', () => {
return new HttpResponse(JSON.stringify({error: 'Rate limit exceeded'}), {
status: 429,
headers: {
'Retry-After': retryAfterSeconds.toString(),
},
});
});
}
export function createPhotoDnaSequenceHandler(
responses: Array<{
isMatch?: boolean;
source?: string;
violations?: Array<string>;
matchDistance?: number;
matchId?: string;
trackingId?: string;
status?: number;
error?: unknown;
}>,
) {
let callCount = 0;
return http.post('https://api.microsoftmoderator.com/photodna/v1.0/Match', async ({request}) => {
const responseConfig = responses[callCount] ?? responses[responses.length - 1];
callCount++;
if (responseConfig?.status && responseConfig.status >= 400) {
return new HttpResponse(JSON.stringify(responseConfig.error ?? {error: 'Error'}), {
status: responseConfig.status,
headers: {
'Content-Type': 'application/json',
},
});
}
const body = (await request.json()) as Array<MatchRequestItem>;
const isMatch = responseConfig?.isMatch ?? false;
const trackingId = responseConfig?.trackingId ?? randomUUID();
const matchResults: Array<MatchResponseResult> = body.map((item) => {
const result: MatchResponseResult = {
Status: {Code: 3000},
ContentId: item.Value,
IsMatch: isMatch,
TrackingId: randomUUID(),
};
if (isMatch) {
const matchFlag: MatchFlag = {
Source: responseConfig?.source ?? 'mock-database',
Violations: responseConfig?.violations ?? ['CSAM'],
MatchDistance: responseConfig?.matchDistance ?? 0.01,
};
if (responseConfig?.matchId) {
matchFlag.AdvancedInfo = [{Key: 'MatchId', Value: responseConfig.matchId}];
}
result.MatchDetails = {
MatchFlags: [matchFlag],
};
}
return result;
});
const response: MatchResponse = {
TrackingId: trackingId,
MatchResults: matchResults,
};
return HttpResponse.json(response);
});
}

View File

@@ -0,0 +1,31 @@
/*
* 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 {HttpResponse, http} from 'msw';
export function createPwnedPasswordsRangeHandler() {
return http.get('https://api.pwnedpasswords.com/range/:prefix', () => {
return HttpResponse.text('', {
status: 200,
headers: {
'content-type': 'text/plain; charset=utf-8',
},
});
});
}

View File

@@ -0,0 +1,571 @@
/*
* 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 {HttpResponse, http} from 'msw';
const STRIPE_API_BASE = 'https://api.stripe.com';
export interface CheckoutSessionParams {
customer?: string;
customer_email?: string;
line_items?: Array<{price_data?: unknown; price?: string; quantity?: number}>;
mode?: string;
success_url?: string;
cancel_url?: string;
metadata?: Record<string, string>;
allow_promotion_codes?: string;
}
export interface PortalSessionParams {
customer: string;
return_url?: string;
}
export interface StripeApiMockConfig {
checkoutShouldFail?: boolean;
portalShouldFail?: boolean;
subscriptionShouldFail?: boolean;
customerShouldFail?: boolean;
}
export interface StripeApiMockSpies {
createdCheckoutSessions: Array<CheckoutSessionParams>;
createdPortalSessions: Array<PortalSessionParams>;
createdCustomers: Array<{id: string; email: string | null}>;
retrievedSubscriptions: Array<string>;
cancelledSubscriptions: Array<string>;
retrievedCustomers: Array<string>;
updatedSubscriptions: Array<{id: string; params: Record<string, unknown>}>;
}
function parseFormDataToObject(formData: FormData): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [key, value] of formData.entries()) {
if (key.includes('[')) {
const matches = key.match(/^([^[]+)\[(\d+)\]\[([^\]]+)\](?:\[([^\]]+)\])?(?:\[([^\]]+)\])?$/);
if (matches) {
const [, arrayName, index, prop1, prop2, prop3] = matches;
if (!result[arrayName!]) {
result[arrayName!] = [];
}
const arr = result[arrayName!] as Array<Record<string, unknown>>;
const idx = parseInt(index!, 10);
if (!arr[idx]) {
arr[idx] = {};
}
if (prop3) {
if (!arr[idx][prop1!]) {
arr[idx][prop1!] = {};
}
const nested = arr[idx][prop1!] as Record<string, unknown>;
if (!nested[prop2!]) {
nested[prop2!] = {};
}
(nested[prop2!] as Record<string, unknown>)[prop3] = value;
} else if (prop2) {
if (!arr[idx][prop1!]) {
arr[idx][prop1!] = {};
}
(arr[idx][prop1!] as Record<string, unknown>)[prop2] = value;
} else {
arr[idx][prop1!] = value;
}
} else {
const simpleMatch = key.match(/^([^[]+)\[([^\]]+)\]$/);
if (simpleMatch) {
const [, objName, objKey] = simpleMatch;
if (!result[objName!]) {
result[objName!] = {};
}
(result[objName!] as Record<string, unknown>)[objKey!] = value;
}
}
} else {
result[key] = value;
}
}
return result;
}
export function createStripeApiHandlers(config: StripeApiMockConfig = {}) {
const spies: StripeApiMockSpies = {
createdCheckoutSessions: [],
createdPortalSessions: [],
createdCustomers: [],
retrievedSubscriptions: [],
cancelledSubscriptions: [],
retrievedCustomers: [],
updatedSubscriptions: [],
};
let sessionCounter = 0;
let portalCounter = 0;
const subscriptionStore = new Map<string, {trial_end: number | null}>();
const handlers = [
http.post(`${STRIPE_API_BASE}/v1/checkout/sessions`, async ({request}) => {
if (config.checkoutShouldFail) {
return HttpResponse.json(
{
error: {
type: 'invalid_request_error',
message: 'Mock checkout failure',
code: 'resource_missing',
},
},
{status: 400},
);
}
const formData = await request.formData();
const params = parseFormDataToObject(formData) as unknown as CheckoutSessionParams;
spies.createdCheckoutSessions.push(params);
sessionCounter++;
const sessionId = `cs_test_${sessionCounter}_${Date.now()}`;
return HttpResponse.json({
id: sessionId,
object: 'checkout.session',
url: `https://checkout.stripe.com/c/pay/${sessionId}`,
customer: params.customer ?? (params.customer_email ? `cus_test_${sessionCounter}` : null),
customer_email: params.customer_email,
mode: params.mode || 'subscription',
metadata: params.metadata || {},
status: 'open',
success_url: params.success_url,
cancel_url: params.cancel_url,
amount_total: null,
currency: null,
livemode: false,
payment_status: 'unpaid',
created: Math.floor(Date.now() / 1000),
expires_at: Math.floor(Date.now() / 1000) + 86400,
});
}),
http.get(`${STRIPE_API_BASE}/v1/checkout/sessions/:id`, ({params}) => {
const {id} = params;
return HttpResponse.json({
id,
object: 'checkout.session',
customer: 'cus_test_1',
customer_email: 'test@example.com',
subscription: 'sub_test_1',
payment_intent: 'pi_test_1',
amount_total: 2500,
currency: 'usd',
status: 'complete',
payment_status: 'paid',
metadata: {},
mode: 'subscription',
livemode: false,
created: Math.floor(Date.now() / 1000) - 3600,
});
}),
http.post(`${STRIPE_API_BASE}/v1/billing_portal/sessions`, async ({request}) => {
if (config.portalShouldFail) {
return HttpResponse.json(
{
error: {
type: 'invalid_request_error',
message: 'Mock portal failure',
code: 'resource_missing',
},
},
{status: 400},
);
}
const formData = await request.formData();
const customer = formData.get('customer') as string;
const return_url = formData.get('return_url') as string | undefined;
spies.createdPortalSessions.push({customer, return_url});
portalCounter++;
return HttpResponse.json({
id: `bps_test_${portalCounter}`,
object: 'billing_portal.session',
url: `https://billing.stripe.com/p/session/test_${portalCounter}_${Date.now()}`,
customer,
return_url: return_url || null,
livemode: false,
created: Math.floor(Date.now() / 1000),
configuration: 'bpc_test_config',
});
}),
http.get(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
if (config.subscriptionShouldFail) {
return HttpResponse.json(
{
error: {
type: 'invalid_request_error',
message: 'No such subscription',
code: 'resource_missing',
param: 'id',
},
},
{status: 404},
);
}
const {id} = params;
spies.retrievedSubscriptions.push(id as string);
const currentPeriodEnd = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
const subState = subscriptionStore.get(id as string);
return HttpResponse.json({
id,
object: 'subscription',
customer: 'cus_test_1',
status: 'active',
current_period_start: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60,
trial_end: subState?.trial_end ?? null,
items: {
object: 'list',
data: [
{
id: 'si_test_1',
object: 'subscription_item',
price: {
id: 'price_test_1',
object: 'price',
unit_amount: 2500,
currency: 'usd',
recurring: {
interval: 'month',
interval_count: 1,
},
type: 'recurring',
active: true,
livemode: false,
},
quantity: 1,
current_period_end: currentPeriodEnd,
},
],
has_more: false,
url: `/v1/subscription_items?subscription=${id}`,
},
cancel_at: null,
cancel_at_period_end: false,
canceled_at: null,
collection_method: 'charge_automatically',
created: Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60,
livemode: false,
metadata: {},
start_date: Math.floor(Date.now() / 1000) - 90 * 24 * 60 * 60,
});
}),
http.post(`${STRIPE_API_BASE}/v1/subscriptions/:id`, async ({params, request}) => {
if (config.subscriptionShouldFail) {
return HttpResponse.json(
{
error: {
type: 'invalid_request_error',
message: 'Mock subscription update failure',
code: 'resource_missing',
},
},
{status: 400},
);
}
const {id} = params;
const formData = await request.formData();
const updateParams = parseFormDataToObject(formData);
spies.updatedSubscriptions.push({id: id as string, params: updateParams});
if (updateParams.trial_end) {
const subState = subscriptionStore.get(id as string) ?? {trial_end: null};
subState.trial_end = Number(updateParams.trial_end);
subscriptionStore.set(id as string, subState);
}
return HttpResponse.json({
id,
object: 'subscription',
customer: 'cus_test_1',
status: 'active',
cancel_at_period_end: updateParams.cancel_at_period_end === 'true',
trial_end: updateParams.trial_end ? Number(updateParams.trial_end) : null,
metadata: updateParams.metadata || {},
livemode: false,
});
}),
http.delete(`${STRIPE_API_BASE}/v1/subscriptions/:id`, ({params}) => {
const {id} = params;
spies.cancelledSubscriptions.push(id as string);
return HttpResponse.json({
id,
object: 'subscription',
customer: 'cus_test_1',
status: 'canceled',
canceled_at: Math.floor(Date.now() / 1000),
ended_at: Math.floor(Date.now() / 1000),
livemode: false,
});
}),
http.get(`${STRIPE_API_BASE}/v1/customers/:id`, ({params}) => {
if (config.customerShouldFail) {
return HttpResponse.json(
{
error: {
type: 'invalid_request_error',
message: 'No such customer',
code: 'resource_missing',
param: 'id',
},
},
{status: 404},
);
}
const {id} = params;
spies.retrievedCustomers.push(id as string);
return HttpResponse.json({
id,
object: 'customer',
email: 'test@example.com',
name: 'Test Customer',
created: Math.floor(Date.now() / 1000) - 365 * 24 * 60 * 60,
livemode: false,
metadata: {},
description: null,
currency: 'usd',
default_source: null,
invoice_settings: {
default_payment_method: 'pm_test_1',
},
});
}),
http.post(`${STRIPE_API_BASE}/v1/customers`, async ({request}) => {
const formData = await request.formData();
const email = formData.get('email') as string | null;
const customerId = `cus_test_new_${Date.now()}`;
spies.createdCustomers.push({id: customerId, email});
return HttpResponse.json({
id: customerId,
object: 'customer',
email,
name: null,
created: Math.floor(Date.now() / 1000),
livemode: false,
metadata: {},
});
}),
http.get(`${STRIPE_API_BASE}/v1/prices/:id`, ({params}) => {
const {id} = params;
return HttpResponse.json({
id,
object: 'price',
active: true,
currency: 'usd',
unit_amount: 2500,
type: 'recurring',
recurring: {
interval: 'month',
interval_count: 1,
},
product: 'prod_test_1',
livemode: false,
created: Math.floor(Date.now() / 1000) - 365 * 24 * 60 * 60,
});
}),
];
function reset() {
spies.createdCheckoutSessions.length = 0;
spies.createdPortalSessions.length = 0;
spies.createdCustomers.length = 0;
spies.retrievedSubscriptions.length = 0;
spies.cancelledSubscriptions.length = 0;
spies.retrievedCustomers.length = 0;
spies.updatedSubscriptions.length = 0;
}
function resetAll() {
reset();
sessionCounter = 0;
portalCounter = 0;
subscriptionStore.clear();
}
return {handlers, spies, reset, resetAll};
}
export interface StripeWebhookEventData {
id?: string;
type: string;
data: {object: Record<string, unknown>};
created?: number;
}
export function createMockWebhookPayload(eventData: StripeWebhookEventData): {
payload: string;
timestamp: number;
} {
const timestamp = Math.floor(Date.now() / 1000);
const event = {
id: eventData.id ?? `evt_test_${Date.now()}`,
object: 'event',
api_version: '2026-01-28.clover',
created: eventData.created ?? timestamp,
type: eventData.type,
data: eventData.data,
livemode: false,
pending_webhooks: 1,
request: {
id: `req_test_${Date.now()}`,
idempotency_key: null,
},
};
return {payload: JSON.stringify(event), timestamp};
}
export function createCheckoutCompletedEvent(options: {
sessionId?: string;
customerId?: string;
customerEmail?: string;
subscriptionId?: string;
amountTotal?: number;
currency?: string;
metadata?: Record<string, string>;
}): StripeWebhookEventData {
return {
type: 'checkout.session.completed',
data: {
object: {
id: options.sessionId ?? `cs_test_${Date.now()}`,
object: 'checkout.session',
customer: options.customerId ?? 'cus_test_1',
customer_email: options.customerEmail ?? 'test@example.com',
subscription: options.subscriptionId ?? 'sub_test_1',
amount_total: options.amountTotal ?? 2500,
currency: options.currency ?? 'usd',
mode: 'subscription',
payment_status: 'paid',
status: 'complete',
metadata: options.metadata ?? {is_donation: 'true'},
},
},
};
}
export function createSubscriptionUpdatedEvent(options: {
subscriptionId?: string;
customerId?: string;
status?: string;
cancelAtPeriodEnd?: boolean;
}): StripeWebhookEventData {
const currentPeriodEnd = Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60;
return {
type: 'customer.subscription.updated',
data: {
object: {
id: options.subscriptionId ?? 'sub_test_1',
object: 'subscription',
customer: options.customerId ?? 'cus_test_1',
status: options.status ?? 'active',
cancel_at_period_end: options.cancelAtPeriodEnd ?? false,
items: {
data: [{current_period_end: currentPeriodEnd}],
},
},
},
};
}
export function createSubscriptionDeletedEvent(options: {
subscriptionId?: string;
customerId?: string;
}): StripeWebhookEventData {
return {
type: 'customer.subscription.deleted',
data: {
object: {
id: options.subscriptionId ?? 'sub_test_1',
object: 'subscription',
customer: options.customerId ?? 'cus_test_1',
status: 'canceled',
canceled_at: Math.floor(Date.now() / 1000),
},
},
};
}
export function createInvoicePaidEvent(options: {
invoiceId?: string;
customerId?: string;
subscriptionId?: string;
amountPaid?: number;
currency?: string;
}): StripeWebhookEventData {
return {
type: 'invoice.paid',
data: {
object: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
amount_paid: options.amountPaid ?? 2500,
currency: options.currency ?? 'usd',
status: 'paid',
paid: true,
},
},
};
}
export function createInvoicePaymentFailedEvent(options: {
invoiceId?: string;
customerId?: string;
subscriptionId?: string;
amountDue?: number;
}): StripeWebhookEventData {
return {
type: 'invoice.payment_failed',
data: {
object: {
id: options.invoiceId ?? `in_test_${Date.now()}`,
object: 'invoice',
customer: options.customerId ?? 'cus_test_1',
subscription: options.subscriptionId ?? 'sub_test_1',
amount_due: options.amountDue ?? 2500,
status: 'open',
paid: false,
attempt_count: 1,
next_payment_attempt: Math.floor(Date.now() / 1000) + 3 * 24 * 60 * 60,
},
},
};
}

View File

@@ -0,0 +1,91 @@
/*
* 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 {HttpResponse, http} from 'msw';
interface WikiSummaryResponse {
type: string;
title: string;
extract: string;
thumbnail?: {
source: string;
width: number;
height: number;
};
originalimage?: {
source: string;
width: number;
height: number;
};
description?: string;
pageid: number;
}
export interface WikipediaApiMockConfig {
articles?: Map<string, WikiSummaryResponse>;
defaultResponse?: WikiSummaryResponse;
}
export function createWikipediaApiHandlers(config: WikipediaApiMockConfig = {}) {
const articles = config.articles ?? new Map();
const defaultResponse = config.defaultResponse ?? {
type: 'standard',
title: 'Test Article',
extract: 'This is a test article extract.',
pageid: 12345,
};
return [
http.get('https://:lang.wikipedia.org/api/rest_v1/page/summary/:title', ({params}) => {
const title = params.title as string;
const decodedTitle = decodeURIComponent(title);
const article = articles.get(decodedTitle) ?? articles.get(title) ?? defaultResponse;
return HttpResponse.json(article);
}),
];
}
export function createWikipediaArticle(options: {
title: string;
extract: string;
pageid?: number;
description?: string;
thumbnail?: {
source: string;
width: number;
height: number;
};
originalimage?: {
source: string;
width: number;
height: number;
};
}): WikiSummaryResponse {
return {
type: 'standard',
title: options.title,
extract: options.extract,
pageid: options.pageid ?? 12345,
description: options.description,
thumbnail: options.thumbnail,
originalimage: options.originalimage,
};
}

View File

@@ -0,0 +1,31 @@
/*
* 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 {createDockerEnginePassthroughHandlers} from '@fluxer/api/src/test/msw/handlers/DockerEngineHandlers';
import {createMeilisearchPassthroughHandlers} from '@fluxer/api/src/test/msw/handlers/MeilisearchHandlers';
import {createNcmecHandlers} from '@fluxer/api/src/test/msw/handlers/NcmecHandlers';
import {createPwnedPasswordsRangeHandler} from '@fluxer/api/src/test/msw/handlers/PwnedPasswordsHandlers';
import {setupServer} from 'msw/node';
export const server = setupServer(
...createDockerEnginePassthroughHandlers(),
...createMeilisearchPassthroughHandlers(),
createPwnedPasswordsRangeHandler(),
...createNcmecHandlers(),
);