refactor progress

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

View File

@@ -0,0 +1,66 @@
/*
* 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 {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const AuditLogActionTypeSchema = withOpenApiType(
createInt32EnumType(
[
[AuditLogActionType.GUILD_UPDATE, 'GUILD_UPDATE', 'Guild settings were updated'],
[AuditLogActionType.CHANNEL_CREATE, 'CHANNEL_CREATE', 'Channel was created'],
[AuditLogActionType.CHANNEL_UPDATE, 'CHANNEL_UPDATE', 'Channel was updated'],
[AuditLogActionType.CHANNEL_DELETE, 'CHANNEL_DELETE', 'Channel was deleted'],
[AuditLogActionType.CHANNEL_OVERWRITE_CREATE, 'CHANNEL_OVERWRITE_CREATE', 'Permission overwrite was created'],
[AuditLogActionType.CHANNEL_OVERWRITE_UPDATE, 'CHANNEL_OVERWRITE_UPDATE', 'Permission overwrite was updated'],
[AuditLogActionType.CHANNEL_OVERWRITE_DELETE, 'CHANNEL_OVERWRITE_DELETE', 'Permission overwrite was deleted'],
[AuditLogActionType.MEMBER_KICK, 'MEMBER_KICK', 'Member was kicked'],
[AuditLogActionType.MEMBER_PRUNE, 'MEMBER_PRUNE', 'Members were pruned'],
[AuditLogActionType.MEMBER_BAN_ADD, 'MEMBER_BAN_ADD', 'Member was banned'],
[AuditLogActionType.MEMBER_BAN_REMOVE, 'MEMBER_BAN_REMOVE', 'Member ban was removed'],
[AuditLogActionType.MEMBER_UPDATE, 'MEMBER_UPDATE', 'Member was updated'],
[AuditLogActionType.MEMBER_ROLE_UPDATE, 'MEMBER_ROLE_UPDATE', 'Member roles were updated'],
[AuditLogActionType.MEMBER_MOVE, 'MEMBER_MOVE', 'Member was moved to a different voice channel'],
[AuditLogActionType.MEMBER_DISCONNECT, 'MEMBER_DISCONNECT', 'Member was disconnected from a voice channel'],
[AuditLogActionType.BOT_ADD, 'BOT_ADD', 'Bot was added to the guild'],
[AuditLogActionType.ROLE_CREATE, 'ROLE_CREATE', 'Role was created'],
[AuditLogActionType.ROLE_UPDATE, 'ROLE_UPDATE', 'Role was updated'],
[AuditLogActionType.ROLE_DELETE, 'ROLE_DELETE', 'Role was deleted'],
[AuditLogActionType.INVITE_CREATE, 'INVITE_CREATE', 'Invite was created'],
[AuditLogActionType.INVITE_UPDATE, 'INVITE_UPDATE', 'Invite was updated'],
[AuditLogActionType.INVITE_DELETE, 'INVITE_DELETE', 'Invite was deleted'],
[AuditLogActionType.WEBHOOK_CREATE, 'WEBHOOK_CREATE', 'Webhook was created'],
[AuditLogActionType.WEBHOOK_UPDATE, 'WEBHOOK_UPDATE', 'Webhook was updated'],
[AuditLogActionType.WEBHOOK_DELETE, 'WEBHOOK_DELETE', 'Webhook was deleted'],
[AuditLogActionType.EMOJI_CREATE, 'EMOJI_CREATE', 'Emoji was created'],
[AuditLogActionType.EMOJI_UPDATE, 'EMOJI_UPDATE', 'Emoji was updated'],
[AuditLogActionType.EMOJI_DELETE, 'EMOJI_DELETE', 'Emoji was deleted'],
[AuditLogActionType.STICKER_CREATE, 'STICKER_CREATE', 'Sticker was created'],
[AuditLogActionType.STICKER_UPDATE, 'STICKER_UPDATE', 'Sticker was updated'],
[AuditLogActionType.STICKER_DELETE, 'STICKER_DELETE', 'Sticker was deleted'],
[AuditLogActionType.MESSAGE_DELETE, 'MESSAGE_DELETE', 'Message was deleted'],
[AuditLogActionType.MESSAGE_BULK_DELETE, 'MESSAGE_BULK_DELETE', 'Messages were bulk deleted'],
[AuditLogActionType.MESSAGE_PIN, 'MESSAGE_PIN', 'Message was pinned'],
[AuditLogActionType.MESSAGE_UNPIN, 'MESSAGE_UNPIN', 'Message was unpinned'],
],
'The type of action that occurred',
'AuditLogActionType',
),
'AuditLogActionType',
);

View File

@@ -0,0 +1,178 @@
/*
* 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 {
ChannelOverwriteTypes,
ChannelOverwriteTypesDescriptions,
ChannelTypes,
} from '@fluxer/constants/src/ChannelConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
createInt32EnumType,
createNamedLiteralUnion,
MAX_STRING_PROCESSING_LENGTH,
normalizeString,
normalizeWhitespace,
stripInvisibles,
withOpenApiType,
withStringLengthRangeValidation,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
export const ChannelTypeSchema = withOpenApiType(
createInt32EnumType(
[
[ChannelTypes.GUILD_TEXT, 'GUILD_TEXT', 'A text channel within a guild'],
[ChannelTypes.DM, 'DM', 'A direct message between users'],
[ChannelTypes.GUILD_VOICE, 'GUILD_VOICE', 'A voice channel within a guild'],
[ChannelTypes.GROUP_DM, 'GROUP_DM', 'A group direct message between users'],
[ChannelTypes.GUILD_CATEGORY, 'GUILD_CATEGORY', 'A category that contains channels'],
[ChannelTypes.GUILD_LINK, 'GUILD_LINK', 'A link channel for external resources'],
[ChannelTypes.DM_PERSONAL_NOTES, 'DM_PERSONAL_NOTES', 'Personal notes DM channel'],
],
'The type of the channel',
),
'ChannelType',
);
export const ChannelOverwriteTypeSchema = withOpenApiType(
createNamedLiteralUnion(
[
[ChannelOverwriteTypes.ROLE, 'ROLE', ChannelOverwriteTypesDescriptions.ROLE],
[ChannelOverwriteTypes.MEMBER, 'MEMBER', ChannelOverwriteTypesDescriptions.MEMBER],
] as const,
'The type of entity the overwrite applies to',
),
'ChannelOverwriteType',
);
const WHITESPACE_REGEX = /\s+/g;
const MULTIPLE_HYPHENS_REGEX = /-{2,}/g;
const VANITY_URL_REGEX = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
const DISALLOWED_CHARS = new Set(' !"#$%&\'()*+,/:;<=>?@[\\]^`{|}~');
function sanitizeChannelName(value: string): string {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
let s = normalizeString(value);
s = stripInvisibles(s);
s = normalizeWhitespace(s);
return s;
}
export const ChannelNameType = z
.string()
.superRefine((value, ctx) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
params: {min: 1, max: 100},
});
return z.NEVER;
}
const normalized = normalizeString(value);
const processed =
normalized
.toLowerCase()
.replace(WHITESPACE_REGEX, '-')
.split('')
.filter((char) => !DISALLOWED_CHARS.has(char))
.join('') || '-';
if (processed.length < 1) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.CHANNEL_NAME_EMPTY_AFTER_NORMALIZATION,
});
return z.NEVER;
}
})
.transform((value) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
const normalized = normalizeString(value);
return (
normalized
.toLowerCase()
.replace(WHITESPACE_REGEX, '-')
.split('')
.filter((char) => !DISALLOWED_CHARS.has(char))
.join('') || '-'
);
})
.pipe(withStringLengthRangeValidation(z.string(), 1, 100, ValidationErrorCodes.STRING_LENGTH_INVALID));
export const GeneralChannelNameType = z
.string()
.superRefine((value, ctx) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.STRING_LENGTH_INVALID,
params: {min: 1, max: 100},
});
return z.NEVER;
}
})
.transform((value) => {
let sanitized = sanitizeChannelName(value);
sanitized = sanitized.replace(WHITESPACE_REGEX, ' ');
return sanitized;
})
.refine((v) => v.trim().length > 0, ValidationErrorCodes.NAME_EMPTY_AFTER_NORMALIZATION)
.pipe(withStringLengthRangeValidation(z.string(), 1, 100, ValidationErrorCodes.STRING_LENGTH_INVALID));
export const VanityURLCodeType = z
.string()
.superRefine((value, ctx) => {
const normalized = normalizeString(value);
const processed = normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
if (!VANITY_URL_REGEX.test(processed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.VANITY_URL_INVALID_CHARACTERS,
});
return z.NEVER;
}
})
.transform((value) => {
const normalized = normalizeString(value);
return normalized.toLowerCase().replace(WHITESPACE_REGEX, '-').replace(MULTIPLE_HYPHENS_REGEX, '-');
})
.pipe(withStringLengthRangeValidation(z.string(), 2, 32, ValidationErrorCodes.VANITY_URL_CODE_LENGTH_INVALID));
const AUDIT_LOG_REASON_MAX_LENGTH = 512;
export const AuditLogReasonType = z
.string()
.nullable()
.optional()
.transform((value) => {
if (!value || value.trim().length === 0) {
return null;
}
const normalized = normalizeString(value);
if (normalized.length < 1 || normalized.length > AUDIT_LOG_REASON_MAX_LENGTH) {
return null;
}
return normalized;
});

View File

@@ -0,0 +1,61 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {DeletionReasons} from '@fluxer/constants/src/Core';
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const DeletionReasonSchema = withOpenApiType(
createInt32EnumType(
[
[DeletionReasons.USER_REQUESTED, 'USER_REQUESTED', 'User requested account deletion'],
[DeletionReasons.OTHER, 'OTHER', 'Other reason'],
[DeletionReasons.SPAM, 'SPAM', 'Spam or unwanted content'],
[DeletionReasons.CHEATING_OR_EXPLOITATION, 'CHEATING_OR_EXPLOITATION', 'Cheating or exploitation'],
[DeletionReasons.COORDINATED_RAIDING, 'COORDINATED_RAIDING', 'Coordinated raiding'],
[DeletionReasons.AUTOMATION_OR_SELFBOT, 'AUTOMATION_OR_SELFBOT', 'Automation or selfbot usage'],
[DeletionReasons.NONCONSENSUAL_SEXUAL_CONTENT, 'NONCONSENSUAL_SEXUAL_CONTENT', 'Non-consensual sexual content'],
[DeletionReasons.SCAM_OR_SOCIAL_ENGINEERING, 'SCAM_OR_SOCIAL_ENGINEERING', 'Scam or social engineering'],
[DeletionReasons.CHILD_SEXUAL_CONTENT, 'CHILD_SEXUAL_CONTENT', 'Child sexual abuse material'],
[DeletionReasons.PRIVACY_VIOLATION_OR_DOXXING, 'PRIVACY_VIOLATION_OR_DOXXING', 'Privacy violation or doxxing'],
[DeletionReasons.HARASSMENT_OR_BULLYING, 'HARASSMENT_OR_BULLYING', 'Harassment or bullying'],
[DeletionReasons.PAYMENT_FRAUD, 'PAYMENT_FRAUD', 'Payment fraud'],
[DeletionReasons.CHILD_SAFETY_VIOLATION, 'CHILD_SAFETY_VIOLATION', 'Child safety violation'],
[DeletionReasons.BILLING_DISPUTE_OR_ABUSE, 'BILLING_DISPUTE_OR_ABUSE', 'Billing dispute or abuse'],
[DeletionReasons.UNSOLICITED_EXPLICIT_CONTENT, 'UNSOLICITED_EXPLICIT_CONTENT', 'Unsolicited explicit content'],
[DeletionReasons.GRAPHIC_VIOLENCE, 'GRAPHIC_VIOLENCE', 'Graphic violence'],
[DeletionReasons.BAN_EVASION, 'BAN_EVASION', 'Ban evasion'],
[DeletionReasons.TOKEN_OR_CREDENTIAL_SCAM, 'TOKEN_OR_CREDENTIAL_SCAM', 'Token or credential scam'],
[DeletionReasons.INACTIVITY, 'INACTIVITY', 'Account inactivity'],
[
DeletionReasons.HATE_SPEECH_OR_EXTREMIST_CONTENT,
'HATE_SPEECH_OR_EXTREMIST_CONTENT',
'Hate speech or extremist content',
],
[DeletionReasons.MALICIOUS_LINKS_OR_MALWARE, 'MALICIOUS_LINKS_OR_MALWARE', 'Malicious links or malware'],
[
DeletionReasons.IMPERSONATION_OR_FAKE_IDENTITY,
'IMPERSONATION_OR_FAKE_IDENTITY',
'Impersonation or fake identity',
],
],
'Reason for account deletion',
'DeletionReason',
),
'DeletionReason',
);

View File

@@ -0,0 +1,49 @@
/*
* 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 emojiRegex from 'emoji-regex';
const EMOJI_REGEX = emojiRegex();
const REGIONAL_INDICATOR_START = 0x1f1e6;
const REGIONAL_INDICATOR_END = 0x1f1ff;
function isSingleRegionalIndicator(value: string): boolean {
const codePoints = [...value];
if (codePoints.length !== 1) {
return false;
}
const codePoint = codePoints[0].codePointAt(0);
return codePoint !== undefined && codePoint >= REGIONAL_INDICATOR_START && codePoint <= REGIONAL_INDICATOR_END;
}
export function isValidSingleUnicodeEmoji(value: string): boolean {
if (!value || value.length === 0) {
return false;
}
EMOJI_REGEX.lastIndex = 0;
const match = EMOJI_REGEX.exec(value);
if (match && match.index === 0 && match[0] === value) {
return true;
}
return isSingleRegionalIndicator(value);
}

View File

@@ -0,0 +1,160 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
normalizeString,
withOpenApiType,
withStringLengthRangeValidation,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const WHITESPACE_REGEX = /\s+/g;
const NON_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}\p{M}_.-]/gu;
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
const WINDOWS_RESERVED_NAMES = /^(CON|PRN|AUX|NUL|COM[1-9]|LPT[1-9])(\.|$)/i;
function isValidBase64(value: string): boolean {
if (value.length % 4 !== 0) {
return false;
}
let padding = 0;
for (let i = value.length - 1; i >= 0; i--) {
if (value.charCodeAt(i) !== 61) {
break;
}
padding++;
}
if (padding > 2) {
return false;
}
const boundary = value.length - padding;
for (let i = 0; i < boundary; i++) {
const code = value.charCodeAt(i);
const isUpper = code >= 65 && code <= 90;
const isLower = code >= 97 && code <= 122;
const isDigit = code >= 48 && code <= 57;
const isPlus = code === 43;
const isSlash = code === 47;
if (!(isUpper || isLower || isDigit || isPlus || isSlash)) {
return false;
}
}
for (let i = boundary; i < value.length; i++) {
if (value.charCodeAt(i) !== 61) {
return false;
}
}
try {
const decoded = Buffer.from(value, 'base64');
if (decoded.length === 0) {
return value === '';
}
return decoded.toString('base64') === value;
} catch {
return false;
}
}
function normalizeFilename(value: string): string {
let normalized = normalizeString(value);
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte filtering is intentional for security
normalized = normalized.replace(/\x00/g, '');
normalized = normalized.replace(/[/\\]/g, '_');
normalized = normalized.replace(/\.{2,}/g, '_');
while (normalized.includes('..')) {
normalized = normalized.replace(/\.\./g, '_');
}
normalized = normalized.replace(/[<>:"|?*]/g, '');
if (WINDOWS_RESERVED_NAMES.test(normalized)) {
normalized = `_${normalized}`;
}
normalized = normalized.replace(WHITESPACE_REGEX, '_');
normalized = normalized.replace(NON_FILENAME_CHARS_REGEX, '');
normalized = normalized.replace(/\.\./g, '_');
normalized = normalized.replace(/[/\\]/g, '_');
if (!normalized || /^[._]+$/.test(normalized)) {
normalized = 'unnamed';
}
return normalized;
}
export const FilenameType = withStringLengthRangeValidation(
z.string(),
1,
255,
ValidationErrorCodes.FILENAME_LENGTH_INVALID,
)
.transform(normalizeFilename)
.refine((value) => value.length >= 1, ValidationErrorCodes.FILENAME_EMPTY_AFTER_NORMALIZATION)
.refine((value) => FILENAME_SAFE_REGEX.test(value), ValidationErrorCodes.FILENAME_INVALID_CHARACTERS);
export function createBase64StringType(minLength = 1, maxLength = 256) {
return withOpenApiType(
z
.string()
.superRefine((value, ctx) => {
const normalized = normalizeString(value);
const commaIndex = normalized.indexOf(',');
const base64 = commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
if (base64.length < minLength || base64.length > maxLength) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.BASE64_LENGTH_INVALID,
params: {min: minLength, maxLength},
});
return z.NEVER;
}
if (base64.length < 1 || !isValidBase64(base64)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_BASE64_FORMAT,
});
return z.NEVER;
}
})
.transform((value) => {
const normalized = normalizeString(value);
const commaIndex = normalized.indexOf(',');
return commaIndex !== -1 ? normalized.slice(commaIndex + 1) : normalized;
}),
'Base64ImageType',
);
}

View File

@@ -0,0 +1,112 @@
/*
* 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 {
GuildExplicitContentFilterTypes,
GuildMFALevel,
GuildNSFWLevel,
GuildSplashCardAlignment,
GuildVerificationLevel,
JoinSourceTypes,
} from '@fluxer/constants/src/GuildConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
createInt32EnumType,
createNamedLiteralUnion,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const GuildVerificationLevelSchema = createInt32EnumType(
[
[GuildVerificationLevel.NONE, 'NONE', 'Unrestricted'],
[GuildVerificationLevel.LOW, 'LOW', 'Must have verified email'],
[GuildVerificationLevel.MEDIUM, 'MEDIUM', 'Registered for more than 5 minutes'],
[GuildVerificationLevel.HIGH, 'HIGH', 'Member of the server for more than 10 minutes'],
[GuildVerificationLevel.VERY_HIGH, 'VERY_HIGH', 'Must have a verified phone number'],
],
'Required verification level for members',
'GuildVerificationLevel',
);
export const GuildMFALevelSchema = createInt32EnumType(
[
[GuildMFALevel.NONE, 'NONE', 'Guild has no MFA requirement'],
[GuildMFALevel.ELEVATED, 'ELEVATED', 'Guild requires 2FA for moderation actions'],
],
'Required MFA level for moderation actions',
'GuildMFALevel',
);
export const GuildExplicitContentFilterSchema = createInt32EnumType(
[
[GuildExplicitContentFilterTypes.DISABLED, 'DISABLED', 'Media content will not be scanned'],
[
GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES,
'MEMBERS_WITHOUT_ROLES',
'Media content from members without roles will be scanned',
],
[GuildExplicitContentFilterTypes.ALL_MEMBERS, 'ALL_MEMBERS', 'Media content from all members will be scanned'],
],
'Level of content filtering for explicit media',
'GuildExplicitContentFilter',
);
export const DefaultMessageNotificationsSchema = createInt32EnumType(
[
[MessageNotifications.ALL_MESSAGES, 'ALL_MESSAGES', 'Notify on all messages'],
[MessageNotifications.ONLY_MENTIONS, 'ONLY_MENTIONS', 'Notify only on mentions'],
],
'Default notification level for new members',
'DefaultMessageNotifications',
);
export const NSFWLevelSchema = createInt32EnumType(
[
[GuildNSFWLevel.DEFAULT, 'DEFAULT', 'Default NSFW level'],
[GuildNSFWLevel.EXPLICIT, 'EXPLICIT', 'Guild has explicit content'],
[GuildNSFWLevel.SAFE, 'SAFE', 'Guild is safe'],
[GuildNSFWLevel.AGE_RESTRICTED, 'AGE_RESTRICTED', 'Guild is age-restricted'],
],
'The NSFW level of the guild',
'NSFWLevel',
);
export const SplashCardAlignmentSchema = createNamedLiteralUnion(
[
[GuildSplashCardAlignment.CENTER, 'CENTER', 'Splash card is centred'],
[GuildSplashCardAlignment.LEFT, 'LEFT', 'Splash card is aligned to the left'],
[GuildSplashCardAlignment.RIGHT, 'RIGHT', 'Splash card is aligned to the right'],
] as const,
'Alignment of the guild splash card',
);
export const JoinSourceTypeSchema = withOpenApiType(
createInt32EnumType(
[
[JoinSourceTypes.CREATOR, 'CREATOR', 'Member created the guild'],
[JoinSourceTypes.INSTANT_INVITE, 'INSTANT_INVITE', 'Member joined via an instant invite'],
[JoinSourceTypes.VANITY_URL, 'VANITY_URL', 'Member joined via the vanity URL'],
[JoinSourceTypes.BOT_INVITE, 'BOT_INVITE', 'Member was added via a bot invite'],
[JoinSourceTypes.ADMIN_FORCE_ADD, 'ADMIN_FORCE_ADD', 'Member was force-added by a platform administrator'],
],
'How the member joined the guild',
'JoinSourceType',
),
'JoinSourceType',
);

View File

@@ -0,0 +1,35 @@
/*
* 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 {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const InviteTypeSchema = withOpenApiType(
createInt32EnumType(
[
[InviteTypes.GUILD, 'GUILD', 'Invite to a guild'],
[InviteTypes.GROUP_DM, 'GROUP_DM', 'Invite to a group DM'],
[InviteTypes.EMOJI_PACK, 'EMOJI_PACK', 'Invite to an emoji pack'],
[InviteTypes.STICKER_PACK, 'STICKER_PACK', 'Invite to a sticker pack'],
],
'The type of invite',
'InviteType',
),
'InviteType',
);

View File

@@ -0,0 +1,98 @@
/*
* 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 {createNamedStringLiteralUnion, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const LocaleSchema = withOpenApiType(
createNamedStringLiteralUnion(
[
['ar', 'AR', 'Arabic'],
['bg', 'BG', 'Bulgarian'],
['cs', 'CS', 'Czech'],
['da', 'DA', 'Danish'],
['de', 'DE', 'German'],
['el', 'EL', 'Greek'],
['en-GB', 'EN_GB', 'English (United Kingdom)'],
['en-US', 'EN_US', 'English (United States)'],
['es-ES', 'ES_ES', 'Spanish (Spain)'],
['es-419', 'ES_419', 'Spanish (Latin America)'],
['fi', 'FI', 'Finnish'],
['fr', 'FR', 'French'],
['he', 'HE', 'Hebrew'],
['hi', 'HI', 'Hindi'],
['hr', 'HR', 'Croatian'],
['hu', 'HU', 'Hungarian'],
['id', 'ID', 'Indonesian'],
['it', 'IT', 'Italian'],
['ja', 'JA', 'Japanese'],
['ko', 'KO', 'Korean'],
['lt', 'LT', 'Lithuanian'],
['nl', 'NL', 'Dutch'],
['no', 'NO', 'Norwegian'],
['pl', 'PL', 'Polish'],
['pt-BR', 'PT_BR', 'Portuguese (Brazil)'],
['ro', 'RO', 'Romanian'],
['ru', 'RU', 'Russian'],
['sv-SE', 'SV_SE', 'Swedish'],
['th', 'TH', 'Thai'],
['tr', 'TR', 'Turkish'],
['uk', 'UK', 'Ukrainian'],
['vi', 'VI', 'Vietnamese'],
['zh-CN', 'ZH_CN', 'Chinese (Simplified)'],
['zh-TW', 'ZH_TW', 'Chinese (Traditional)'],
] as const,
'The locale code for the user interface language',
),
'Locale',
);
export type Locale =
| 'ar'
| 'bg'
| 'cs'
| 'da'
| 'de'
| 'el'
| 'en-GB'
| 'en-US'
| 'es-ES'
| 'es-419'
| 'fi'
| 'fr'
| 'he'
| 'hi'
| 'hr'
| 'hu'
| 'id'
| 'it'
| 'ja'
| 'ko'
| 'lt'
| 'nl'
| 'no'
| 'pl'
| 'pt-BR'
| 'ro'
| 'ru'
| 'sv-SE'
| 'th'
| 'tr'
| 'uk'
| 'vi'
| 'zh-CN'
| 'zh-TW';

View File

@@ -0,0 +1,116 @@
/*
* 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 {
AllowedMentionParseTypes,
AllowedMentionParseTypesDescriptions,
EmbedMediaFlags,
EmbedMediaFlagsDescriptions,
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
MessageEmbedTypes,
MessageFlags,
MessageFlagsDescriptions,
MessageReferenceTypes,
MessageReferenceTypesDescriptions,
MessageTypes,
} from '@fluxer/constants/src/ChannelConstants';
import {
createBitflagInt32Type,
createInt32EnumType,
createNamedStringLiteralUnion,
withOpenApiType,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const MessageTypeSchema = withOpenApiType(
createInt32EnumType(
[
[MessageTypes.DEFAULT, 'DEFAULT', 'A regular message'],
[MessageTypes.RECIPIENT_ADD, 'RECIPIENT_ADD', 'A system message indicating a user was added to the conversation'],
[
MessageTypes.RECIPIENT_REMOVE,
'RECIPIENT_REMOVE',
'A system message indicating a user was removed from the conversation',
],
[MessageTypes.CALL, 'CALL', 'A message representing a call'],
[MessageTypes.CHANNEL_NAME_CHANGE, 'CHANNEL_NAME_CHANGE', 'A system message indicating the channel name changed'],
[MessageTypes.CHANNEL_ICON_CHANGE, 'CHANNEL_ICON_CHANGE', 'A system message indicating the channel icon changed'],
[
MessageTypes.CHANNEL_PINNED_MESSAGE,
'CHANNEL_PINNED_MESSAGE',
'A system message indicating a message was pinned',
],
[MessageTypes.USER_JOIN, 'USER_JOIN', 'A system message indicating a user joined'],
[MessageTypes.REPLY, 'REPLY', 'A reply message'],
],
'The type of message',
),
'MessageType',
);
export const MessageReferenceTypeSchema = createInt32EnumType(
[
[MessageReferenceTypes.DEFAULT, 'DEFAULT', MessageReferenceTypesDescriptions.DEFAULT],
[MessageReferenceTypes.FORWARD, 'FORWARD', MessageReferenceTypesDescriptions.FORWARD],
],
'The type of message reference',
'MessageReferenceType',
);
export const AllowedMentionParseTypeSchema = createNamedStringLiteralUnion(
[
[AllowedMentionParseTypes.USERS, 'USERS', AllowedMentionParseTypesDescriptions.USERS],
[AllowedMentionParseTypes.ROLES, 'ROLES', AllowedMentionParseTypesDescriptions.ROLES],
[AllowedMentionParseTypes.EVERYONE, 'EVERYONE', AllowedMentionParseTypesDescriptions.EVERYONE],
],
'Types of mentions to parse from content',
);
export const MessageFlagsSchema = withOpenApiType(
createBitflagInt32Type(MessageFlags, MessageFlagsDescriptions, 'Message bitflags', 'MessageFlags'),
'MessageFlags',
);
export const MessageAttachmentFlagsSchema = withOpenApiType(
createBitflagInt32Type(
MessageAttachmentFlags,
MessageAttachmentFlagsDescriptions,
'Message attachment bitflags',
'MessageAttachmentFlags',
),
'MessageAttachmentFlags',
);
export const EmbedMediaFlagsSchema = withOpenApiType(
createBitflagInt32Type(EmbedMediaFlags, EmbedMediaFlagsDescriptions, 'Embed media bitflags', 'EmbedMediaFlags'),
'EmbedMediaFlags',
);
export const MessageEmbedTypeSchema = createNamedStringLiteralUnion(
[
[MessageEmbedTypes.RICH, 'RICH', 'Rich embed with custom content'],
[MessageEmbedTypes.ARTICLE, 'ARTICLE', 'Article embed from a link'],
[MessageEmbedTypes.LINK, 'LINK', 'Link embed'],
[MessageEmbedTypes.IMAGE, 'IMAGE', 'Image embed'],
[MessageEmbedTypes.VIDEO, 'VIDEO', 'Video embed'],
[MessageEmbedTypes.AUDIO, 'AUDIO', 'Audio embed'],
[MessageEmbedTypes.GIFV, 'GIFV', 'Animated GIF video embed'],
],
'The type of embed',
);

View File

@@ -0,0 +1,26 @@
/*
* 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 {Permissions, PermissionsDescriptions} from '@fluxer/constants/src/ChannelConstants';
import {createPermissionStringType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const PermissionStringType = withOpenApiType(
createPermissionStringType(Permissions, PermissionsDescriptions, 'Permission bitfield as string', 'Permissions'),
'Permissions',
);

View File

@@ -0,0 +1,98 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {z} from 'zod';
const TRUE_VALUES = ['true', 'True', '1'];
const ISO_TIMESTAMP_REGEX = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/;
export const QueryBooleanType = z
.string()
.trim()
.optional()
.default('false')
.transform((value) => TRUE_VALUES.includes(value));
export function createQueryIntegerType({defaultValue = 0, minValue = 0, maxValue = 2147483647} = {}) {
return z
.string()
.trim()
.optional()
.default(defaultValue.toString())
.superRefine((value, ctx) => {
const num = Number.parseInt(value, 10);
if (Number.isNaN(num)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE,
params: {minValue, maxValue},
});
return z.NEVER;
}
if (!Number.isInteger(num) || num < minValue || num > maxValue) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE,
params: {minValue, maxValue},
});
return z.NEVER;
}
})
.transform((value) => {
const num = Number.parseInt(value, 10);
if (Number.isNaN(num)) {
throw new Error(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE);
}
return num;
});
}
export const DateTimeType = z.union([
z
.string()
.regex(ISO_TIMESTAMP_REGEX, ValidationErrorCodes.INVALID_ISO_TIMESTAMP)
.superRefine((value, ctx) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_ISO_TIMESTAMP,
});
return z.NEVER;
}
})
.transform((value) => new Date(value)),
z
.number()
.int()
.min(0)
.max(8640000000000000)
.superRefine((value, ctx) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_ISO_TIMESTAMP,
});
return z.NEVER;
}
})
.transform((value) => new Date(value)),
]);

View File

@@ -0,0 +1,463 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import type {ZodTypeAny} from 'zod';
import {z} from 'zod';
export function withOpenApiType<T extends ZodTypeAny>(schema: T, typeName: string): T {
(schema as Record<string, unknown>).__fluxer_custom_type__ = typeName;
return schema;
}
export function withFieldDescription<T extends z.ZodTypeAny>(schema: T, fieldDescription: string): T {
const currentDesc = schema.description ?? '';
const newDesc = currentDesc ? `${currentDesc}|fieldDesc:${fieldDescription}` : `|fieldDesc:${fieldDescription}`;
return schema.describe(newDesc) as T;
}
const RTL_OVERRIDE_REGEX = /\u202E/g;
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
const FORM_FEED_REGEX = /\u000C/g;
export const MAX_STRING_PROCESSING_LENGTH = 10_000;
export function normalizeString(value: string): string {
return (
value
.replace(RTL_OVERRIDE_REGEX, '')
.replace(FORM_FEED_REGEX, '')
// biome-ignore lint/suspicious/noControlCharactersInRegex: null byte and control character filtering is intentional for security
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, '')
.trim()
);
}
export const Int64Type = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
try {
const bigInt = BigInt(trimmed);
if (bigInt < -9223372036854775808n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:Int64Type');
export const UnsignedInt64Type = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
if (!/^\d+$/.test(trimmed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
try {
const bigInt = BigInt(trimmed);
if (bigInt < 0n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_INTEGER_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:UnsignedInt64Type');
export const Int64StringType = z
.string()
.regex(/^-?\d+$/)
.describe('fluxer:Int64StringType');
const SNOWFLAKE_REGEX = /^(0|[1-9][0-9]*)$/;
const UNSIGNED_INT64_STRING_REGEX = /^\d+$/;
export const SnowflakeStringType = z.string().regex(SNOWFLAKE_REGEX).describe('fluxer:SnowflakeStringType');
export const BitflagStringType = z.string().regex(UNSIGNED_INT64_STRING_REGEX).describe('fluxer:BitflagStringType');
const HEX_STRING_16_REGEX = /^[a-f0-9]{16}$/;
export const HexString16Type = z.string().regex(HEX_STRING_16_REGEX).describe('fluxer:HexString16Type');
const HEX_STRING_32_REGEX = /^[a-f0-9]{32}$/;
export const HexString32Type = z.string().regex(HEX_STRING_32_REGEX).describe('fluxer:HexString32Type');
export const SnowflakeType = z
.union([z.string(), z.number().int()])
.transform((value, ctx) => {
if (typeof value === 'number' && !Number.isSafeInteger(value)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
const normalized = typeof value === 'number' ? value.toString() : value;
const trimmed = normalized.trim();
if (!SNOWFLAKE_REGEX.test(trimmed)) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
try {
const bigInt = BigInt(trimmed);
if (bigInt < 0n || bigInt > 9223372036854775807n) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.SNOWFLAKE_OUT_OF_RANGE,
});
return z.NEVER;
}
return bigInt;
} catch {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.INVALID_SNOWFLAKE_FORMAT,
});
return z.NEVER;
}
})
.describe('fluxer:SnowflakeType');
export const ColorType = z
.number()
.int()
.min(0x000000, ValidationErrorCodes.COLOR_VALUE_TOO_LOW)
.max(0xffffff, ValidationErrorCodes.COLOR_VALUE_TOO_HIGH)
.describe('fluxer:ColorType');
export const Int32Type = z.number().int().min(0).max(2147483647).describe('fluxer:Int32Type');
export const SignedInt32Type = z.number().int().min(-2147483648).max(2147483647).describe('fluxer:SignedInt32Type');
const INTEGER_STRING_REGEX = /^[+-]?\d+$/;
function coerceNumericStringToNumber(value: unknown): unknown {
if (typeof value !== 'string') {
return value;
}
const trimmed = value.trim();
if (trimmed.length === 0 || !INTEGER_STRING_REGEX.test(trimmed)) {
return value;
}
const parsed = Number(trimmed);
return Number.isNaN(parsed) ? value : parsed;
}
export function coerceNumberFromString<T extends z.ZodNumber>(schema: T) {
return z.preprocess((value) => coerceNumericStringToNumber(value), schema);
}
export function withStringLengthRangeValidation(
schema: z.ZodString,
minLength: number,
maxLength: number,
errorCode: string,
) {
return schema.superRefine((value, ctx) => {
if (value.length < minLength || value.length > maxLength) {
const params: Record<string, unknown> = {min: minLength, max: maxLength};
if (minLength === maxLength) {
params.length = minLength;
}
ctx.addIssue({code: 'custom', message: errorCode, params});
}
});
}
export function createStringType(minLength = 1, maxLength = 256) {
const errorMessage =
minLength === maxLength ? ValidationErrorCodes.STRING_LENGTH_EXACT : ValidationErrorCodes.STRING_LENGTH_INVALID;
return z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), minLength, maxLength, errorMessage));
}
export function createUnboundedStringType() {
return z.string().transform(normalizeString);
}
const C0_C1_CTRL_REGEX =
// biome-ignore lint/suspicious/noControlCharactersInRegex: this is fine
/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F\u0080-\u009F]/g;
const JOIN_CONTROLS_REGEX = /(?:\u200C|\u200D)/g;
const WJ_BOM_REGEX = /(?:\u2060|\uFEFF)/g;
const BIDI_CTRL_REGEX = /[\u200E\u200F\u202A-\u202E\u2066-\u2069]/g;
const MISC_INVISIBLES_REGEX = /[\u00AD\u180E\uFFFE\uFFFF]/g;
const TAG_CHARS_REGEX = /[\u{E0000}-\u{E007F}]/gu;
const VARIATION_SELECTORS_BASIC = /[\uFE00-\uFE0F]/g;
const VARIATION_SELECTORS_IDEOGRAPHIC = /[\u{E0100}-\u{E01EF}]/gu;
const UNICODE_SPACES_REGEX = /[\s\u00A0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000]/g;
export function removeStandaloneSurrogates(value: string): string {
return Array.from(value)
.filter((char) => {
if (char.length > 1) {
return true;
}
const codePoint = char.codePointAt(0);
if (codePoint === undefined) {
return false;
}
return codePoint < 0xd800 || codePoint > 0xdfff;
})
.join('');
}
export function normalizeWhitespace(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s.replace(UNICODE_SPACES_REGEX, ' ').replace(/\s+/g, ' ').trim();
}
export function stripInvisibles(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s
.replace(C0_C1_CTRL_REGEX, '')
.replace(JOIN_CONTROLS_REGEX, '')
.replace(WJ_BOM_REGEX, '')
.replace(BIDI_CTRL_REGEX, '')
.replace(MISC_INVISIBLES_REGEX, '')
.replace(TAG_CHARS_REGEX, '');
}
export function stripVariationSelectors(s: string): string {
if (s.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
return s.replace(VARIATION_SELECTORS_BASIC, '').replace(VARIATION_SELECTORS_IDEOGRAPHIC, '');
}
interface EnumEntryJson {
n: string;
v: string | number;
d?: string;
}
export function createNamedLiteral<T extends number>(value: T, name: string, description?: string) {
const entry: EnumEntryJson = {n: name, v: value};
if (description) entry.d = description;
return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`);
}
export function createNamedLiteralUnion<T extends number>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
return z
.union(literals as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...Array<z.ZodLiteral<T>>])
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createNamedStringLiteral<T extends string>(value: T, name: string, description?: string) {
const entry: EnumEntryJson = {n: name, v: value};
if (description) entry.d = description;
return z.literal(value).describe(`fluxer:EnumValue:${JSON.stringify(entry)}`);
}
export function createNamedStringLiteralUnion<T extends string>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
return z
.union(literals as [z.ZodLiteral<T>, z.ZodLiteral<T>, ...Array<z.ZodLiteral<T>>])
.describe(`fluxer:EnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createFlexibleStringLiteralUnion<T extends string>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
) {
const literals = pairs.map(([value]) => z.literal(value));
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const descPart = description ? ` ${description}` : '';
const flexibleUnionOperands = [...literals, z.string()] as unknown as [
z.ZodLiteral<T>,
z.ZodLiteral<T>,
...Array<z.ZodLiteral<T> | z.ZodString>,
];
return z.union(flexibleUnionOperands).describe(`fluxer:FlexibleEnumValues:${JSON.stringify(entries)}${descPart}`);
}
export function createInt32EnumType<T extends number>(
pairs: ReadonlyArray<readonly [T, string] | readonly [T, string, string?]>,
description?: string,
typeName?: string,
) {
const entries: Array<EnumEntryJson> = pairs.map(([value, name, desc]) => {
const entry: EnumEntryJson = {n: name, v: value};
if (desc) entry.d = desc;
return entry;
});
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = description ? ` ${description}` : '';
return Int32Type.describe(`fluxer:Int32Enum${typeNamePart}:${JSON.stringify(entries)}${descPart}`);
}
type BitflagConstantsObject = Readonly<Record<string, number | bigint>>;
type BitflagDescriptionsObject<T extends BitflagConstantsObject> = Readonly<Partial<Record<keyof T, string>>>;
interface BitflagEntryJson {
n: string;
v: string;
d?: string;
}
function formatBitflagAnnotation<T extends BitflagConstantsObject>(
constants: T,
descriptions?: BitflagDescriptionsObject<T>,
): string {
const entries: Array<BitflagEntryJson> = Object.entries(constants)
.filter(([, value]) => typeof value === 'number' || typeof value === 'bigint')
.map(([name, value]) => {
const desc = descriptions?.[name as keyof T];
const entry: BitflagEntryJson = {n: name, v: value.toString()};
if (desc) entry.d = desc;
return entry;
});
return JSON.stringify(entries);
}
export function createBitflagStringType<T extends BitflagConstantsObject>(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return BitflagStringType.describe(`fluxer:Bitflags64${typeNamePart}:${annotation}${descPart}`);
}
export function createBitflagInt32Type<T extends BitflagConstantsObject>(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return Int32Type.describe(`fluxer:Bitflags32${typeNamePart}:${annotation}${descPart}`);
}
export function createPermissionStringType<T extends BitflagConstantsObject>(
constants: T,
descriptionOrDescriptions?: string | BitflagDescriptionsObject<T>,
description?: string,
typeName?: string,
) {
const descriptions = typeof descriptionOrDescriptions === 'object' ? descriptionOrDescriptions : undefined;
const overallDescription = typeof descriptionOrDescriptions === 'string' ? descriptionOrDescriptions : description;
const annotation = formatBitflagAnnotation(constants, descriptions);
const typeNamePart = typeName ? `:${typeName}` : '';
const descPart = overallDescription ? ` ${overallDescription}` : '';
return z
.string()
.regex(UNSIGNED_INT64_STRING_REGEX)
.describe(`fluxer:Permissions${typeNamePart}:${annotation}${descPart}`);
}

View File

@@ -0,0 +1,106 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {normalizeString, withStringLengthRangeValidation} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import validator from 'validator';
import {z} from 'zod';
const PROTOCOLS = ['http', 'https'];
const FILENAME_SAFE_REGEX = /^[\p{L}\p{N}\p{M}_.-]+$/u;
const URL_VALIDATOR_OPTIONS = {
require_protocol: true,
require_host: true,
disallow_auth: true,
allow_trailing_dot: false,
allow_protocol_relative_urls: false,
allow_fragments: false,
validate_length: true,
protocols: ['http', 'https'] as Array<string>,
} as const;
let isDevelopment = false;
export function setIsDevelopment(value: boolean): void {
isDevelopment = value;
}
function createUrlSchema(allowFragments: boolean) {
return z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 1, 2048, ValidationErrorCodes.URL_LENGTH_INVALID))
.refine((value) => {
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return false;
}
try {
const url = new URL(value);
return PROTOCOLS.includes(url.protocol.slice(0, -1));
} catch {
return false;
}
}, ValidationErrorCodes.INVALID_URL_FORMAT)
.refine(
(value) =>
validator.isURL(value, {
...URL_VALIDATOR_OPTIONS,
allow_fragments: allowFragments,
require_tld: !isDevelopment,
}),
ValidationErrorCodes.INVALID_URL_FORMAT,
);
}
export const URLType = createUrlSchema(false);
export const URLWithFragmentType = createUrlSchema(true);
export const AttachmentURLType = z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 1, 2048, ValidationErrorCodes.URL_LENGTH_INVALID))
.refine((value) => {
if (value.startsWith('attachment://')) {
const filename = value.slice(13);
if (filename.length === 0) {
return false;
}
return FILENAME_SAFE_REGEX.test(filename);
}
if (!value.startsWith('http://') && !value.startsWith('https://')) {
return false;
}
try {
const url = new URL(value);
return PROTOCOLS.includes(url.protocol.slice(0, -1));
} catch {
return false;
}
}, ValidationErrorCodes.INVALID_URL_OR_ATTACHMENT_FORMAT)
.refine((value) => {
if (value.startsWith('attachment://')) {
return true;
}
return validator.isURL(value, {
...URL_VALIDATOR_OPTIONS,
require_tld: !isDevelopment,
});
}, ValidationErrorCodes.INVALID_URL_FORMAT);

View File

@@ -0,0 +1,127 @@
/*
* 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 {
RelationshipTypes,
RelationshipTypesDescriptions,
RenderSpoilers,
RenderSpoilersDescriptions,
StickerAnimationOptions,
StickerAnimationOptionsDescriptions,
TimeFormatTypes,
TimeFormatTypesDescriptions,
UserAuthenticatorTypes,
UserAuthenticatorTypesDescriptions,
UserExplicitContentFilterTypes,
UserNotificationSettings,
UserNotificationSettingsDescriptions,
UserPremiumTypes,
UserPremiumTypesDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {createInt32EnumType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const StickerAnimationOptionsSchema = createInt32EnumType(
[
[StickerAnimationOptions.ALWAYS_ANIMATE, 'ALWAYS_ANIMATE', StickerAnimationOptionsDescriptions.ALWAYS_ANIMATE],
[
StickerAnimationOptions.ANIMATE_ON_INTERACTION,
'ANIMATE_ON_INTERACTION',
StickerAnimationOptionsDescriptions.ANIMATE_ON_INTERACTION,
],
[StickerAnimationOptions.NEVER_ANIMATE, 'NEVER_ANIMATE', StickerAnimationOptionsDescriptions.NEVER_ANIMATE],
],
'Sticker animation preference',
'StickerAnimationOptions',
);
export const RenderSpoilersSchema = createInt32EnumType(
[
[RenderSpoilers.ALWAYS, 'ALWAYS', RenderSpoilersDescriptions.ALWAYS],
[RenderSpoilers.ON_CLICK, 'ON_CLICK', RenderSpoilersDescriptions.ON_CLICK],
[RenderSpoilers.IF_MODERATOR, 'IF_MODERATOR', RenderSpoilersDescriptions.IF_MODERATOR],
],
'Spoiler rendering preference',
'RenderSpoilers',
);
export const TimeFormatTypesSchema = createInt32EnumType(
[
[TimeFormatTypes.AUTO, 'AUTO', TimeFormatTypesDescriptions.AUTO],
[TimeFormatTypes.TWELVE_HOUR, 'TWELVE_HOUR', TimeFormatTypesDescriptions.TWELVE_HOUR],
[TimeFormatTypes.TWENTY_FOUR_HOUR, 'TWENTY_FOUR_HOUR', TimeFormatTypesDescriptions.TWENTY_FOUR_HOUR],
],
'Time format preference',
'TimeFormatTypes',
);
export const UserNotificationSettingsSchema = createInt32EnumType(
[
[UserNotificationSettings.ALL_MESSAGES, 'ALL_MESSAGES', UserNotificationSettingsDescriptions.ALL_MESSAGES],
[UserNotificationSettings.ONLY_MENTIONS, 'ONLY_MENTIONS', UserNotificationSettingsDescriptions.ONLY_MENTIONS],
[UserNotificationSettings.NO_MESSAGES, 'NO_MESSAGES', UserNotificationSettingsDescriptions.NO_MESSAGES],
[UserNotificationSettings.INHERIT, 'INHERIT', UserNotificationSettingsDescriptions.INHERIT],
],
'Notification level preference',
'UserNotificationSettings',
);
export const RelationshipTypesSchema = createInt32EnumType(
[
[RelationshipTypes.FRIEND, 'FRIEND', RelationshipTypesDescriptions.FRIEND],
[RelationshipTypes.BLOCKED, 'BLOCKED', RelationshipTypesDescriptions.BLOCKED],
[RelationshipTypes.INCOMING_REQUEST, 'INCOMING_REQUEST', RelationshipTypesDescriptions.INCOMING_REQUEST],
[RelationshipTypes.OUTGOING_REQUEST, 'OUTGOING_REQUEST', RelationshipTypesDescriptions.OUTGOING_REQUEST],
],
'Relationship type',
'RelationshipTypes',
);
export const UserPremiumTypesSchema = createInt32EnumType(
[
[UserPremiumTypes.NONE, 'NONE', UserPremiumTypesDescriptions.NONE],
[UserPremiumTypes.SUBSCRIPTION, 'SUBSCRIPTION', UserPremiumTypesDescriptions.SUBSCRIPTION],
[UserPremiumTypes.LIFETIME, 'LIFETIME', UserPremiumTypesDescriptions.LIFETIME],
],
'Premium subscription type',
'UserPremiumTypes',
);
export const UserAuthenticatorTypesSchema = createInt32EnumType(
[
[UserAuthenticatorTypes.TOTP, 'TOTP', UserAuthenticatorTypesDescriptions.TOTP],
[UserAuthenticatorTypes.SMS, 'SMS', UserAuthenticatorTypesDescriptions.SMS],
[UserAuthenticatorTypes.WEBAUTHN, 'WEBAUTHN', UserAuthenticatorTypesDescriptions.WEBAUTHN],
],
'Authenticator type',
'UserAuthenticatorTypes',
);
export const UserExplicitContentFilterTypesSchema = createInt32EnumType(
[
[UserExplicitContentFilterTypes.DISABLED, 'DISABLED', 'Explicit content filter disabled'],
[UserExplicitContentFilterTypes.NON_FRIENDS, 'NON_FRIENDS', 'Filter explicit content from non-friends only'],
[
UserExplicitContentFilterTypes.FRIENDS_AND_NON_FRIENDS,
'FRIENDS_AND_NON_FRIENDS',
'Filter explicit content from all users',
],
],
'Explicit content filter level',
'UserExplicitContentFilterTypes',
);

View File

@@ -0,0 +1,157 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {
PublicUserFlags,
PublicUserFlagsDescriptions,
UserFlags,
UserFlagsDescriptions,
} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
createBitflagInt32Type,
createBitflagStringType,
MAX_STRING_PROCESSING_LENGTH,
normalizeString,
normalizeWhitespace,
removeStandaloneSurrogates,
stripInvisibles,
stripVariationSelectors,
withOpenApiType,
withStringLengthRangeValidation,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {z} from 'zod';
const EMAIL_LOCAL_REGEX = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+$/;
const DISCRIMINATOR_REGEX = /^\d{1,4}$/;
const FLUXER_TAG_REGEX = /^[a-zA-Z0-9_]+$/;
export const PHONE_E164_REGEX = /^\+[1-9]\d{1,14}$/;
function sanitizeUsername(value: string): string {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
throw new Error(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
let s = normalizeString(value);
s = removeStandaloneSurrogates(s);
s = stripInvisibles(s);
s = stripVariationSelectors(s);
s = normalizeWhitespace(s);
return s;
}
export const EmailType = withOpenApiType(
z
.string()
.transform(normalizeString)
.pipe(
withStringLengthRangeValidation(
z.string().email(ValidationErrorCodes.INVALID_EMAIL_FORMAT),
1,
254,
ValidationErrorCodes.EMAIL_LENGTH_INVALID,
),
)
.refine((value: string) => {
const atIndex = value.indexOf('@');
if (atIndex === -1) return false;
const local = value.slice(0, atIndex);
return EMAIL_LOCAL_REGEX.test(local);
}, ValidationErrorCodes.INVALID_EMAIL_LOCAL_PART),
'EmailType',
);
export const DiscriminatorType = z
.union([z.string(), z.number()])
.transform((value) => String(value))
.pipe(z.string().regex(DISCRIMINATOR_REGEX, ValidationErrorCodes.DISCRIMINATOR_INVALID_FORMAT))
.transform((value) => {
return Number.parseInt(value, 10);
});
export const UsernameType = withOpenApiType(
z
.string()
.transform((value) => value.trim())
.pipe(withStringLengthRangeValidation(z.string(), 1, 32, ValidationErrorCodes.USERNAME_LENGTH_INVALID))
.refine((value) => FLUXER_TAG_REGEX.test(value), ValidationErrorCodes.USERNAME_INVALID_CHARACTERS)
.refine((value) => {
const lowerValue = value.toLowerCase();
return lowerValue !== 'everyone' && lowerValue !== 'here';
}, ValidationErrorCodes.USERNAME_RESERVED_VALUE)
.refine((value) => {
const lowerValue = value.toLowerCase();
return !lowerValue.includes('fluxer') && !lowerValue.includes('system message');
}, ValidationErrorCodes.USERNAME_CANNOT_CONTAIN_RESERVED_TERMS),
'UsernameType',
);
export const GlobalNameType = z
.string()
.superRefine((value, ctx) => {
if (value.length > MAX_STRING_PROCESSING_LENGTH) {
ctx.addIssue({
code: 'custom',
message: ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID,
params: {min: 1, max: 32},
});
return z.NEVER;
}
})
.transform(sanitizeUsername)
.pipe(withStringLengthRangeValidation(z.string(), 1, 32, ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID))
.refine((value) => {
const lowerValue = value.toLowerCase();
return lowerValue !== 'everyone' && lowerValue !== 'here';
}, ValidationErrorCodes.GLOBAL_NAME_RESERVED_VALUE)
.refine((value) => {
const lowerValue = value.toLowerCase();
return !lowerValue.includes('system message');
}, ValidationErrorCodes.GLOBAL_NAME_CANNOT_CONTAIN_RESERVED_TERMS);
export const PasswordType = withOpenApiType(
z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 8, 256, ValidationErrorCodes.PASSWORD_LENGTH_INVALID)),
'PasswordType',
);
export const PhoneNumberType = withOpenApiType(
z
.string()
.transform(normalizeString)
.refine((value) => PHONE_E164_REGEX.test(value), ValidationErrorCodes.PHONE_NUMBER_INVALID_FORMAT),
'PhoneNumberType',
);
export const WebhookNameType = z
.string()
.transform(normalizeString)
.pipe(withStringLengthRangeValidation(z.string(), 1, 80, ValidationErrorCodes.WEBHOOK_NAME_LENGTH_INVALID));
export const UserFlagsSchema = withOpenApiType(
createBitflagStringType(UserFlags, UserFlagsDescriptions, 'User bitflags (64-bit)', 'UserFlags'),
'UserFlags',
);
export const PublicUserFlagsSchema = withOpenApiType(
createBitflagInt32Type(PublicUserFlags, PublicUserFlagsDescriptions, 'Public user bitflags', 'PublicUserFlags'),
'PublicUserFlags',
);

View File

@@ -0,0 +1,32 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createInt32EnumType, withOpenApiType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
export const WebhookTypeSchema = withOpenApiType(
createInt32EnumType(
[
[1, 'INCOMING', 'Incoming webhook'],
[2, 'CHANNEL_FOLLOWER', 'Channel follower webhook'],
],
'The type of webhook',
'WebhookType',
),
'WebhookType',
);

View File

@@ -0,0 +1,69 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {AuditLogActionType} from '@fluxer/constants/src/AuditLogActionType';
import {AuditLogActionTypeSchema} from '@fluxer/schema/src/primitives/AuditLogValidators';
import {describe, expect, it} from 'vitest';
describe('AuditLogActionTypeSchema', () => {
it('accepts valid audit log action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.GUILD_UPDATE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.GUILD_UPDATE);
}
});
it('accepts channel action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.CHANNEL_CREATE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.CHANNEL_CREATE);
}
});
it('accepts member action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.MEMBER_KICK);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.MEMBER_KICK);
}
});
it('accepts role action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.ROLE_CREATE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.ROLE_CREATE);
}
});
it('accepts message action types', () => {
const result = AuditLogActionTypeSchema.safeParse(AuditLogActionType.MESSAGE_DELETE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(AuditLogActionType.MESSAGE_DELETE);
}
});
it('rejects non-numeric values', () => {
const result = AuditLogActionTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,282 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
AuditLogReasonType,
ChannelNameType,
GeneralChannelNameType,
VanityURLCodeType,
} from '@fluxer/schema/src/primitives/ChannelValidators';
import {describe, expect, it} from 'vitest';
describe('ChannelNameType', () => {
it('accepts valid channel names', () => {
const result = ChannelNameType.safeParse('general');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('general');
}
});
it('converts to lowercase', () => {
const result = ChannelNameType.safeParse('GENERAL');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('general');
}
});
it('replaces spaces with hyphens', () => {
const result = ChannelNameType.safeParse('my channel');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-channel');
}
});
it('removes disallowed characters', () => {
const result = ChannelNameType.safeParse('my#channel@name');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('mychannelname');
}
});
it('handles unicode characters', () => {
const result = ChannelNameType.safeParse('channel-name');
expect(result.success).toBe(true);
});
it('rejects channels exceeding max length', () => {
const result = ChannelNameType.safeParse('a'.repeat(101));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
});
it('falls back to hyphen for empty result', () => {
const result = ChannelNameType.safeParse('###');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('-');
}
});
it('normalizes and trims input', () => {
const result = ChannelNameType.safeParse(' my-channel ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-channel');
}
});
});
describe('GeneralChannelNameType', () => {
it('accepts valid channel names with spaces', () => {
const result = GeneralChannelNameType.safeParse('General Chat');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('General Chat');
}
});
it('preserves case', () => {
const result = GeneralChannelNameType.safeParse('My Channel');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Channel');
}
});
it('normalizes multiple spaces to single space', () => {
const result = GeneralChannelNameType.safeParse('My Channel');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Channel');
}
});
it('trims whitespace', () => {
const result = GeneralChannelNameType.safeParse(' My Channel ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Channel');
}
});
it('rejects empty names after normalization', () => {
const result = GeneralChannelNameType.safeParse(' ');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.NAME_EMPTY_AFTER_NORMALIZATION);
}
});
it('rejects names exceeding max length', () => {
const result = GeneralChannelNameType.safeParse('a'.repeat(101));
expect(result.success).toBe(false);
});
});
describe('VanityURLCodeType', () => {
it('accepts valid vanity URL codes', () => {
const result = VanityURLCodeType.safeParse('myserver');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('myserver');
}
});
it('accepts codes with hyphens', () => {
const result = VanityURLCodeType.safeParse('my-server');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-server');
}
});
it('accepts codes with numbers', () => {
const result = VanityURLCodeType.safeParse('server123');
expect(result.success).toBe(true);
});
it('converts to lowercase', () => {
const result = VanityURLCodeType.safeParse('MyServer');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('myserver');
}
});
it('replaces spaces with hyphens', () => {
const result = VanityURLCodeType.safeParse('my server');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-server');
}
});
it('collapses multiple hyphens', () => {
const result = VanityURLCodeType.safeParse('my--server');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my-server');
}
});
it('rejects codes that are too short', () => {
const result = VanityURLCodeType.safeParse('a');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VANITY_URL_CODE_LENGTH_INVALID);
}
});
it('rejects codes that are too long', () => {
const result = VanityURLCodeType.safeParse('a'.repeat(33));
expect(result.success).toBe(false);
});
it('rejects codes starting with hyphen', () => {
const result = VanityURLCodeType.safeParse('-myserver');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VANITY_URL_INVALID_CHARACTERS);
}
});
it('rejects codes ending with hyphen', () => {
const result = VanityURLCodeType.safeParse('myserver-');
expect(result.success).toBe(false);
});
it('rejects codes with invalid characters', () => {
const result = VanityURLCodeType.safeParse('my_server');
expect(result.success).toBe(false);
});
});
describe('AuditLogReasonType', () => {
it('accepts valid audit log reasons', () => {
const result = AuditLogReasonType.safeParse('User was spamming');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('User was spamming');
}
});
it('accepts null', () => {
const result = AuditLogReasonType.safeParse(null);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('accepts undefined', () => {
const result = AuditLogReasonType.safeParse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('returns null for empty string', () => {
const result = AuditLogReasonType.safeParse('');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('returns null for whitespace-only string', () => {
const result = AuditLogReasonType.safeParse(' ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('returns null for strings exceeding max length', () => {
const result = AuditLogReasonType.safeParse('a'.repeat(513));
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeNull();
}
});
it('accepts strings at max length', () => {
const reason = 'a'.repeat(512);
const result = AuditLogReasonType.safeParse(reason);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(reason);
}
});
it('trims and normalizes reason', () => {
const result = AuditLogReasonType.safeParse(' Reason here ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Reason here');
}
});
});

View File

@@ -0,0 +1,195 @@
/*
* 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 {isValidSingleUnicodeEmoji} from '@fluxer/schema/src/primitives/EmojiValidators';
import {describe, expect, it} from 'vitest';
describe('isValidSingleUnicodeEmoji', () => {
describe('valid single emojis', () => {
it('accepts simple emoji', () => {
expect(isValidSingleUnicodeEmoji('👍')).toBe(true);
});
it('accepts common face emojis', () => {
expect(isValidSingleUnicodeEmoji('😀')).toBe(true);
expect(isValidSingleUnicodeEmoji('😂')).toBe(true);
expect(isValidSingleUnicodeEmoji('🥺')).toBe(true);
expect(isValidSingleUnicodeEmoji('😡')).toBe(true);
expect(isValidSingleUnicodeEmoji('🤔')).toBe(true);
});
it('accepts emoji with skin tone modifier', () => {
expect(isValidSingleUnicodeEmoji('👍🏿')).toBe(true);
expect(isValidSingleUnicodeEmoji('👍🏻')).toBe(true);
expect(isValidSingleUnicodeEmoji('👍🏽')).toBe(true);
});
it('accepts ZWJ sequence emojis', () => {
expect(isValidSingleUnicodeEmoji('👨‍👩‍👧‍👦')).toBe(true);
expect(isValidSingleUnicodeEmoji('👩‍💻')).toBe(true);
expect(isValidSingleUnicodeEmoji('🧑‍🎄')).toBe(true);
});
it('accepts ZWJ sequence with skin tone at correct position', () => {
expect(isValidSingleUnicodeEmoji('🧑🏿‍🎄')).toBe(true);
expect(isValidSingleUnicodeEmoji('👩🏻‍💻')).toBe(true);
});
it('accepts flag emojis', () => {
expect(isValidSingleUnicodeEmoji('🇺🇸')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇬🇧')).toBe(true);
expect(isValidSingleUnicodeEmoji('🏳️‍🌈')).toBe(true);
});
it('accepts single regional indicator symbols', () => {
expect(isValidSingleUnicodeEmoji('🇦')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇧')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇵')).toBe(true);
expect(isValidSingleUnicodeEmoji('🇿')).toBe(true);
});
it('accepts all 26 regional indicator symbols', () => {
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
expect(isValidSingleUnicodeEmoji(String.fromCodePoint(cp))).toBe(true);
}
});
it('accepts keycap emojis', () => {
expect(isValidSingleUnicodeEmoji('1⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('#️⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('*️⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('0⃣')).toBe(true);
expect(isValidSingleUnicodeEmoji('9⃣')).toBe(true);
});
it('accepts variation selector emojis', () => {
expect(isValidSingleUnicodeEmoji('❤️')).toBe(true);
expect(isValidSingleUnicodeEmoji('☀️')).toBe(true);
});
it('accepts text-style emojis without variation selector', () => {
expect(isValidSingleUnicodeEmoji('❤')).toBe(true);
expect(isValidSingleUnicodeEmoji('☀')).toBe(true);
expect(isValidSingleUnicodeEmoji('☺')).toBe(true);
});
it('accepts copyright, registered, and trademark symbols', () => {
expect(isValidSingleUnicodeEmoji('©')).toBe(true);
expect(isValidSingleUnicodeEmoji('©️')).toBe(true);
expect(isValidSingleUnicodeEmoji('®')).toBe(true);
expect(isValidSingleUnicodeEmoji('®️')).toBe(true);
expect(isValidSingleUnicodeEmoji('™')).toBe(true);
expect(isValidSingleUnicodeEmoji('™️')).toBe(true);
});
it('accepts animal and nature emojis', () => {
expect(isValidSingleUnicodeEmoji('🐱')).toBe(true);
expect(isValidSingleUnicodeEmoji('🌸')).toBe(true);
expect(isValidSingleUnicodeEmoji('🌍')).toBe(true);
});
it('accepts food and object emojis', () => {
expect(isValidSingleUnicodeEmoji('🍕')).toBe(true);
expect(isValidSingleUnicodeEmoji('🎸')).toBe(true);
expect(isValidSingleUnicodeEmoji('💎')).toBe(true);
});
it('accepts symbol emojis', () => {
expect(isValidSingleUnicodeEmoji('✅')).toBe(true);
expect(isValidSingleUnicodeEmoji('❌')).toBe(true);
expect(isValidSingleUnicodeEmoji('⚠️')).toBe(true);
expect(isValidSingleUnicodeEmoji('💯')).toBe(true);
});
});
describe('invalid inputs', () => {
it('rejects empty string', () => {
expect(isValidSingleUnicodeEmoji('')).toBe(false);
});
it('rejects plain text', () => {
expect(isValidSingleUnicodeEmoji('hello')).toBe(false);
expect(isValidSingleUnicodeEmoji('abc')).toBe(false);
});
it('rejects single ascii characters', () => {
expect(isValidSingleUnicodeEmoji('a')).toBe(false);
expect(isValidSingleUnicodeEmoji('1')).toBe(false);
expect(isValidSingleUnicodeEmoji('#')).toBe(false);
expect(isValidSingleUnicodeEmoji(' ')).toBe(false);
});
it('rejects multiple emojis', () => {
expect(isValidSingleUnicodeEmoji('👍👍')).toBe(false);
expect(isValidSingleUnicodeEmoji('🎉🎊')).toBe(false);
expect(isValidSingleUnicodeEmoji('👨‍👩‍👧‍👦👨‍👩‍👧')).toBe(false);
});
it('rejects multiple regional indicator symbols', () => {
expect(isValidSingleUnicodeEmoji('\u{1F1E6}\u{1F1E7}')).toBe(false);
});
it('rejects emoji with trailing text', () => {
expect(isValidSingleUnicodeEmoji('👍abc')).toBe(false);
expect(isValidSingleUnicodeEmoji('🎉!')).toBe(false);
});
it('rejects emoji with leading text', () => {
expect(isValidSingleUnicodeEmoji('abc👍')).toBe(false);
expect(isValidSingleUnicodeEmoji('!🎉')).toBe(false);
});
it('rejects unicode characters that are not emoji', () => {
expect(isValidSingleUnicodeEmoji('é')).toBe(false);
expect(isValidSingleUnicodeEmoji('中')).toBe(false);
expect(isValidSingleUnicodeEmoji('α')).toBe(false);
});
it('rejects regional indicator with trailing text', () => {
expect(isValidSingleUnicodeEmoji('\u{1F1F5}abc')).toBe(false);
});
it('rejects regional indicator with leading text', () => {
expect(isValidSingleUnicodeEmoji('abc\u{1F1F5}')).toBe(false);
});
});
describe('malformed emoji sequences', () => {
it('rejects skin tone at wrong position in ZWJ sequence', () => {
expect(isValidSingleUnicodeEmoji('🧑‍🎄🏿')).toBe(false);
});
it('accepts standalone skin tone modifier as valid emoji', () => {
expect(isValidSingleUnicodeEmoji('🏿')).toBe(true);
expect(isValidSingleUnicodeEmoji('🏻')).toBe(true);
});
it('rejects standalone ZWJ character', () => {
expect(isValidSingleUnicodeEmoji('\u200D')).toBe(false);
});
it('rejects emoji followed by standalone skin tone', () => {
expect(isValidSingleUnicodeEmoji('🎄🏿')).toBe(false);
});
it('rejects double skin tone modifiers', () => {
expect(isValidSingleUnicodeEmoji('👍🏿🏻')).toBe(false);
});
});
});

View File

@@ -0,0 +1,198 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {createBase64StringType, FilenameType} from '@fluxer/schema/src/primitives/FileValidators';
import {describe, expect, it} from 'vitest';
describe('FilenameType', () => {
it('accepts valid filenames', () => {
const result = FilenameType.safeParse('document.pdf');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('document.pdf');
}
});
it('accepts filenames with unicode characters', () => {
const result = FilenameType.safeParse('archivo.txt');
expect(result.success).toBe(true);
});
it('accepts filenames with numbers', () => {
const result = FilenameType.safeParse('file123.txt');
expect(result.success).toBe(true);
});
it('accepts filenames with underscores and hyphens', () => {
const result = FilenameType.safeParse('my_file-name.txt');
expect(result.success).toBe(true);
});
it('replaces path separators with underscores', () => {
const result = FilenameType.safeParse('path/to/file.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('path_to_file.txt');
}
});
it('replaces backslashes with underscores', () => {
const result = FilenameType.safeParse('path\\to\\file.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('path_to_file.txt');
}
});
it('replaces double dots with underscores', () => {
const result = FilenameType.safeParse('..file..txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toContain('..');
}
});
it('handles Windows reserved names by prefixing', () => {
const result = FilenameType.safeParse('CON.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('_CON.txt');
}
});
it('replaces spaces with underscores', () => {
const result = FilenameType.safeParse('my file.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('my_file.txt');
}
});
it('removes null bytes', () => {
const result = FilenameType.safeParse('file\x00name.txt');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).not.toContain('\x00');
}
});
it('falls back to "unnamed" for invalid filenames', () => {
const result = FilenameType.safeParse('...');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('unnamed');
}
});
it('rejects filenames exceeding max length', () => {
const result = FilenameType.safeParse('a'.repeat(256));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.FILENAME_LENGTH_INVALID);
}
});
it('rejects empty filenames', () => {
const result = FilenameType.safeParse('');
expect(result.success).toBe(false);
});
it('trims whitespace', () => {
const result = FilenameType.safeParse(' file.txt ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('file.txt');
}
});
});
describe('createBase64StringType', () => {
it('accepts valid base64 strings', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('SGVsbG8gV29ybGQ=');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('SGVsbG8gV29ybGQ=');
}
});
it('accepts base64 with data URL prefix and extracts base64 part', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('data:image/png;base64,SGVsbG8gV29ybGQ=');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('SGVsbG8gV29ybGQ=');
}
});
it('accepts base64 without padding', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('YQ==');
expect(result.success).toBe(true);
});
it('rejects invalid base64 characters', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('Invalid!Base64@');
expect(result.success).toBe(false);
});
it('rejects base64 exceeding max length', () => {
const Base64Type = createBase64StringType(1, 10);
const result = Base64Type.safeParse('SGVsbG8gV29ybGQ=');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.BASE64_LENGTH_INVALID);
}
});
it('rejects base64 shorter than min length', () => {
const Base64Type = createBase64StringType(100, 200);
const result = Base64Type.safeParse('YQ==');
expect(result.success).toBe(false);
});
it('rejects base64 with incorrect padding', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('YQ===');
expect(result.success).toBe(false);
});
it('rejects empty base64 even when min is 0', () => {
const Base64Type = createBase64StringType(0, 100);
const result = Base64Type.safeParse('');
expect(result.success).toBe(false);
});
it('handles base64 with plus and slash characters', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse('YWJj+/8=');
expect(result.success).toBe(true);
});
it('trims whitespace before validation', () => {
const Base64Type = createBase64StringType(1, 100);
const result = Base64Type.safeParse(' SGVsbG8gV29ybGQ= ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('SGVsbG8gV29ybGQ=');
}
});
});

View File

@@ -0,0 +1,196 @@
/*
* 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 {
GuildExplicitContentFilterTypes,
GuildMFALevel,
GuildNSFWLevel,
GuildSplashCardAlignment,
GuildVerificationLevel,
JoinSourceTypes,
} from '@fluxer/constants/src/GuildConstants';
import {MessageNotifications} from '@fluxer/constants/src/NotificationConstants';
import {
DefaultMessageNotificationsSchema,
GuildExplicitContentFilterSchema,
GuildMFALevelSchema,
GuildVerificationLevelSchema,
JoinSourceTypeSchema,
NSFWLevelSchema,
SplashCardAlignmentSchema,
} from '@fluxer/schema/src/primitives/GuildValidators';
import {describe, expect, it} from 'vitest';
describe('GuildVerificationLevelSchema', () => {
it('accepts none verification level', () => {
const result = GuildVerificationLevelSchema.safeParse(GuildVerificationLevel.NONE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildVerificationLevel.NONE);
}
});
it('accepts low verification level', () => {
const result = GuildVerificationLevelSchema.safeParse(GuildVerificationLevel.LOW);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildVerificationLevel.LOW);
}
});
it('accepts very high verification level', () => {
const result = GuildVerificationLevelSchema.safeParse(GuildVerificationLevel.VERY_HIGH);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildVerificationLevel.VERY_HIGH);
}
});
});
describe('GuildMFALevelSchema', () => {
it('accepts none MFA level', () => {
const result = GuildMFALevelSchema.safeParse(GuildMFALevel.NONE);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildMFALevel.NONE);
}
});
it('accepts elevated MFA level', () => {
const result = GuildMFALevelSchema.safeParse(GuildMFALevel.ELEVATED);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildMFALevel.ELEVATED);
}
});
});
describe('GuildExplicitContentFilterSchema', () => {
it('accepts disabled filter', () => {
const result = GuildExplicitContentFilterSchema.safeParse(GuildExplicitContentFilterTypes.DISABLED);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildExplicitContentFilterTypes.DISABLED);
}
});
it('accepts members without roles filter', () => {
const result = GuildExplicitContentFilterSchema.safeParse(GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildExplicitContentFilterTypes.MEMBERS_WITHOUT_ROLES);
}
});
it('accepts all members filter', () => {
const result = GuildExplicitContentFilterSchema.safeParse(GuildExplicitContentFilterTypes.ALL_MEMBERS);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildExplicitContentFilterTypes.ALL_MEMBERS);
}
});
});
describe('DefaultMessageNotificationsSchema', () => {
it('accepts all messages notification level', () => {
const result = DefaultMessageNotificationsSchema.safeParse(MessageNotifications.ALL_MESSAGES);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageNotifications.ALL_MESSAGES);
}
});
it('accepts only mentions notification level', () => {
const result = DefaultMessageNotificationsSchema.safeParse(MessageNotifications.ONLY_MENTIONS);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageNotifications.ONLY_MENTIONS);
}
});
});
describe('NSFWLevelSchema', () => {
it('accepts default NSFW level', () => {
const result = NSFWLevelSchema.safeParse(GuildNSFWLevel.DEFAULT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildNSFWLevel.DEFAULT);
}
});
it('accepts explicit NSFW level', () => {
const result = NSFWLevelSchema.safeParse(GuildNSFWLevel.EXPLICIT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildNSFWLevel.EXPLICIT);
}
});
it('accepts age-restricted NSFW level', () => {
const result = NSFWLevelSchema.safeParse(GuildNSFWLevel.AGE_RESTRICTED);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildNSFWLevel.AGE_RESTRICTED);
}
});
});
describe('SplashCardAlignmentSchema', () => {
it('accepts centre alignment', () => {
const result = SplashCardAlignmentSchema.safeParse(GuildSplashCardAlignment.CENTER);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildSplashCardAlignment.CENTER);
}
});
it('accepts left alignment', () => {
const result = SplashCardAlignmentSchema.safeParse(GuildSplashCardAlignment.LEFT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildSplashCardAlignment.LEFT);
}
});
it('accepts right alignment', () => {
const result = SplashCardAlignmentSchema.safeParse(GuildSplashCardAlignment.RIGHT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(GuildSplashCardAlignment.RIGHT);
}
});
it('rejects invalid alignments', () => {
const result = SplashCardAlignmentSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});
describe('JoinSourceTypeSchema', () => {
it('accepts all valid join source types', () => {
for (const value of Object.values(JoinSourceTypes)) {
const result = JoinSourceTypeSchema.safeParse(value);
expect(result.success).toBe(true);
}
});
it('rejects non-numeric values', () => {
expect(JoinSourceTypeSchema.safeParse('invalid').success).toBe(false);
});
});

View File

@@ -0,0 +1,84 @@
/*
* 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 {MessageReferenceTypes, MessageTypes} from '@fluxer/constants/src/ChannelConstants';
import {MessageReferenceTypeSchema, MessageTypeSchema} from '@fluxer/schema/src/primitives/MessageValidators';
import {describe, expect, it} from 'vitest';
describe('MessageTypeSchema', () => {
it('accepts default message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.DEFAULT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.DEFAULT);
}
});
it('accepts recipient add message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.RECIPIENT_ADD);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.RECIPIENT_ADD);
}
});
it('accepts call message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.CALL);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.CALL);
}
});
it('accepts reply message type', () => {
const result = MessageTypeSchema.safeParse(MessageTypes.REPLY);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageTypes.REPLY);
}
});
it('rejects non-numeric values', () => {
const result = MessageTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});
describe('MessageReferenceTypeSchema', () => {
it('accepts default reference type', () => {
const result = MessageReferenceTypeSchema.safeParse(MessageReferenceTypes.DEFAULT);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageReferenceTypes.DEFAULT);
}
});
it('accepts forward reference type', () => {
const result = MessageReferenceTypeSchema.safeParse(MessageReferenceTypes.FORWARD);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(MessageReferenceTypes.FORWARD);
}
});
it('rejects non-numeric values', () => {
const result = MessageReferenceTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,259 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {createQueryIntegerType, DateTimeType, QueryBooleanType} from '@fluxer/schema/src/primitives/QueryValidators';
import {describe, expect, it} from 'vitest';
describe('QueryBooleanType', () => {
it('parses "true" as true', () => {
const result = QueryBooleanType.safeParse('true');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it('parses "True" as true', () => {
const result = QueryBooleanType.safeParse('True');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it('parses "1" as true', () => {
const result = QueryBooleanType.safeParse('1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
it('parses "false" as false', () => {
const result = QueryBooleanType.safeParse('false');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('parses "0" as false', () => {
const result = QueryBooleanType.safeParse('0');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('defaults to false when undefined', () => {
const result = QueryBooleanType.safeParse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('parses any other string as false', () => {
const result = QueryBooleanType.safeParse('yes');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(false);
}
});
it('trims whitespace', () => {
const result = QueryBooleanType.safeParse(' true ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(true);
}
});
});
describe('createQueryIntegerType', () => {
it('parses valid integer strings', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('42');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(42);
}
});
it('uses default value when undefined', () => {
const IntType = createQueryIntegerType({defaultValue: 10});
const result = IntType.safeParse(undefined);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(10);
}
});
it('respects minimum value', () => {
const IntType = createQueryIntegerType({minValue: 5});
const result = IntType.safeParse('3');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE);
}
});
it('respects maximum value', () => {
const IntType = createQueryIntegerType({maxValue: 100});
const result = IntType.safeParse('150');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.VALUE_MUST_BE_INTEGER_IN_RANGE);
}
});
it('accepts value at minimum', () => {
const IntType = createQueryIntegerType({minValue: 5});
const result = IntType.safeParse('5');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(5);
}
});
it('accepts value at maximum', () => {
const IntType = createQueryIntegerType({maxValue: 100});
const result = IntType.safeParse('100');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(100);
}
});
it('rejects non-integer strings', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('not-a-number');
expect(result.success).toBe(false);
});
it('rejects floating point strings', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('3.14');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(3);
}
});
it('trims whitespace', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse(' 42 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(42);
}
});
it('handles zero correctly', () => {
const IntType = createQueryIntegerType();
const result = IntType.safeParse('0');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0);
}
});
});
describe('DateTimeType', () => {
it('accepts valid ISO timestamp strings', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00Z');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
expect(result.data.toISOString()).toBe('2024-01-15T12:30:00.000Z');
}
});
it('accepts ISO timestamps with milliseconds', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00.123Z');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts ISO timestamps with timezone offset', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00+05:00');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts ISO timestamps with negative timezone offset', () => {
const result = DateTimeType.safeParse('2024-01-15T12:30:00-08:00');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts valid Unix timestamps as numbers', () => {
const result = DateTimeType.safeParse(1705323000000);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
}
});
it('accepts zero timestamp', () => {
const result = DateTimeType.safeParse(0);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBeInstanceOf(Date);
expect(result.data.getTime()).toBe(0);
}
});
it('rejects invalid ISO timestamp format', () => {
const result = DateTimeType.safeParse('2024-01-15 12:30:00');
expect(result.success).toBe(false);
});
it('rejects non-date strings', () => {
const result = DateTimeType.safeParse('not-a-date');
expect(result.success).toBe(false);
});
it('rejects negative timestamps', () => {
const result = DateTimeType.safeParse(-1);
expect(result.success).toBe(false);
});
it('rejects timestamps exceeding maximum', () => {
const result = DateTimeType.safeParse(8640000000000001);
expect(result.success).toBe(false);
});
it('accepts maximum valid timestamp', () => {
const result = DateTimeType.safeParse(8640000000000000);
expect(result.success).toBe(true);
});
it('rejects floating point timestamps', () => {
const result = DateTimeType.safeParse(1705323000000.5);
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,469 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
ColorType,
coerceNumberFromString,
createStringType,
createUnboundedStringType,
Int32Type,
Int64StringType,
Int64Type,
normalizeString,
normalizeWhitespace,
removeStandaloneSurrogates,
stripInvisibles,
stripVariationSelectors,
UnsignedInt64Type,
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
import {describe, expect, it} from 'vitest';
import {z} from 'zod';
describe('normalizeString', () => {
it('removes RTL override characters', () => {
const input = 'hello\u202Eworld';
expect(normalizeString(input)).toBe('helloworld');
});
it('removes form feed characters', () => {
const input = 'hello\u000Cworld';
expect(normalizeString(input)).toBe('helloworld');
});
it('removes null bytes and control characters', () => {
const input = 'hello\x00\x01\x02\x03world';
expect(normalizeString(input)).toBe('helloworld');
});
it('trims whitespace', () => {
const input = ' hello world ';
expect(normalizeString(input)).toBe('hello world');
});
it('handles empty strings', () => {
expect(normalizeString('')).toBe('');
});
it('handles strings with only whitespace', () => {
expect(normalizeString(' ')).toBe('');
});
it('preserves normal text', () => {
const input = 'Hello, World!';
expect(normalizeString(input)).toBe('Hello, World!');
});
});
describe('Int64Type', () => {
it('accepts valid integer strings', () => {
const result = Int64Type.safeParse('12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
it('accepts valid integer numbers', () => {
const result = Int64Type.safeParse(12345);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
it('accepts negative integers', () => {
const result = Int64Type.safeParse('-9223372036854775808');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(-9223372036854775808n);
}
});
it('accepts maximum int64 value', () => {
const result = Int64Type.safeParse('9223372036854775807');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(9223372036854775807n);
}
});
it('rejects values exceeding int64 range', () => {
const result = Int64Type.safeParse('9223372036854775808');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE);
}
});
it('rejects values below int64 range', () => {
const result = Int64Type.safeParse('-9223372036854775809');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INTEGER_OUT_OF_INT64_RANGE);
}
});
it('rejects invalid integer strings', () => {
const result = Int64Type.safeParse('not-a-number');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_INTEGER_FORMAT);
}
});
it('rejects unsafe JavaScript integers', () => {
const result = Int64Type.safeParse(Number.MAX_SAFE_INTEGER + 1);
expect(result.success).toBe(false);
});
it('handles whitespace in string input', () => {
const result = Int64Type.safeParse(' 12345 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
});
describe('Int64StringType', () => {
it('accepts positive integer strings', () => {
const result = Int64StringType.safeParse('12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('12345');
}
});
it('accepts negative integer strings', () => {
const result = Int64StringType.safeParse('-12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('-12345');
}
});
it('rejects non-integer strings', () => {
const result = Int64StringType.safeParse('not-a-number');
expect(result.success).toBe(false);
});
});
describe('UnsignedInt64Type', () => {
it('accepts positive integer strings', () => {
const result = UnsignedInt64Type.safeParse('12345');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(12345n);
}
});
it('rejects negative integer strings', () => {
const result = UnsignedInt64Type.safeParse('-12345');
expect(result.success).toBe(false);
});
});
describe('ColorType', () => {
it('accepts valid color values', () => {
const result = ColorType.safeParse(0xff5500);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0xff5500);
}
});
it('accepts minimum color value (black)', () => {
const result = ColorType.safeParse(0x000000);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0x000000);
}
});
it('accepts maximum color value (white)', () => {
const result = ColorType.safeParse(0xffffff);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0xffffff);
}
});
it('rejects negative color values', () => {
const result = ColorType.safeParse(-1);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.COLOR_VALUE_TOO_LOW);
}
});
it('rejects color values exceeding max', () => {
const result = ColorType.safeParse(0x1000000);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.COLOR_VALUE_TOO_HIGH);
}
});
it('rejects non-integer values', () => {
const result = ColorType.safeParse(123.45);
expect(result.success).toBe(false);
});
});
describe('Int32Type', () => {
it('accepts valid int32 values', () => {
const result = Int32Type.safeParse(1000);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1000);
}
});
it('accepts zero', () => {
const result = Int32Type.safeParse(0);
expect(result.success).toBe(true);
});
it('accepts maximum int32 value', () => {
const result = Int32Type.safeParse(2147483647);
expect(result.success).toBe(true);
});
it('rejects negative values', () => {
const result = Int32Type.safeParse(-1);
expect(result.success).toBe(false);
});
it('rejects values exceeding int32 max', () => {
const result = Int32Type.safeParse(2147483648);
expect(result.success).toBe(false);
});
});
describe('coerceNumberFromString', () => {
it('coerces valid integer strings to numbers', () => {
const schema = coerceNumberFromString(z.number().int().min(0).max(100));
const result = schema.safeParse('50');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(50);
}
});
it('coerces negative integer strings', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse('-42');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(-42);
}
});
it('passes through numbers unchanged', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse(42);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(42);
}
});
it('does not coerce non-integer strings', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse('not-a-number');
expect(result.success).toBe(false);
});
it('handles empty strings', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse('');
expect(result.success).toBe(false);
});
it('handles whitespace trimming', () => {
const schema = coerceNumberFromString(z.number().int());
const result = schema.safeParse(' 123 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(123);
}
});
});
describe('createStringType', () => {
it('validates string within length bounds', () => {
const StringType = createStringType(1, 10);
const result = StringType.safeParse('hello');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('hello');
}
});
it('normalizes and trims input', () => {
const StringType = createStringType(1, 10);
const result = StringType.safeParse(' hello ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('hello');
}
});
it('rejects strings shorter than minimum', () => {
const StringType = createStringType(5, 10);
const result = StringType.safeParse('hi');
expect(result.success).toBe(false);
});
it('rejects strings longer than maximum', () => {
const StringType = createStringType(1, 5);
const result = StringType.safeParse('hello world');
expect(result.success).toBe(false);
});
it('uses STRING_LENGTH_EXACT for exact length requirement', () => {
const StringType = createStringType(5, 5);
const result = StringType.safeParse('hi');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.STRING_LENGTH_EXACT);
}
});
it('uses STRING_LENGTH_INVALID for range requirement', () => {
const StringType = createStringType(5, 10);
const result = StringType.safeParse('hi');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.STRING_LENGTH_INVALID);
}
});
});
describe('createUnboundedStringType', () => {
it('normalizes string without length validation', () => {
const UnboundedStringType = createUnboundedStringType();
const result = UnboundedStringType.safeParse(' hello\x00world ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('helloworld');
}
});
it('accepts empty strings', () => {
const UnboundedStringType = createUnboundedStringType();
const result = UnboundedStringType.safeParse('');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('');
}
});
});
describe('removeStandaloneSurrogates', () => {
it('preserves valid characters', () => {
expect(removeStandaloneSurrogates('hello')).toBe('hello');
});
it('preserves valid emoji (surrogate pairs)', () => {
expect(removeStandaloneSurrogates('hello\uD83D\uDE00world')).toBe('hello\uD83D\uDE00world');
});
it('removes standalone high surrogates', () => {
expect(removeStandaloneSurrogates('hello\uD83Dworld')).toBe('helloworld');
});
it('removes standalone low surrogates', () => {
expect(removeStandaloneSurrogates('hello\uDE00world')).toBe('helloworld');
});
it('handles empty strings', () => {
expect(removeStandaloneSurrogates('')).toBe('');
});
});
describe('normalizeWhitespace', () => {
it('collapses multiple spaces to single space', () => {
expect(normalizeWhitespace('hello world')).toBe('hello world');
});
it('normalizes unicode spaces', () => {
expect(normalizeWhitespace('hello\u00A0world')).toBe('hello world');
});
it('trims leading and trailing whitespace', () => {
expect(normalizeWhitespace(' hello world ')).toBe('hello world');
});
it('handles strings with only whitespace', () => {
expect(normalizeWhitespace(' ')).toBe('');
});
it('throws on excessively long strings', () => {
const longString = 'a'.repeat(10001);
expect(() => normalizeWhitespace(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID);
});
});
describe('stripInvisibles', () => {
it('removes C0 and C1 control characters', () => {
expect(stripInvisibles('hello\x00\x01\x02world')).toBe('helloworld');
});
it('removes zero-width joiner and non-joiner', () => {
expect(stripInvisibles('hello\u200C\u200Dworld')).toBe('helloworld');
});
it('removes word joiner and BOM', () => {
expect(stripInvisibles('hello\u2060\uFEFFworld')).toBe('helloworld');
});
it('removes bidirectional control characters', () => {
expect(stripInvisibles('hello\u200E\u200F\u202Aworld')).toBe('helloworld');
});
it('preserves normal text', () => {
expect(stripInvisibles('Hello, World!')).toBe('Hello, World!');
});
it('throws on excessively long strings', () => {
const longString = 'a'.repeat(10001);
expect(() => stripInvisibles(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID);
});
});
describe('stripVariationSelectors', () => {
it('removes basic variation selectors', () => {
expect(stripVariationSelectors('hello\uFE0Fworld')).toBe('helloworld');
});
it('removes ideographic variation selectors', () => {
expect(stripVariationSelectors('hello\u{E0100}world')).toBe('helloworld');
});
it('preserves normal text', () => {
expect(stripVariationSelectors('Hello, World!')).toBe('Hello, World!');
});
it('throws on excessively long strings', () => {
const longString = 'a'.repeat(10001);
expect(() => stripVariationSelectors(longString)).toThrow(ValidationErrorCodes.STRING_LENGTH_INVALID);
});
});

View File

@@ -0,0 +1,235 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
AttachmentURLType,
setIsDevelopment,
URLType,
URLWithFragmentType,
} from '@fluxer/schema/src/primitives/UrlValidators';
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
describe('URLType', () => {
it('accepts valid HTTPS URLs', () => {
const result = URLType.safeParse('https://example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('https://example.com');
}
});
it('accepts valid HTTP URLs', () => {
const result = URLType.safeParse('http://example.com');
expect(result.success).toBe(true);
});
it('accepts URLs with paths', () => {
const result = URLType.safeParse('https://example.com/path/to/resource');
expect(result.success).toBe(true);
});
it('accepts URLs with query parameters', () => {
const result = URLType.safeParse('https://example.com/page?param=value');
expect(result.success).toBe(true);
});
it('accepts URLs with subdomains', () => {
const result = URLType.safeParse('https://sub.domain.example.com');
expect(result.success).toBe(true);
});
it('accepts URLs with ports', () => {
const result = URLType.safeParse('https://example.com:8080');
expect(result.success).toBe(true);
});
it('rejects URLs without protocol', () => {
const result = URLType.safeParse('example.com');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_URL_FORMAT);
}
});
it('rejects URLs with fragments', () => {
const result = URLType.safeParse('https://example.com#section');
expect(result.success).toBe(false);
});
it('rejects FTP URLs', () => {
const result = URLType.safeParse('ftp://example.com');
expect(result.success).toBe(false);
});
it('rejects file URLs', () => {
const result = URLType.safeParse('file:///path/to/file');
expect(result.success).toBe(false);
});
it('rejects URLs exceeding max length', () => {
const longPath = 'a'.repeat(2040);
const result = URLType.safeParse(`https://example.com/${longPath}`);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.URL_LENGTH_INVALID);
}
});
it('rejects empty strings', () => {
const result = URLType.safeParse('');
expect(result.success).toBe(false);
});
it('trims whitespace', () => {
const result = URLType.safeParse(' https://example.com ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('https://example.com');
}
});
});
describe('URLWithFragmentType', () => {
it('accepts URLs with fragments', () => {
const result = URLWithFragmentType.safeParse('https://example.com#section');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('https://example.com#section');
}
});
it('accepts URLs without fragments', () => {
const result = URLWithFragmentType.safeParse('https://example.com');
expect(result.success).toBe(true);
});
it('accepts complex fragments', () => {
const result = URLWithFragmentType.safeParse('https://example.com/page#section-1.2');
expect(result.success).toBe(true);
});
});
describe('AttachmentURLType', () => {
it('accepts valid HTTPS URLs', () => {
const result = AttachmentURLType.safeParse('https://example.com/image.png');
expect(result.success).toBe(true);
});
it('accepts valid attachment:// URLs', () => {
const result = AttachmentURLType.safeParse('attachment://image.png');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('attachment://image.png');
}
});
it('accepts attachment URLs with unicode filenames', () => {
const result = AttachmentURLType.safeParse('attachment://archivo.png');
expect(result.success).toBe(true);
});
it('accepts attachment URLs with numbers', () => {
const result = AttachmentURLType.safeParse('attachment://file123.txt');
expect(result.success).toBe(true);
});
it('accepts attachment URLs with underscores and hyphens', () => {
const result = AttachmentURLType.safeParse('attachment://my_file-name.txt');
expect(result.success).toBe(true);
});
it('rejects attachment URLs with empty filename', () => {
const result = AttachmentURLType.safeParse('attachment://');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.INVALID_URL_OR_ATTACHMENT_FORMAT);
}
});
it('rejects attachment URLs with invalid filename characters', () => {
const result = AttachmentURLType.safeParse('attachment://file name.txt');
expect(result.success).toBe(false);
});
it('rejects attachment URLs with path separators', () => {
const result = AttachmentURLType.safeParse('attachment://path/file.txt');
expect(result.success).toBe(false);
});
it('rejects URLs without valid protocol', () => {
const result = AttachmentURLType.safeParse('ftp://example.com/file.txt');
expect(result.success).toBe(false);
});
it('rejects URLs exceeding max length', () => {
const longFilename = 'a'.repeat(2040);
const result = AttachmentURLType.safeParse(`https://example.com/${longFilename}`);
expect(result.success).toBe(false);
});
it('trims and normalizes input', () => {
const result = AttachmentURLType.safeParse(' attachment://file.txt ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('attachment://file.txt');
}
});
});
describe('URL validators in development mode', () => {
beforeEach(() => {
setIsDevelopment(true);
});
afterEach(() => {
setIsDevelopment(false);
});
it('accepts localhost URLs in development mode', () => {
const result = URLType.safeParse('http://localhost:3000');
expect(result.success).toBe(true);
});
it('accepts URLs without TLD in development mode', () => {
const result = URLType.safeParse('http://myservice:8080');
expect(result.success).toBe(true);
});
it('accepts attachment URLs to local services', () => {
const result = AttachmentURLType.safeParse('http://localhost:3000/image.png');
expect(result.success).toBe(true);
});
});
describe('URL validators in production mode', () => {
beforeEach(() => {
setIsDevelopment(false);
});
it('rejects localhost URLs in production mode', () => {
const result = URLType.safeParse('http://localhost:3000');
expect(result.success).toBe(false);
});
it('rejects URLs without TLD in production mode', () => {
const result = URLType.safeParse('http://myservice:8080');
expect(result.success).toBe(false);
});
});

View File

@@ -0,0 +1,389 @@
/*
* 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 {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {
DiscriminatorType,
EmailType,
GlobalNameType,
PasswordType,
PhoneNumberType,
UsernameType,
WebhookNameType,
} from '@fluxer/schema/src/primitives/UserValidators';
import {describe, expect, it} from 'vitest';
describe('EmailType', () => {
it('accepts valid email addresses', () => {
const result = EmailType.safeParse('user@example.com');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('user@example.com');
}
});
it('accepts emails with subdomains', () => {
const result = EmailType.safeParse('user@mail.example.com');
expect(result.success).toBe(true);
});
it('accepts emails with plus addressing', () => {
const result = EmailType.safeParse('user+tag@example.com');
expect(result.success).toBe(true);
});
it('rejects emails without @ symbol', () => {
const result = EmailType.safeParse('userexample.com');
expect(result.success).toBe(false);
});
it('rejects emails without domain', () => {
const result = EmailType.safeParse('user@');
expect(result.success).toBe(false);
});
it('rejects emails with invalid local part characters', () => {
const result = EmailType.safeParse('user name@example.com');
expect(result.success).toBe(false);
});
it('rejects emails exceeding max length', () => {
const longLocal = 'a'.repeat(250);
const result = EmailType.safeParse(`${longLocal}@example.com`);
expect(result.success).toBe(false);
});
it('trims emails with leading/trailing whitespace', () => {
const result = EmailType.safeParse(' user@example.com ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('user@example.com');
}
});
});
describe('DiscriminatorType', () => {
it('accepts valid single digit discriminators', () => {
const result = DiscriminatorType.safeParse('1');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1);
}
});
it('accepts valid four digit discriminators', () => {
const result = DiscriminatorType.safeParse('1234');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1234);
}
});
it('accepts zero as discriminator', () => {
const result = DiscriminatorType.safeParse('0');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(0);
}
});
it('accepts discriminators with leading zeros', () => {
const result = DiscriminatorType.safeParse('0001');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1);
}
});
it('rejects discriminators with more than 4 digits', () => {
const result = DiscriminatorType.safeParse('12345');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.DISCRIMINATOR_INVALID_FORMAT);
}
});
it('rejects non-numeric discriminators', () => {
const result = DiscriminatorType.safeParse('abc');
expect(result.success).toBe(false);
});
it('rejects negative discriminators', () => {
const result = DiscriminatorType.safeParse('-1');
expect(result.success).toBe(false);
});
});
describe('UsernameType', () => {
it('accepts valid alphanumeric usernames', () => {
const result = UsernameType.safeParse('testuser');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('testuser');
}
});
it('accepts usernames with underscores', () => {
const result = UsernameType.safeParse('test_user');
expect(result.success).toBe(true);
});
it('accepts usernames with numbers', () => {
const result = UsernameType.safeParse('user123');
expect(result.success).toBe(true);
});
it('trims whitespace', () => {
const result = UsernameType.safeParse(' testuser ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('testuser');
}
});
it('rejects empty usernames', () => {
const result = UsernameType.safeParse('');
expect(result.success).toBe(false);
});
it('rejects usernames exceeding max length', () => {
const result = UsernameType.safeParse('a'.repeat(33));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_LENGTH_INVALID);
}
});
it('rejects usernames with invalid characters', () => {
const result = UsernameType.safeParse('test-user');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_INVALID_CHARACTERS);
}
});
it('rejects "everyone" as username', () => {
const result = UsernameType.safeParse('everyone');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_RESERVED_VALUE);
}
});
it('rejects "here" as username', () => {
const result = UsernameType.safeParse('here');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_RESERVED_VALUE);
}
});
it('rejects usernames containing "fluxer"', () => {
const result = UsernameType.safeParse('fluxeruser');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.USERNAME_CANNOT_CONTAIN_RESERVED_TERMS);
}
});
it('rejects reserved terms case-insensitively', () => {
const result = UsernameType.safeParse('EVERYONE');
expect(result.success).toBe(false);
});
});
describe('GlobalNameType', () => {
it('accepts valid display names', () => {
const result = GlobalNameType.safeParse('Test User');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Test User');
}
});
it('accepts names with unicode characters', () => {
const result = GlobalNameType.safeParse('Test User');
expect(result.success).toBe(true);
});
it('sanitizes and normalizes input', () => {
const result = GlobalNameType.safeParse(' Test User ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('Test User');
}
});
it('rejects empty names', () => {
const result = GlobalNameType.safeParse('');
expect(result.success).toBe(false);
});
it('rejects names exceeding max length', () => {
const result = GlobalNameType.safeParse('a'.repeat(33));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_LENGTH_INVALID);
}
});
it('rejects "everyone" as global name', () => {
const result = GlobalNameType.safeParse('everyone');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_RESERVED_VALUE);
}
});
it('rejects "here" as global name', () => {
const result = GlobalNameType.safeParse('here');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_RESERVED_VALUE);
}
});
it('rejects names containing "system message"', () => {
const result = GlobalNameType.safeParse('My System Message');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.GLOBAL_NAME_CANNOT_CONTAIN_RESERVED_TERMS);
}
});
});
describe('PasswordType', () => {
it('accepts valid passwords', () => {
const result = PasswordType.safeParse('securepassword123');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('securepassword123');
}
});
it('accepts passwords at minimum length', () => {
const result = PasswordType.safeParse('12345678');
expect(result.success).toBe(true);
});
it('rejects passwords shorter than minimum', () => {
const result = PasswordType.safeParse('1234567');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.PASSWORD_LENGTH_INVALID);
}
});
it('rejects passwords exceeding maximum length', () => {
const result = PasswordType.safeParse('a'.repeat(257));
expect(result.success).toBe(false);
});
it('trims and normalizes password', () => {
const result = PasswordType.safeParse(' password123 ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('password123');
}
});
});
describe('PhoneNumberType', () => {
it('accepts valid E.164 phone numbers', () => {
const result = PhoneNumberType.safeParse('+14155551234');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('+14155551234');
}
});
it('accepts international phone numbers', () => {
const result = PhoneNumberType.safeParse('+442071234567');
expect(result.success).toBe(true);
});
it('rejects phone numbers without plus prefix', () => {
const result = PhoneNumberType.safeParse('14155551234');
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.PHONE_NUMBER_INVALID_FORMAT);
}
});
it('rejects phone numbers starting with +0', () => {
const result = PhoneNumberType.safeParse('+04155551234');
expect(result.success).toBe(false);
});
it('rejects phone numbers with invalid characters', () => {
const result = PhoneNumberType.safeParse('+1-415-555-1234');
expect(result.success).toBe(false);
});
it('rejects phone numbers that are too short', () => {
const result = PhoneNumberType.safeParse('+1');
expect(result.success).toBe(false);
});
it('rejects phone numbers that are too long', () => {
const result = PhoneNumberType.safeParse('+1234567890123456');
expect(result.success).toBe(false);
});
});
describe('WebhookNameType', () => {
it('accepts valid webhook names', () => {
const result = WebhookNameType.safeParse('My Webhook');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Webhook');
}
});
it('accepts single character names', () => {
const result = WebhookNameType.safeParse('A');
expect(result.success).toBe(true);
});
it('accepts names at maximum length', () => {
const result = WebhookNameType.safeParse('a'.repeat(80));
expect(result.success).toBe(true);
});
it('rejects empty names', () => {
const result = WebhookNameType.safeParse('');
expect(result.success).toBe(false);
});
it('rejects names exceeding maximum length', () => {
const result = WebhookNameType.safeParse('a'.repeat(81));
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toBe(ValidationErrorCodes.WEBHOOK_NAME_LENGTH_INVALID);
}
});
it('trims and normalizes webhook names', () => {
const result = WebhookNameType.safeParse(' My Webhook ');
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe('My Webhook');
}
});
});

View File

@@ -0,0 +1,44 @@
/*
* 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 {WebhookTypeSchema} from '@fluxer/schema/src/primitives/WebhookValidators';
import {describe, expect, it} from 'vitest';
describe('WebhookTypeSchema', () => {
it('accepts incoming webhook type', () => {
const result = WebhookTypeSchema.safeParse(1);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(1);
}
});
it('accepts channel follower webhook type', () => {
const result = WebhookTypeSchema.safeParse(2);
expect(result.success).toBe(true);
if (result.success) {
expect(result.data).toBe(2);
}
});
it('rejects non-numeric values', () => {
const result = WebhookTypeSchema.safeParse('invalid');
expect(result.success).toBe(false);
});
});