fix(app,api): kick reasons, blocked counts and locale fallbacks (#3334)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:40:08 +02:00
1 parent a5c1362f4a
commit 4db13b0375
157 files changed
+3080 -1995

No files matched your search

+2 -1
View File
@@ -4,6 +4,7 @@ import {ErrorCodeToI18nKey} from '@fluxer/errors/src/i18n/ErrorCodeMappings';
import {ERROR_I18N_LOCALE_MESSAGES} from '@fluxer/errors/src/i18n/ErrorI18nLocales';
import {ERROR_I18N_MESSAGES, type ErrorI18nCatalog, type ErrorI18nKey} from '@fluxer/errors/src/i18n/ErrorI18nMessages';
import {identityLocale} from '@fluxer/i18n/src/normalization/IdentityLocale';
import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage';
import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n';
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
import type {
@@ -34,7 +35,7 @@ const errorI18n = createStaticI18n<ErrorI18nKey, string, ErrorI18nRuntimeVariabl
validateVariables: (_key, template, variables) => validateMessageTemplateVariables(template, variables),
},
(template, variables, mf) => {
return String(mf.compile(template)(variables));
return String(compileMessage(mf, template)(variables));
},
);
@@ -0,0 +1,21 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type MessageFormat from '@messageformat/core';
type CompiledMessage = (variables: Record<string, unknown>) => unknown;
const compiledMessagesByFormat = new WeakMap<MessageFormat, Map<string, CompiledMessage>>();
export function compileMessage(messageFormat: MessageFormat, template: string): CompiledMessage {
let compiledMessages = compiledMessagesByFormat.get(messageFormat);
if (!compiledMessages) {
compiledMessages = new Map();
compiledMessagesByFormat.set(messageFormat, compiledMessages);
}
let compiledMessage = compiledMessages.get(template);
if (!compiledMessage) {
compiledMessage = messageFormat.compile(template);
compiledMessages.set(template, compiledMessage);
}
return compiledMessage;
}
@@ -103,12 +103,23 @@ function collectMessageTemplateVariables(tokens: ReadonlyArray<Token>, variables
}
}
export function extractMessageTemplateVariables(template: string): Set<string> {
const templateVariablesByTemplate = new Map<string, ReadonlySet<string>>();
function getMessageTemplateVariables(template: string): ReadonlySet<string> {
const cachedVariables = templateVariablesByTemplate.get(template);
if (cachedVariables) {
return cachedVariables;
}
const variables = new Set<string>();
collectMessageTemplateVariables(parse(template), variables);
templateVariablesByTemplate.set(template, variables);
return variables;
}
export function extractMessageTemplateVariables(template: string): Set<string> {
return new Set(getMessageTemplateVariables(template));
}
function collectMessageTemplatePlaceholders(tokens: ReadonlyArray<Token>, placeholders: Set<string>): void {
for (const token of tokens) {
switch (token.type) {
@@ -161,9 +172,9 @@ export function validateMessageTemplateVariables(
template: string,
variables: Record<string, unknown> | undefined,
): string | null {
let requiredVariables: Set<string>;
let requiredVariables: ReadonlySet<string>;
try {
requiredVariables = extractMessageTemplateVariables(template);
requiredVariables = getMessageTemplateVariables(template);
} catch (error) {
const message = error instanceof Error ? error.message : 'Unknown parser error';
return `Invalid i18n message template: ${message}`;
@@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {compileMessage} from '@fluxer/i18n/src/runtime/CompileMessage';
import {createStaticI18n} from '@fluxer/i18n/src/runtime/CreateStaticI18n';
import type {I18nResult} from '@fluxer/i18n/src/runtime/I18nTypes';
import {
extractMessageTemplateVariables,
validateMessageTemplateVariables,
} from '@fluxer/i18n/src/runtime/MessageCatalogTypes';
import MessageFormat from '@messageformat/core';
import {parse} from '@messageformat/parser';
import {afterEach, describe, expect, it, vi} from 'vitest';
vi.mock('@messageformat/parser', async (importOriginal) => {
const actual = await importOriginal<typeof import('@messageformat/parser')>();
return {...actual, parse: vi.fn(actual.parse)};
});
const FILES_TEMPLATE = '{count, plural, one {# file one} other {# files other}}';
type TestKey = 'files' | 'greeting';
function unwrap(result: I18nResult<TestKey, string>): string {
if (!result.ok) {
throw new Error(result.error.message);
}
return result.value;
}
function createTestI18n() {
return createStaticI18n<TestKey, string, Record<string, unknown>>(
{
defaultLocale: 'en-US',
defaultMessages: {files: FILES_TEMPLATE, greeting: 'Hello {name}'},
localeMessages: {fr: {files: FILES_TEMPLATE}},
validateVariables: (_key, template, variables) => validateMessageTemplateVariables(template, variables),
},
(template, variables, mf) => String(compileMessage(mf, template)(variables)),
);
}
describe('compileMessage', () => {
afterEach(() => {
vi.restoreAllMocks();
});
it('compiles a template once and reuses it across renders', () => {
const messageFormat = new MessageFormat('en-US');
const compileSpy = vi.spyOn(messageFormat, 'compile');
const first = compileMessage(messageFormat, 'Hello {name}');
const second = compileMessage(messageFormat, 'Hello {name}');
expect(second).toBe(first);
expect(first({name: 'Taylor'})).toBe('Hello Taylor');
expect(second({name: 'Jordan'})).toBe('Hello Jordan');
expect(compileSpy).toHaveBeenCalledTimes(1);
compileMessage(messageFormat, 'Bye {name}');
expect(compileSpy).toHaveBeenCalledTimes(2);
});
it('keeps compiled templates separate per locale so plural rules stay correct', () => {
const english = new MessageFormat('en-US');
const french = new MessageFormat('fr');
const englishFiles = compileMessage(english, FILES_TEMPLATE);
const frenchFiles = compileMessage(french, FILES_TEMPLATE);
expect(frenchFiles).not.toBe(englishFiles);
expect(englishFiles({count: 0})).toBe('0 files other');
expect(frenchFiles({count: 0})).toBe('0 file one');
expect(compileMessage(english, FILES_TEMPLATE)({count: 1})).toBe('1 file one');
expect(compileMessage(french, FILES_TEMPLATE)({count: 2})).toBe('2 files other');
});
it('returns the raw value for a lone argument', () => {
expect(compileMessage(new MessageFormat('en-US'), '{count}')({count: 3})).toBe(3);
});
it('compiles once per locale when rendering through createStaticI18n', () => {
const compileSpy = vi.spyOn(MessageFormat.prototype, 'compile');
const i18n = createTestI18n();
expect(unwrap(i18n.getTemplate('files', 'en-US', {count: 0}))).toBe('0 files other');
expect(unwrap(i18n.getTemplate('files', 'en-US', {count: 1}))).toBe('1 file one');
expect(unwrap(i18n.getTemplate('files', 'fr', {count: 0}))).toBe('0 file one');
expect(unwrap(i18n.getTemplate('files', 'fr', {count: 2}))).toBe('2 files other');
expect(unwrap(i18n.getTemplate('greeting', 'en-US', {name: 'Taylor'}))).toBe('Hello Taylor');
expect(unwrap(i18n.getTemplate('greeting', 'en-US', {name: 'Jordan'}))).toBe('Hello Jordan');
expect(compileSpy).toHaveBeenCalledTimes(3);
i18n.reset();
expect(unwrap(i18n.getTemplate('files', 'en-US', {count: 3}))).toBe('3 files other');
expect(compileSpy).toHaveBeenCalledTimes(4);
});
});
describe('message template variables', () => {
it('parses a template once across validations', () => {
const template = 'Hello {cachedName}, you have {cachedCount, plural, one {# invite} other {# invites}}';
const parseMock = vi.mocked(parse);
parseMock.mockClear();
expect(validateMessageTemplateVariables(template, {cachedName: 'Taylor', cachedCount: 1})).toBeNull();
expect(validateMessageTemplateVariables(template, {cachedName: 'Jordan', cachedCount: 2})).toBeNull();
expect(validateMessageTemplateVariables(template, {cachedCount: 2})).toBe(
'Missing required i18n variable: cachedName',
);
expect([...extractMessageTemplateVariables(template)].sort()).toEqual(['cachedCount', 'cachedName']);
expect(parseMock).toHaveBeenCalledTimes(1);
});
it('hands out copies so callers cannot change the cached set', () => {
const template = 'Welcome {copiedName}';
const variables = extractMessageTemplateVariables(template);
variables.add('injected');
variables.delete('copiedName');
expect([...extractMessageTemplateVariables(template)]).toEqual(['copiedName']);
expect(validateMessageTemplateVariables(template, {copiedName: 'Taylor'})).toBeNull();
expect(validateMessageTemplateVariables(template, {})).toBe('Missing required i18n variable: copiedName');
});
it('does not cache templates that fail to parse', () => {
const parseMock = vi.mocked(parse);
parseMock.mockClear();
expect(validateMessageTemplateVariables('Broken {unclosed', {})).toMatch(/^Invalid i18n message template: /);
expect(validateMessageTemplateVariables('Broken {unclosed', {})).toMatch(/^Invalid i18n message template: /);
expect(parseMock).toHaveBeenCalledTimes(2);
});
});
@@ -297,6 +297,72 @@ describe('published public and admin documents', () => {
expect(status).toEqual({type: 'string', description: 'Current status of the report (pending, resolved)'});
});
it('declares the token query parameter of the harvest archive download', () => {
const operation = documentFor('public').paths['/harvest-downloads/{harvestId}'].get;
expect(operation.parameters?.map(({name, in: location, required}) => ({name, in: location, required}))).toEqual([
{name: 'harvestId', in: 'path', required: true},
{name: 'token', in: 'query', required: true},
]);
expect(operation.parameters?.[1].schema).toMatchObject({type: 'string'});
});
it('leaves the MFA challenge out of responses that never return one', () => {
const document = documentFor('public');
const responseSchema = (path: string) =>
document.paths[path].post.responses['200'].content?.['application/json']?.schema;
expect(document.components.schemas.AuthRegisterResponse).toEqual({
anyOf: [
{$ref: '#/components/schemas/AuthTokenWithUserIdResponse'},
{$ref: '#/components/schemas/AuthRegistrationPendingApprovalResponse'},
],
});
expect(responseSchema('/auth/register')).toEqual({$ref: '#/components/schemas/AuthRegisterResponse'});
expect(responseSchema('/auth/email-revert')).toEqual({$ref: '#/components/schemas/AuthTokenWithUserIdResponse'});
for (const path of ['/auth/login', '/auth/reset']) {
expect(responseSchema(path)).toEqual({$ref: '#/components/schemas/AuthLoginResponse'});
}
const login = document.components.schemas.AuthLoginResponse.anyOf ?? [];
expect(login).toHaveLength(2);
expect(login[0]).toEqual({$ref: '#/components/schemas/AuthTokenWithUserIdResponse'});
expect(login[1]).toHaveProperty('properties.mfa');
});
it('describes an embed provider with a name and a link only', () => {
const document = documentFor('public');
expect(Object.keys(document.components.schemas.EmbedProviderResponse.properties ?? {})).toEqual(['name', 'url']);
for (const name of ['MessageEmbedResponse', 'MessageEmbedChildResponse']) {
const properties = document.components.schemas[name].properties;
expect(properties?.provider).toMatchObject({
anyOf: [{$ref: '#/components/schemas/EmbedProviderResponse'}, {type: 'null'}],
});
expect(properties?.author).toMatchObject({
anyOf: [{$ref: '#/components/schemas/EmbedAuthorResponse'}, {type: 'null'}],
});
}
expect(Object.keys(document.components.schemas.EmbedAuthorResponse.properties ?? {})).toEqual([
'name',
'url',
'icon_url',
'proxy_icon_url',
]);
});
it('lists only the fields the admin guild update returns', () => {
const document = documentFor('admin');
const guild = document.components.schemas.GuildUpdateResponse.properties?.guild;
assert(typeof guild === 'object', 'the guild member of GuildUpdateResponse must be an object schema');
expect(Object.keys(guild.properties ?? {})).toEqual([
'id',
'name',
'features',
'owner_id',
'icon',
'banner',
'member_count',
'nsfw_level',
]);
});
it('keeps the report status and type enums in the admin document', () => {
const {schemas} = documentFor('admin').components;
expect(schemas.ReportStatus).toMatchObject({type: 'integer', format: 'int32', enum: [0, 1]});
@@ -1269,9 +1269,6 @@ const AdminGuildResponseSchema = z.object({
banner: createStringType(1, 256).nullable(),
member_count: Int32Type,
nsfw_level: NSFWLevelSchema.optional(),
nsfw: z.boolean().optional(),
content_warning_level: ContentWarningLevelSchema.optional(),
content_warning_text: createStringType(0, CONTENT_WARNING_TEXT_MAX_LENGTH).nullable().optional(),
});
const AdminGuildChannelSummarySchema = z.object({
id: SnowflakeStringType,
@@ -232,11 +232,7 @@ export const RecoverAccountResponse = z.union([
export type RecoverAccountResponse = z.infer<typeof RecoverAccountResponse>;
export const AuthRegisterResponse = z.union([
AuthTokenWithUserIdResponse,
AuthMfaRequiredResponse,
AuthRegistrationPendingApprovalResponse,
]);
export const AuthRegisterResponse = z.union([AuthTokenWithUserIdResponse, AuthRegistrationPendingApprovalResponse]);
export type AuthRegisterResponse = z.infer<typeof AuthRegisterResponse>;
@@ -13,6 +13,11 @@ export const EmbedAuthorResponse = z.object({
export type EmbedAuthorResponse = z.infer<typeof EmbedAuthorResponse>;
export const EmbedProviderResponse = z.object({
name: z.string().describe('The name of the provider'),
url: z.url().nullish().describe('The URL of the provider'),
});
export const EmbedFooterResponse = z.object({
text: z.string().describe('The footer text'),
icon_url: z.url().nullish().describe('The URL of the footer icon'),
@@ -61,7 +66,7 @@ export const MessageEmbedChildResponse = z.object({
thumbnail: EmbedMediaResponse.nullish().describe('The thumbnail of the embed'),
footer: EmbedFooterResponse.nullish().describe('The footer of the embed'),
fields: z.array(EmbedFieldResponse).nullish().describe('The fields of the embed'),
provider: EmbedAuthorResponse.nullish().describe('The provider of the embed (e.g., YouTube, Twitter)'),
provider: EmbedProviderResponse.nullish().describe('The provider of the embed (e.g., YouTube, Twitter)'),
video: EmbedMediaResponse.nullish().describe('The video of the embed'),
audio: EmbedMediaResponse.nullish().describe('The audio of the embed'),
html: z.string().nullish().describe('Sanitized oEmbed HTML for trusted specialized embed renderers'),
@@ -89,6 +94,11 @@ export interface EmbedAuthor {
readonly proxy_icon_url?: string;
}
export interface EmbedProvider {
readonly name: string;
readonly url?: string;
}
export interface EmbedFooter {
readonly text: string;
readonly icon_url?: string;
@@ -128,7 +138,7 @@ export interface MessageEmbedChild {
readonly thumbnail?: EmbedMedia;
readonly footer?: EmbedFooter;
readonly fields?: ReadonlyArray<EmbedField>;
readonly provider?: EmbedAuthor;
readonly provider?: EmbedProvider;
readonly video?: EmbedMedia;
readonly audio?: EmbedMedia;
readonly html?: string;
@@ -84,13 +84,13 @@ export const TicketResponse = z.object({
export type TicketResponse = z.infer<typeof TicketResponse>;
const FLUXER_TAG_REGEX = /^([^#]{1,32})#([0-9]{4})$/;
const FLUXER_TAG_REGEX = /^([^#]{1,32})(#[0-9]{4})?$/;
const FLUXER_TAG_TYPE = z
.string()
.min(3)
.min(1)
.max(37)
.refine((value) => FLUXER_TAG_REGEX.test(value), 'Fluxer tag must be in the format username#1234')
.describe('A Fluxer username tag in the format username#1234');
.refine((value) => FLUXER_TAG_REGEX.test(value), 'Fluxer tag must be in the format username#1234 or username')
.describe('A Fluxer username tag in the format username#1234, or a bare username on an instance without user tags');
const EuCountryCodeEnum = withOpenApiType(
createNamedStringLiteralUnion(
[
@@ -59,4 +59,8 @@ export const HarvestDownloadUrlResponse = z.object({
export type HarvestDownloadUrlResponse = z.infer<typeof HarvestDownloadUrlResponse>;
export const HarvestDownloadQuery = z.object({
token: createStringType(1, 2048).describe('The signed download token from the harvest download URL'),
});
export const HarvestArchiveResponse = z.file().describe('The ZIP archive containing the requested user data');