refactor progress
This commit is contained in:
66
packages/schema/src/primitives/AuditLogValidators.tsx
Normal file
66
packages/schema/src/primitives/AuditLogValidators.tsx
Normal 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',
|
||||
);
|
||||
178
packages/schema/src/primitives/ChannelValidators.tsx
Normal file
178
packages/schema/src/primitives/ChannelValidators.tsx
Normal 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;
|
||||
});
|
||||
61
packages/schema/src/primitives/DeletionValidators.tsx
Normal file
61
packages/schema/src/primitives/DeletionValidators.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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',
|
||||
);
|
||||
49
packages/schema/src/primitives/EmojiValidators.tsx
Normal file
49
packages/schema/src/primitives/EmojiValidators.tsx
Normal 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);
|
||||
}
|
||||
160
packages/schema/src/primitives/FileValidators.tsx
Normal file
160
packages/schema/src/primitives/FileValidators.tsx
Normal 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',
|
||||
);
|
||||
}
|
||||
112
packages/schema/src/primitives/GuildValidators.tsx
Normal file
112
packages/schema/src/primitives/GuildValidators.tsx
Normal 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',
|
||||
);
|
||||
35
packages/schema/src/primitives/InviteValidators.tsx
Normal file
35
packages/schema/src/primitives/InviteValidators.tsx
Normal 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',
|
||||
);
|
||||
98
packages/schema/src/primitives/LocaleSchema.tsx
Normal file
98
packages/schema/src/primitives/LocaleSchema.tsx
Normal 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';
|
||||
116
packages/schema/src/primitives/MessageValidators.tsx
Normal file
116
packages/schema/src/primitives/MessageValidators.tsx
Normal 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',
|
||||
);
|
||||
26
packages/schema/src/primitives/PermissionValidators.tsx
Normal file
26
packages/schema/src/primitives/PermissionValidators.tsx
Normal 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',
|
||||
);
|
||||
98
packages/schema/src/primitives/QueryValidators.tsx
Normal file
98
packages/schema/src/primitives/QueryValidators.tsx
Normal 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)),
|
||||
]);
|
||||
463
packages/schema/src/primitives/SchemaPrimitives.tsx
Normal file
463
packages/schema/src/primitives/SchemaPrimitives.tsx
Normal 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}`);
|
||||
}
|
||||
106
packages/schema/src/primitives/UrlValidators.tsx
Normal file
106
packages/schema/src/primitives/UrlValidators.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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);
|
||||
127
packages/schema/src/primitives/UserSettingsValidators.tsx
Normal file
127
packages/schema/src/primitives/UserSettingsValidators.tsx
Normal 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',
|
||||
);
|
||||
157
packages/schema/src/primitives/UserValidators.tsx
Normal file
157
packages/schema/src/primitives/UserValidators.tsx
Normal 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',
|
||||
);
|
||||
32
packages/schema/src/primitives/WebhookValidators.tsx
Normal file
32
packages/schema/src/primitives/WebhookValidators.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Fluxer Contributors
|
||||
*
|
||||
* This file is part of Fluxer.
|
||||
*
|
||||
* Fluxer is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* Fluxer is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {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',
|
||||
);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
282
packages/schema/src/primitives/tests/ChannelValidators.test.tsx
Normal file
282
packages/schema/src/primitives/tests/ChannelValidators.test.tsx
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
195
packages/schema/src/primitives/tests/EmojiValidators.test.tsx
Normal file
195
packages/schema/src/primitives/tests/EmojiValidators.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
198
packages/schema/src/primitives/tests/FileValidators.test.tsx
Normal file
198
packages/schema/src/primitives/tests/FileValidators.test.tsx
Normal 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=');
|
||||
}
|
||||
});
|
||||
});
|
||||
196
packages/schema/src/primitives/tests/GuildValidators.test.tsx
Normal file
196
packages/schema/src/primitives/tests/GuildValidators.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
259
packages/schema/src/primitives/tests/QueryValidators.test.tsx
Normal file
259
packages/schema/src/primitives/tests/QueryValidators.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
469
packages/schema/src/primitives/tests/SchemaPrimitives.test.tsx
Normal file
469
packages/schema/src/primitives/tests/SchemaPrimitives.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
235
packages/schema/src/primitives/tests/UrlValidators.test.tsx
Normal file
235
packages/schema/src/primitives/tests/UrlValidators.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
389
packages/schema/src/primitives/tests/UserValidators.test.tsx
Normal file
389
packages/schema/src/primitives/tests/UserValidators.test.tsx
Normal 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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user