refactor progress
This commit is contained in:
189
packages/api/src/test/ApiTestHarness.tsx
Normal file
189
packages/api/src/test/ApiTestHarness.tsx
Normal 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};
|
||||
}
|
||||
739
packages/api/src/test/NoopGatewayService.tsx
Normal file
739
packages/api/src/test/NoopGatewayService.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
32
packages/api/src/test/NoopWorkerService.tsx
Normal file
32
packages/api/src/test/NoopWorkerService.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
70
packages/api/src/test/Setup.tsx
Normal file
70
packages/api/src/test/Setup.tsx
Normal 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();
|
||||
});
|
||||
175
packages/api/src/test/TestConstants.tsx
Normal file
175
packages/api/src/test/TestConstants.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
3110
packages/api/src/test/TestHarnessController.tsx
Normal file
3110
packages/api/src/test/TestHarnessController.tsx
Normal file
File diff suppressed because it is too large
Load Diff
76
packages/api/src/test/TestHarnessReset.tsx
Normal file
76
packages/api/src/test/TestHarnessReset.tsx
Normal 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');
|
||||
};
|
||||
}
|
||||
209
packages/api/src/test/TestMediaService.tsx
Normal file
209
packages/api/src/test/TestMediaService.tsx
Normal 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';
|
||||
}
|
||||
}
|
||||
192
packages/api/src/test/TestRequestBuilder.tsx
Normal file
192
packages/api/src/test/TestRequestBuilder.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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, '');
|
||||
}
|
||||
45
packages/api/src/test/TestS3Service.tsx
Normal file
45
packages/api/src/test/TestS3Service.tsx
Normal 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});
|
||||
}
|
||||
}
|
||||
58
packages/api/src/test/fixtures/document.pdf
vendored
Normal file
58
packages/api/src/test/fixtures/document.pdf
vendored
Normal 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
|
||||
75
packages/api/src/test/fixtures/ncmec/NcmecXmlFixtures.tsx
vendored
Normal file
75
packages/api/src/test/fixtures/ncmec/NcmecXmlFixtures.tsx
vendored
Normal 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>`;
|
||||
BIN
packages/api/src/test/fixtures/sticker.png
vendored
Normal file
BIN
packages/api/src/test/fixtures/sticker.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 B |
BIN
packages/api/src/test/fixtures/thisisfine.gif
vendored
Normal file
BIN
packages/api/src/test/fixtures/thisisfine.gif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 48 KiB |
BIN
packages/api/src/test/fixtures/yeah.png
vendored
Normal file
BIN
packages/api/src/test/fixtures/yeah.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
67
packages/api/src/test/meilisearch/MeilisearchTestServer.tsx
Normal file
67
packages/api/src/test/meilisearch/MeilisearchTestServer.tsx
Normal 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();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
108
packages/api/src/test/mocks/MockAssetDeletionQueue.tsx
Normal file
108
packages/api/src/test/mocks/MockAssetDeletionQueue.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
106
packages/api/src/test/mocks/MockBlueskyOAuthService.tsx
Normal file
106
packages/api/src/test/mocks/MockBlueskyOAuthService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
75
packages/api/src/test/mocks/MockCsamEvidenceService.tsx
Normal file
75
packages/api/src/test/mocks/MockCsamEvidenceService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
69
packages/api/src/test/mocks/MockCsamScanQueueService.tsx
Normal file
69
packages/api/src/test/mocks/MockCsamScanQueueService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
300
packages/api/src/test/mocks/MockGatewayService.tsx
Normal file
300
packages/api/src/test/mocks/MockGatewayService.tsx
Normal 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 = {};
|
||||
}
|
||||
}
|
||||
922
packages/api/src/test/mocks/MockKVProvider.tsx
Normal file
922
packages/api/src/test/mocks/MockKVProvider.tsx
Normal 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;
|
||||
}
|
||||
141
packages/api/src/test/mocks/MockLiveKitService.tsx
Normal file
141
packages/api/src/test/mocks/MockLiveKitService.tsx
Normal 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 = {};
|
||||
}
|
||||
}
|
||||
110
packages/api/src/test/mocks/MockMediaService.tsx
Normal file
110
packages/api/src/test/mocks/MockMediaService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
126
packages/api/src/test/mocks/MockNcmecReporter.tsx
Normal file
126
packages/api/src/test/mocks/MockNcmecReporter.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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();
|
||||
}
|
||||
}
|
||||
61
packages/api/src/test/mocks/MockPhotoDnaHashClient.tsx
Normal file
61
packages/api/src/test/mocks/MockPhotoDnaHashClient.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
132
packages/api/src/test/mocks/MockSnowflakeService.tsx
Normal file
132
packages/api/src/test/mocks/MockSnowflakeService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
268
packages/api/src/test/mocks/MockStorageService.tsx
Normal file
268
packages/api/src/test/mocks/MockStorageService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
123
packages/api/src/test/mocks/MockSynchronousCsamScanner.tsx
Normal file
123
packages/api/src/test/mocks/MockSynchronousCsamScanner.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
99
packages/api/src/test/mocks/MockVirusScanService.tsx
Normal file
99
packages/api/src/test/mocks/MockVirusScanService.tsx
Normal 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();
|
||||
}
|
||||
}
|
||||
36
packages/api/src/test/mocks/NoopLogger.tsx
Normal file
36
packages/api/src/test/mocks/NoopLogger.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
138
packages/api/src/test/msw/handlers/ArachnidShieldHandlers.tsx
Normal file
138
packages/api/src/test/msw/handlers/ArachnidShieldHandlers.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
220
packages/api/src/test/msw/handlers/BlueskyApiHandlers.tsx
Normal file
220
packages/api/src/test/msw/handlers/BlueskyApiHandlers.tsx
Normal 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;
|
||||
}
|
||||
30
packages/api/src/test/msw/handlers/DockerEngineHandlers.tsx
Normal file
30
packages/api/src/test/msw/handlers/DockerEngineHandlers.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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())];
|
||||
}
|
||||
141
packages/api/src/test/msw/handlers/GatewayRpcHandlers.tsx
Normal file
141
packages/api/src/test/msw/handlers/GatewayRpcHandlers.tsx
Normal 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});
|
||||
});
|
||||
}
|
||||
30
packages/api/src/test/msw/handlers/MeilisearchHandlers.tsx
Normal file
30
packages/api/src/test/msw/handlers/MeilisearchHandlers.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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())];
|
||||
}
|
||||
207
packages/api/src/test/msw/handlers/NcmecHandlers.tsx
Normal file
207
packages/api/src/test/msw/handlers/NcmecHandlers.tsx
Normal 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;
|
||||
}
|
||||
216
packages/api/src/test/msw/handlers/PhotoDnaHandlers.tsx
Normal file
216
packages/api/src/test/msw/handlers/PhotoDnaHandlers.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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);
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
571
packages/api/src/test/msw/handlers/StripeApiHandlers.tsx
Normal file
571
packages/api/src/test/msw/handlers/StripeApiHandlers.tsx
Normal 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,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
91
packages/api/src/test/msw/handlers/WikipediaApiHandlers.tsx
Normal file
91
packages/api/src/test/msw/handlers/WikipediaApiHandlers.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
31
packages/api/src/test/msw/server.tsx
Normal file
31
packages/api/src/test/msw/server.tsx
Normal 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(),
|
||||
);
|
||||
Reference in New Issue
Block a user