mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
refactor(schema): simplify validation and OpenAPI generation (#2709)
This commit is contained in:
1 parent
4bc5593f9f
commit
de1fd95a99
510 files changed
+29494
-36131
No files matched your search
@@ -61,12 +61,9 @@ describe('ChannelResponse', () => {
|
||||
rate_limit_per_user: 0,
|
||||
};
|
||||
it('accepts valid channel response', () => {
|
||||
const result = ChannelResponse.safeParse(validChannel);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('123456789012345678');
|
||||
expect(result.data.name).toBe('general');
|
||||
}
|
||||
const result = ChannelResponse.parse(validChannel);
|
||||
expect(result.id).toBe('123456789012345678');
|
||||
expect(result.name).toBe('general');
|
||||
});
|
||||
it('requires id and type', () => {
|
||||
const {id, ...channelWithoutId} = validChannel;
|
||||
@@ -100,11 +97,8 @@ describe('ChannelResponse', () => {
|
||||
{id: '222222222222222222', type: 1, allow: '0', deny: '2048'},
|
||||
],
|
||||
};
|
||||
const result = ChannelResponse.safeParse(channel);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.permission_overwrites).toHaveLength(2);
|
||||
}
|
||||
const result = ChannelResponse.parse(channel);
|
||||
expect(result.permission_overwrites).toHaveLength(2);
|
||||
});
|
||||
it('accepts more permission_overwrites than the old response ceiling', () => {
|
||||
const channel = {
|
||||
@@ -116,11 +110,8 @@ describe('ChannelResponse', () => {
|
||||
deny: '0',
|
||||
})),
|
||||
};
|
||||
const result = ChannelResponse.safeParse(channel);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.permission_overwrites).toHaveLength(501);
|
||||
}
|
||||
const result = ChannelResponse.parse(channel);
|
||||
expect(result.permission_overwrites).toHaveLength(501);
|
||||
});
|
||||
it('accepts voice channel properties', () => {
|
||||
const voiceChannel = {
|
||||
@@ -131,13 +122,10 @@ describe('ChannelResponse', () => {
|
||||
voice_connection_limit: 5,
|
||||
rtc_region: 'us-west',
|
||||
};
|
||||
const result = ChannelResponse.safeParse(voiceChannel);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.bitrate).toBe(64000);
|
||||
expect(result.data.user_limit).toBe(10);
|
||||
expect(result.data.voice_connection_limit).toBe(5);
|
||||
}
|
||||
const result = ChannelResponse.parse(voiceChannel);
|
||||
expect(result.bitrate).toBe(64000);
|
||||
expect(result.user_limit).toBe(10);
|
||||
expect(result.voice_connection_limit).toBe(5);
|
||||
});
|
||||
it('accepts null optional fields', () => {
|
||||
const channel = {
|
||||
@@ -159,11 +147,8 @@ describe('ChannelResponse', () => {
|
||||
'222222222222222222': 'Friend 2',
|
||||
},
|
||||
};
|
||||
const result = ChannelResponse.safeParse(channel);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.nicks).toBeDefined();
|
||||
}
|
||||
const result = ChannelResponse.parse(channel);
|
||||
expect(result.nicks).toBeDefined();
|
||||
});
|
||||
it('accepts valid last_pin_timestamp', () => {
|
||||
const channel = {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
ExperimentAssignmentsResponse,
|
||||
ExperimentDeliveryConfigSchema,
|
||||
ExperimentDeliveryConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('experiment delivery schemas', () => {
|
||||
it('derives complete configuration defaults without filling partial updates', () => {
|
||||
expect(ExperimentDeliveryConfigSchema.parse({})).toEqual(DEFAULT_EXPERIMENT_DELIVERY_CONFIG);
|
||||
expect(ExperimentDeliveryConfigUpdateRequest.parse({})).toEqual({});
|
||||
expect(ExperimentDeliveryConfigUpdateRequest.parse({poll_interval_seconds: 3600})).toEqual({
|
||||
poll_interval_seconds: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{poll_interval_seconds: 59},
|
||||
{poll_interval_seconds: 86401},
|
||||
{poll_jitter_percent: -1},
|
||||
{poll_jitter_percent: 51},
|
||||
])('uses the same delivery bounds in configuration and updates: %j', (value) => {
|
||||
expect(ExperimentDeliveryConfigSchema.safeParse(value).success).toBe(false);
|
||||
expect(ExperimentDeliveryConfigUpdateRequest.safeParse(value).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([1, 999999])('preserves server poll intervals for client-side clamping: %i', (interval) => {
|
||||
expect(
|
||||
ExperimentAssignmentsResponse.parse({
|
||||
...DEFAULT_EXPERIMENT_DELIVERY_CONFIG,
|
||||
poll_interval_seconds: interval,
|
||||
assignments: {},
|
||||
}).poll_interval_seconds,
|
||||
).toBe(interval);
|
||||
});
|
||||
});
|
||||
@@ -6,13 +6,34 @@ import {
|
||||
MAX_TEMP_BAN_DURATION_SECONDS,
|
||||
MIN_TEMP_BAN_DURATION_SECONDS,
|
||||
} from '@fluxer/constants/src/LimitConstants';
|
||||
import {
|
||||
DiscoveryApplicationPatchRequest,
|
||||
DiscoveryApplicationRequest,
|
||||
DiscoverySearchQuery,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import {
|
||||
GuildBanCreateRequest,
|
||||
GuildMemberUpdateRequest,
|
||||
GuildStickerCreateRequest,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildRequestSchemas';
|
||||
import {TemplateChannel} from '@fluxer/schema/src/domains/guild/GuildTemplateSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe.each([
|
||||
{name: 'application', schema: DiscoveryApplicationRequest.shape.primary_language},
|
||||
{name: 'application patch', schema: DiscoveryApplicationPatchRequest.shape.primary_language},
|
||||
{name: 'search', schema: DiscoverySearchQuery.shape.language},
|
||||
])('discovery $name language', ({schema}) => {
|
||||
it.each(['en-US', 'sv-SE', undefined])('preserves supported or omitted language %j', (language) => {
|
||||
expect(schema.parse(language)).toBe(language);
|
||||
});
|
||||
it.each(['unsupported', 'EN-US', ' en-US '])('rejects unsupported language %j without normalization', (language) => {
|
||||
expect(schema.safeParse(language).error?.issues).toEqual([
|
||||
{code: 'custom', message: 'Unsupported language code', path: []},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GuildBanCreateRequest', () => {
|
||||
it('accepts permanent bans and arbitrary temporary durations within range', () => {
|
||||
expect(GuildBanCreateRequest.safeParse({ban_duration_seconds: 0}).success).toBe(true);
|
||||
@@ -57,3 +78,38 @@ describe('GuildStickerCreateRequest', () => {
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TemplateChannel permission overwrites', () => {
|
||||
it.each([
|
||||
['role', 0],
|
||||
['member', 1],
|
||||
['0', 0],
|
||||
[1, 1],
|
||||
[42, 42],
|
||||
])('normalizes the imported overwrite type %j', (type, expected) => {
|
||||
const channel = TemplateChannel.parse({
|
||||
id: '1',
|
||||
type: 0,
|
||||
position: 0,
|
||||
permission_overwrites: [{id: '2', type, allow: '8', deny: '0'}],
|
||||
});
|
||||
expect(channel.permission_overwrites).toEqual([{id: '2', type: expected, allow: '8', deny: '0'}]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'invalid',
|
||||
'NaN',
|
||||
'Infinity',
|
||||
Number.NaN,
|
||||
Number.POSITIVE_INFINITY,
|
||||
])('rejects nonfinite overwrite type %j', (type) => {
|
||||
expect(
|
||||
TemplateChannel.safeParse({
|
||||
id: '1',
|
||||
type: 0,
|
||||
position: 0,
|
||||
permission_overwrites: [{id: '2', type, allow: '8', deny: '0'}],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -44,12 +44,9 @@ describe('GuildResponse', () => {
|
||||
permissions: '8',
|
||||
};
|
||||
it('accepts valid guild response', () => {
|
||||
const result = GuildResponse.safeParse(validGuild);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('123456789012345678');
|
||||
expect(result.data.name).toBe('Test Guild');
|
||||
}
|
||||
const result = GuildResponse.parse(validGuild);
|
||||
expect(result.id).toBe('123456789012345678');
|
||||
expect(result.name).toBe('Test Guild');
|
||||
});
|
||||
it('accepts guild with null optional fields', () => {
|
||||
const guild = {
|
||||
@@ -116,33 +113,24 @@ describe('GuildResponse', () => {
|
||||
...validGuild,
|
||||
features: [GuildFeatures.RAID_DETECTED, GuildFeatures.VERIFIED],
|
||||
};
|
||||
const result = GuildResponse.safeParse(guild);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.features).toEqual([GuildFeatures.RAID_DETECTED, GuildFeatures.VERIFIED]);
|
||||
}
|
||||
const result = GuildResponse.parse(guild);
|
||||
expect(result.features).toEqual([GuildFeatures.RAID_DETECTED, GuildFeatures.VERIFIED]);
|
||||
});
|
||||
it('preserves unknown features in guild response', () => {
|
||||
const guild = {
|
||||
...validGuild,
|
||||
features: [GuildFeatures.VERIFIED, 'DISALLOW_UNCLAIMED_ACCOUNTS'],
|
||||
};
|
||||
const result = GuildResponse.safeParse(guild);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
|
||||
}
|
||||
const result = GuildResponse.parse(guild);
|
||||
expect(result.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
|
||||
});
|
||||
it('deduplicates and sorts guild features while preserving values', () => {
|
||||
const guild = {
|
||||
...validGuild,
|
||||
features: [GuildFeatures.VERIFIED, 'DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED],
|
||||
};
|
||||
const result = GuildResponse.safeParse(guild);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
|
||||
}
|
||||
const result = GuildResponse.parse(guild);
|
||||
expect(result.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
|
||||
});
|
||||
it('rejects non-integer system_channel_flags', () => {
|
||||
const guild = {...validGuild, system_channel_flags: 1.5};
|
||||
@@ -174,12 +162,9 @@ describe('GuildPartialResponse', () => {
|
||||
features: [GuildFeatures.VERIFIED],
|
||||
};
|
||||
it('accepts valid partial guild response', () => {
|
||||
const result = GuildPartialResponse.safeParse(validPartialGuild);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('123456789012345678');
|
||||
expect(result.data.name).toBe('Test Guild');
|
||||
}
|
||||
const result = GuildPartialResponse.parse(validPartialGuild);
|
||||
expect(result.id).toBe('123456789012345678');
|
||||
expect(result.name).toBe('Test Guild');
|
||||
});
|
||||
it('accepts partial guild with null optional fields', () => {
|
||||
const guild = {
|
||||
@@ -212,11 +197,8 @@ describe('GuildPartialResponse', () => {
|
||||
...validPartialGuild,
|
||||
features: [GuildFeatures.VERIFIED, 'DISALLOW_UNCLAIMED_ACCOUNTS'],
|
||||
};
|
||||
const result = GuildPartialResponse.safeParse(guild);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
|
||||
}
|
||||
const result = GuildPartialResponse.parse(guild);
|
||||
expect(result.features).toEqual(['DISALLOW_UNCLAIMED_ACCOUNTS', GuildFeatures.VERIFIED]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,10 +10,47 @@ describe('jobs schemas', () => {
|
||||
expect(JobLedgerEntrySchema.shape.status.safeParse('failed').success).toBe(false);
|
||||
});
|
||||
|
||||
test('the job status enum accepts every status the ledger writes', () => {
|
||||
for (const status of ['queued', 'running', 'succeeded', 'cancelled', 'deadletter']) {
|
||||
expect(ListJobsQuery.safeParse({status}).success).toBe(true);
|
||||
expect(JobLedgerEntrySchema.shape.status.safeParse(status).success).toBe(true);
|
||||
}
|
||||
test.each([
|
||||
'queued',
|
||||
'running',
|
||||
'succeeded',
|
||||
'cancelled',
|
||||
'deadletter',
|
||||
])('accepts ledger status %s across query, request, and response', (status) => {
|
||||
expect(ListJobsQuery.parse({status}).status).toBe(status);
|
||||
expect(ListJobsRequest.parse({status}).status).toBe(status);
|
||||
expect(JobLedgerEntrySchema.shape.status.parse(status)).toBe(status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('job pagination cursor', () => {
|
||||
const cursor = {
|
||||
cursor_bucket_day: '2024-02-29',
|
||||
cursor_created_at: '2024-02-29T12:00:00Z',
|
||||
cursor_job_id: '123',
|
||||
};
|
||||
|
||||
test('accepts a complete cursor and applies query defaults', () => {
|
||||
expect(ListJobsQuery.parse(cursor)).toEqual({...cursor, limit: 50, max_lookback_days: 14});
|
||||
});
|
||||
|
||||
test.each([
|
||||
'cursor_bucket_day',
|
||||
'cursor_created_at',
|
||||
'cursor_job_id',
|
||||
] as const)('rejects a cursor missing %s', (field) => {
|
||||
expect(ListJobsQuery.safeParse({...cursor, [field]: undefined})).toMatchObject({
|
||||
success: false,
|
||||
error: {issues: [{path: [field]}]},
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{cursor_bucket_day: '2023-02-29'},
|
||||
{cursor_bucket_day: '2024-04-31'},
|
||||
{cursor_created_at: '2024-02-30T12:00:00Z'},
|
||||
{cursor_job_id: '01'},
|
||||
])('rejects malformed cursor component %j', (invalid) => {
|
||||
expect(ListJobsQuery.safeParse({...cursor, ...invalid}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,35 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
MessageNonceRequest,
|
||||
MessageRequestSchema,
|
||||
MessageUpdateRequestSchema,
|
||||
RichEmbedRequest,
|
||||
} from '@fluxer/schema/src/domains/message/MessageRequestSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('MessageNonceRequest', () => {
|
||||
it.each([
|
||||
[0, '0'],
|
||||
[Number.MAX_SAFE_INTEGER, '9007199254740991'],
|
||||
[' client-nonce ', 'client-nonce'],
|
||||
['-1', '-1'],
|
||||
])('normalizes nonce %j to its wire string', (input, expected) => {
|
||||
expect(MessageNonceRequest.parse(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
-1,
|
||||
0.5,
|
||||
Number.MAX_SAFE_INTEGER + 1,
|
||||
Number.POSITIVE_INFINITY,
|
||||
'',
|
||||
'a'.repeat(33),
|
||||
])('rejects invalid nonce %j', (input) => {
|
||||
expect(MessageNonceRequest.safeParse(input).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('MessageRequestSchema', () => {
|
||||
it('preserves ANSI escape characters in message content', () => {
|
||||
const content = '```ansi\nWelcome to \u001b[2;33mFluxer\u001b[0m!\n```';
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {MAX_APPLICATION_REDIRECT_URIS} from '@fluxer/constants/src/LimitConstants';
|
||||
import {ApplicationCreateRequest, ApplicationResponse} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
|
||||
import {UserAuthenticatorTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
ApplicationCreateRequest,
|
||||
ApplicationResponse,
|
||||
ApplicationUpdateRequest,
|
||||
AuthorizeConsentRequest,
|
||||
AuthorizeRequest,
|
||||
IntrospectRequestForm,
|
||||
RevokeRequestForm,
|
||||
} from '@fluxer/schema/src/domains/oauth/OAuthSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const buildRedirectURIs = (count: number) =>
|
||||
@@ -27,3 +36,139 @@ describe('application redirect_uris bounds', () => {
|
||||
expect(ApplicationResponse.safeParse(buildApplicationResponse(uris)).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{name: 'authorize query', schema: AuthorizeRequest},
|
||||
{name: 'consent request', schema: AuthorizeConsentRequest},
|
||||
])('$name', ({schema}) => {
|
||||
const required = {client_id: '123', scope: 'identify'};
|
||||
it('normalizes shared fields without adding optional defaults', () => {
|
||||
expect(schema.parse({client_id: ' 123 ', scope: ' identify '})).toEqual({client_id: 123n, scope: 'identify'});
|
||||
});
|
||||
it.each(['S256', 'plain'])('preserves the PKCE method %s and shared fields', (method) => {
|
||||
expect(
|
||||
schema.parse({
|
||||
...required,
|
||||
redirect_uri: ' https://example.com/callback ',
|
||||
state: ' nonce ',
|
||||
permissions: '0',
|
||||
guild_id: '456',
|
||||
channel_id: '789',
|
||||
code_challenge: ' challenge ',
|
||||
code_challenge_method: method,
|
||||
}),
|
||||
).toEqual({
|
||||
client_id: 123n,
|
||||
scope: 'identify',
|
||||
redirect_uri: 'https://example.com/callback',
|
||||
state: 'nonce',
|
||||
permissions: '0',
|
||||
guild_id: 456n,
|
||||
channel_id: 789n,
|
||||
code_challenge: 'challenge',
|
||||
code_challenge_method: method,
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{client_id: undefined},
|
||||
{client_id: '1.5'},
|
||||
{scope: undefined},
|
||||
{scope: ' '},
|
||||
{state: ''},
|
||||
{redirect_uri: '/relative'},
|
||||
{code_challenge: ''},
|
||||
{code_challenge_method: 's256'},
|
||||
])('rejects malformed shared fields %j', (fields) => {
|
||||
expect(schema.safeParse({...required, ...fields}).success).toBe(false);
|
||||
});
|
||||
it('leaves registered redirect policy to the authorization service', () => {
|
||||
expect(schema.parse({...required, redirect_uri: 'http://example.com/callback'}).redirect_uri).toBe(
|
||||
'http://example.com/callback',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authorization endpoint differences', () => {
|
||||
it('keeps response_type validation strict only for the authorize query', () => {
|
||||
const input = {client_id: '123', scope: 'identify', response_type: 'token'};
|
||||
expect(AuthorizeRequest.safeParse(input).success).toBe(false);
|
||||
expect(AuthorizeConsentRequest.parse(input).response_type).toBe('token');
|
||||
});
|
||||
it('does not leak query-only controls into the consent request', () => {
|
||||
const input = {client_id: '123', scope: 'identify', prompt: 'none', disable_guild_select: 'true'};
|
||||
expect(AuthorizeRequest.parse(input)).toEqual({...input, client_id: 123n});
|
||||
expect(AuthorizeConsentRequest.parse(input)).toEqual({client_id: 123n, scope: 'identify'});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{name: 'introspection', schema: IntrospectRequestForm},
|
||||
{name: 'revocation', schema: RevokeRequestForm},
|
||||
])('$name credentials', ({schema}) => {
|
||||
it('keeps body credentials optional for authorization-header authentication', () => {
|
||||
expect(schema.parse({token: ' token '})).toEqual({token: 'token'});
|
||||
});
|
||||
it('normalizes explicit client credentials', () => {
|
||||
expect(schema.parse({token: 'token', client_id: '123', client_secret: ' secret '})).toEqual({
|
||||
token: 'token',
|
||||
client_id: 123n,
|
||||
client_secret: 'secret',
|
||||
});
|
||||
});
|
||||
it.each([{token: ''}, {client_id: 'not-an-id'}, {client_secret: ''}])('rejects invalid credentials %j', (fields) => {
|
||||
expect(schema.safeParse({token: 'token', ...fields}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(['access_token', 'refresh_token'])('preserves %s hint only on revocation', (hint) => {
|
||||
const input = {token: 'token', token_type_hint: hint};
|
||||
expect(RevokeRequestForm.parse(input)).toEqual(input);
|
||||
expect(IntrospectRequestForm.parse(input)).toEqual({token: 'token'});
|
||||
});
|
||||
|
||||
describe('application redirect policies', () => {
|
||||
it.each([
|
||||
'https://example.com/callback',
|
||||
'http://localhost/callback',
|
||||
'http://app.localhost/callback',
|
||||
'http://127.0.0.1:3000/callback',
|
||||
'http://[::1]:3000/callback',
|
||||
'http://192.0.2.1/callback',
|
||||
])('accepts registered URI %s', (uri) => {
|
||||
expect(ApplicationCreateRequest.parse({name: 'Application', redirect_uris: [uri]}).redirect_uris).toEqual([uri]);
|
||||
});
|
||||
it.each([
|
||||
'http://example.com/callback',
|
||||
'https://',
|
||||
'/relative',
|
||||
'ftp://example.com/callback',
|
||||
])('rejects registered URI %s', (uri) => {
|
||||
expect(ApplicationCreateRequest.safeParse({name: 'Application', redirect_uris: [uri]}).success).toBe(false);
|
||||
});
|
||||
it('defaults missing create URIs but does not clear missing update URIs', () => {
|
||||
expect(ApplicationCreateRequest.parse({name: 'Application'})).toEqual({name: 'Application', redirect_uris: []});
|
||||
expect(ApplicationUpdateRequest.parse({})).toEqual({});
|
||||
});
|
||||
it.each([null, []])('clears explicitly supplied URIs %j', (uris) => {
|
||||
expect(ApplicationUpdateRequest.parse({redirect_uris: uris})).toEqual({redirect_uris: []});
|
||||
});
|
||||
});
|
||||
|
||||
describe('application bot authenticators', () => {
|
||||
const response = {
|
||||
...buildApplicationResponse([]),
|
||||
bot: {id: '123', username: 'bot', discriminator: '0001', bio: null, flags: 0},
|
||||
};
|
||||
it('accepts every canonical authenticator type', () => {
|
||||
const types = Object.values(UserAuthenticatorTypes);
|
||||
expect(ApplicationResponse.parse({...response, bot: {...response.bot, authenticator_types: types}}).bot).toEqual({
|
||||
...response.bot,
|
||||
authenticator_types: types,
|
||||
});
|
||||
});
|
||||
it.each([-1, 0.5, 999])('rejects unknown authenticator %j', (type) => {
|
||||
expect(
|
||||
ApplicationResponse.safeParse({...response, bot: {...response.bot, authenticator_types: [type]}}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
CreateVoiceServerRequest,
|
||||
CreateVoiceServerRequestBody,
|
||||
UpdateVoiceRegionRequest,
|
||||
UpdateVoiceRegionRequestBody,
|
||||
UpdateVoiceServerRequest,
|
||||
UpdateVoiceServerRequestBody,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminVoiceSchemas';
|
||||
import {ChannelUpdateRequest, ChannelUpdateRequestBody} from '@fluxer/schema/src/domains/channel/ChannelRequestSchemas';
|
||||
import {
|
||||
BulkDeleteSelfMessagesFilter,
|
||||
BulkDeleteSelfMessagesRequest,
|
||||
HarvestSelfDataRequest,
|
||||
} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const server = {
|
||||
server_id: 'primary',
|
||||
endpoint: 'wss://voice.example.com',
|
||||
api_key: 'key',
|
||||
api_secret: 'secret',
|
||||
};
|
||||
|
||||
describe('voice request bodies', () => {
|
||||
it('requires server creation fields but not the URL-supplied region', () => {
|
||||
const body = CreateVoiceServerRequestBody.parse(server);
|
||||
expect(body).toEqual({
|
||||
...server,
|
||||
is_active: true,
|
||||
soft_connection_limit: null,
|
||||
vip_only: false,
|
||||
required_guild_features: [],
|
||||
allowed_guild_ids: [],
|
||||
allowed_user_ids: [],
|
||||
});
|
||||
expect(CreateVoiceServerRequestBody.safeParse({}).success).toBe(false);
|
||||
expect(CreateVoiceServerRequest.safeParse(server).success).toBe(false);
|
||||
expect(CreateVoiceServerRequest.parse({...server, region_id: 'eu'})).toEqual({...body, region_id: 'eu'});
|
||||
});
|
||||
|
||||
it.each(['server_id', 'endpoint', 'api_key', 'api_secret'] as const)('requires creation field %s', (field) => {
|
||||
expect(CreateVoiceServerRequestBody.safeParse({...server, [field]: undefined}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps patch bodies empty without injecting defaults', () => {
|
||||
expect(UpdateVoiceRegionRequestBody.parse({})).toEqual({});
|
||||
expect(UpdateVoiceServerRequestBody.parse({})).toEqual({});
|
||||
expect(UpdateVoiceRegionRequest.parse({id: 'eu'})).toEqual({id: 'eu'});
|
||||
expect(UpdateVoiceServerRequest.parse({region_id: 'eu', server_id: 'primary'})).toEqual({
|
||||
region_id: 'eu',
|
||||
server_id: 'primary',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat client-supplied path fields as body properties', () => {
|
||||
expect(UpdateVoiceRegionRequestBody.parse({id: 'ignored', name: 'Europe'})).toEqual({name: 'Europe'});
|
||||
expect(UpdateVoiceServerRequestBody.parse({region_id: 'ignored', server_id: 'ignored'})).toEqual({});
|
||||
});
|
||||
|
||||
it('retains explicit false, null, and snowflake transformations', () => {
|
||||
expect(
|
||||
UpdateVoiceServerRequestBody.parse({is_active: false, soft_connection_limit: null, allowed_user_ids: ['123']}),
|
||||
).toEqual({is_active: false, soft_connection_limit: null, allowed_user_ids: [123n]});
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{name: 'create body', schema: CreateVoiceServerRequestBody, required: server},
|
||||
{name: 'create service request', schema: CreateVoiceServerRequest, required: {...server, region_id: 'eu'}},
|
||||
{name: 'update body', schema: UpdateVoiceServerRequestBody, required: {}},
|
||||
{name: 'update service request', schema: UpdateVoiceServerRequest, required: {region_id: 'eu', server_id: 'primary'}},
|
||||
])('$name coordinates', ({schema, required}) => {
|
||||
it.each([
|
||||
{},
|
||||
{latitude: 0, longitude: 0},
|
||||
{latitude: null, longitude: null},
|
||||
])('accepts the paired state %j', (coordinates) => {
|
||||
expect(schema.parse({...required, ...coordinates})).toMatchObject(coordinates);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{latitude: 0},
|
||||
{longitude: 0},
|
||||
{latitude: null},
|
||||
{longitude: null},
|
||||
{latitude: 0, longitude: null},
|
||||
{latitude: null, longitude: 0},
|
||||
])('rejects the unpaired state %j', (coordinates) => {
|
||||
expect(schema.safeParse({...required, ...coordinates})).toMatchObject({
|
||||
success: false,
|
||||
error: {
|
||||
issues: [{path: ['latitude'], message: 'Latitude and longitude must both be provided or both be omitted'}],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('channel update request body', () => {
|
||||
it('permits an empty body while the service validator still requires the stored channel type', () => {
|
||||
expect(ChannelUpdateRequestBody.parse({})).toEqual({});
|
||||
expect(ChannelUpdateRequest.safeParse({}).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
ChannelTypes.GUILD_TEXT,
|
||||
ChannelTypes.GUILD_VOICE,
|
||||
ChannelTypes.GUILD_CATEGORY,
|
||||
ChannelTypes.GUILD_LINK,
|
||||
ChannelTypes.GROUP_DM,
|
||||
])('preserves field transformations for stored channel type %i', (type) => {
|
||||
const input = {name: ' General ', owner_id: '123'};
|
||||
const expected = {name: 'General', owner_id: 123n};
|
||||
expect(ChannelUpdateRequestBody.parse(input)).toEqual(expected);
|
||||
expect(ChannelUpdateRequest.parse({...input, type})).toEqual({...expected, type});
|
||||
});
|
||||
|
||||
it('preserves guild-only fields in the documented body', () => {
|
||||
const input = {topic: ' Topic ', parent_id: '123', nsfw_override: false, bitrate: 64000, rtc_region: null};
|
||||
expect(ChannelUpdateRequestBody.parse(input)).toEqual({...input, topic: 'Topic', parent_id: 123n});
|
||||
});
|
||||
|
||||
it('does not accept the injected discriminator as a body property', () => {
|
||||
expect(ChannelUpdateRequestBody.parse({type: ChannelTypes.GUILD_VOICE})).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
const bulkDeleteDefaults = {
|
||||
scope: 'selected',
|
||||
include_dms: true,
|
||||
include_dms_closed: true,
|
||||
include_group_dms: true,
|
||||
include_guilds: true,
|
||||
guild_filter_mode: 'exclude',
|
||||
excluded_guild_ids: [],
|
||||
included_guild_ids: [],
|
||||
};
|
||||
const noSelectedContexts = {
|
||||
include_dms: false,
|
||||
include_dms_closed: false,
|
||||
include_group_dms: false,
|
||||
include_guilds: false,
|
||||
};
|
||||
const earlierDate = '2026-01-01T00:00:00Z';
|
||||
const laterDate = '2026-02-01T00:00:00Z';
|
||||
const selectedContextIssue = {
|
||||
code: 'custom',
|
||||
message: 'Enable at least one of include_dms, include_dms_closed, include_group_dms, or include_guilds.',
|
||||
path: ['include_dms'],
|
||||
};
|
||||
const dateRangeIssue = {
|
||||
code: 'custom',
|
||||
message: 'start_date must be earlier than end_date.',
|
||||
path: ['end_date'],
|
||||
};
|
||||
|
||||
describe.each([
|
||||
{name: 'bulk-delete filter', schema: BulkDeleteSelfMessagesFilter},
|
||||
{name: 'bulk-delete request', schema: BulkDeleteSelfMessagesRequest},
|
||||
{name: 'harvest request', schema: HarvestSelfDataRequest},
|
||||
])('$name', ({schema}) => {
|
||||
it('applies the shared defaults to an empty request', () => {
|
||||
expect(schema.parse({})).toEqual(bulkDeleteDefaults);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'include_dms',
|
||||
'include_dms_closed',
|
||||
'include_group_dms',
|
||||
'include_guilds',
|
||||
] as const)('accepts %s as the only selected context', (field) => {
|
||||
const selection = {...noSelectedContexts, [field]: true};
|
||||
expect(schema.parse(selection)).toEqual({...bulkDeleteDefaults, ...selection});
|
||||
});
|
||||
|
||||
it('does not require selected contexts for inaccessible-only scope', () => {
|
||||
const selection = {...noSelectedContexts, scope: 'inaccessible_only'};
|
||||
expect(schema.parse(selection)).toEqual({...bulkDeleteDefaults, ...selection});
|
||||
});
|
||||
|
||||
it('preserves the guild filter and transforms both guild ID lists', () => {
|
||||
expect(
|
||||
schema.parse({guild_filter_mode: 'include_only', included_guild_ids: ['123'], excluded_guild_ids: ['456']}),
|
||||
).toEqual({
|
||||
...bulkDeleteDefaults,
|
||||
guild_filter_mode: 'include_only',
|
||||
included_guild_ids: [123n],
|
||||
excluded_guild_ids: [456n],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{start_date: null, end_date: null},
|
||||
{start_date: earlierDate},
|
||||
{end_date: laterDate},
|
||||
{start_date: earlierDate, end_date: null},
|
||||
{start_date: null, end_date: laterDate},
|
||||
{start_date: earlierDate, end_date: laterDate},
|
||||
])('accepts the date bounds %j', (dates) => {
|
||||
expect(schema.parse(dates)).toEqual({...bulkDeleteDefaults, ...dates});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{input: noSelectedContexts, issues: [selectedContextIssue]},
|
||||
{input: {start_date: earlierDate, end_date: earlierDate}, issues: [dateRangeIssue]},
|
||||
{input: {start_date: laterDate, end_date: earlierDate}, issues: [dateRangeIssue]},
|
||||
{
|
||||
input: {...noSelectedContexts, start_date: laterDate, end_date: earlierDate},
|
||||
issues: [selectedContextIssue, dateRangeIssue],
|
||||
},
|
||||
])('reports exact refinement issues in order for $input', ({input, issues}) => {
|
||||
expect(schema.safeParse(input).error?.issues).toEqual(issues);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulk-delete sudo verification', () => {
|
||||
it.each([
|
||||
{password: 'correct horse battery staple'},
|
||||
{mfa_method: 'totp', mfa_code: '123456'},
|
||||
{mfa_method: 'webauthn', webauthn_challenge: 'challenge'},
|
||||
])('preserves sudo fields only on the delete request: %j', (verification) => {
|
||||
expect(BulkDeleteSelfMessagesRequest.parse(verification)).toEqual({...bulkDeleteDefaults, ...verification});
|
||||
expect(BulkDeleteSelfMessagesFilter.parse(verification)).toEqual(bulkDeleteDefaults);
|
||||
expect(HarvestSelfDataRequest.parse(verification)).toEqual(bulkDeleteDefaults);
|
||||
});
|
||||
|
||||
it('still validates sudo fields on the extended request', () => {
|
||||
expect(BulkDeleteSelfMessagesRequest.safeParse({mfa_method: 'unknown'})).toMatchObject({
|
||||
success: false,
|
||||
error: {issues: [{code: 'invalid_union', path: ['mfa_method']}]},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -17,26 +17,21 @@ import {describe, expect, it} from 'vitest';
|
||||
|
||||
describe('CustomStatusPayload', () => {
|
||||
it('accepts single-codepoint unicode emoji names', () => {
|
||||
const result = CustomStatusPayload.safeParse({
|
||||
text: 'Coffee time',
|
||||
emoji_name: '☕',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.emoji_name).toBe('☕');
|
||||
}
|
||||
expect(
|
||||
CustomStatusPayload.parse({
|
||||
text: 'Coffee time',
|
||||
emoji_name: '☕',
|
||||
}),
|
||||
).toEqual({text: 'Coffee time', emoji_name: '☕'});
|
||||
});
|
||||
it('ignores emoji_name when emoji_id is provided', () => {
|
||||
const result = CustomStatusPayload.safeParse({
|
||||
text: 'Custom emoji status',
|
||||
emoji_id: '123456789012345678',
|
||||
emoji_name: 'not-a-unicode-emoji-and-way-too-long-to-validate-in-this-path',
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.emoji_id).toBe(123456789012345678n);
|
||||
expect(result.data.emoji_name).toBeUndefined();
|
||||
}
|
||||
expect(
|
||||
CustomStatusPayload.parse({
|
||||
text: 'Custom emoji status',
|
||||
emoji_id: '123456789012345678',
|
||||
emoji_name: 'not-a-unicode-emoji-and-way-too-long-to-validate-in-this-path',
|
||||
}),
|
||||
).toEqual({text: 'Custom emoji status', emoji_id: 123456789012345678n});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -47,30 +42,21 @@ describe('UserSettingsUpdateRequest synced_preferences', () => {
|
||||
searchEngines: create(SearchEngineSettingsSchema, {textSearchEngineId: 'google'}),
|
||||
}),
|
||||
);
|
||||
const result = UserSettingsUpdateRequest.safeParse({synced_preferences: encoded});
|
||||
expect(result.success).toBe(true);
|
||||
expect(UserSettingsUpdateRequest.parse({synced_preferences: encoded})).toEqual({synced_preferences: encoded});
|
||||
});
|
||||
it('accepts the empty snapshot', () => {
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: ''}).success).toBe(true);
|
||||
it.each([
|
||||
{name: 'empty snapshot', value: ''},
|
||||
{name: 'cleared snapshot', value: null},
|
||||
{name: 'exact size cap', value: 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH)},
|
||||
])('preserves $name', ({value}) => {
|
||||
expect(UserSettingsUpdateRequest.parse({synced_preferences: value})).toEqual({synced_preferences: value});
|
||||
});
|
||||
it('accepts null to clear the snapshot', () => {
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: null}).success).toBe(true);
|
||||
});
|
||||
it('rejects non-base64 strings', () => {
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: 'not_base64!!!'}).success).toBe(false);
|
||||
});
|
||||
it('rejects raw record/object payloads', () => {
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: {textSearchEngineId: 'google'}}).success).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
it('accepts a string at exactly the size cap', () => {
|
||||
const atCap = 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH);
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: atCap}).success).toBe(true);
|
||||
});
|
||||
it('rejects strings exceeding the size cap', () => {
|
||||
const oversized = 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH + 1);
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: oversized}).success).toBe(false);
|
||||
it.each([
|
||||
{name: 'non-base64 string', value: 'not_base64!!!'},
|
||||
{name: 'raw object', value: {textSearchEngineId: 'google'}},
|
||||
{name: 'above size cap', value: 'A'.repeat(SYNCED_PREFERENCES_MAX_ENCODED_LENGTH + 1)},
|
||||
])('rejects $name', ({value}) => {
|
||||
expect(UserSettingsUpdateRequest.safeParse({synced_preferences: value}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -79,7 +65,7 @@ describe('CreatePrivateChannelRequest', () => {
|
||||
const recipients = Array.from({length: MAX_GROUP_DM_OTHER_RECIPIENTS}, (_, index) =>
|
||||
String(100000000000000000n + BigInt(index)),
|
||||
);
|
||||
expect(CreatePrivateChannelRequest.safeParse({recipients}).success).toBe(true);
|
||||
expect(CreatePrivateChannelRequest.parse({recipients})).toEqual({recipients: recipients.map(BigInt)});
|
||||
});
|
||||
it('rejects group DM requests above the member limit', () => {
|
||||
const recipients = Array.from({length: MAX_GROUP_DM_OTHER_RECIPIENTS + 1}, (_, index) =>
|
||||
@@ -88,10 +74,12 @@ describe('CreatePrivateChannelRequest', () => {
|
||||
expect(CreatePrivateChannelRequest.safeParse({recipients}).success).toBe(false);
|
||||
});
|
||||
it('allows DM requests to the system user id 0', () => {
|
||||
const parsed = CreatePrivateChannelRequest.safeParse({recipient_id: '0'});
|
||||
expect(parsed.success).toBe(true);
|
||||
if (parsed.success) {
|
||||
expect(parsed.data.recipient_id).toBe(0n);
|
||||
}
|
||||
expect(CreatePrivateChannelRequest.parse({recipient_id: '0'})).toEqual({recipient_id: 0n});
|
||||
});
|
||||
it.each([{}, {recipient_id: '0', recipients: []}])('requires exactly one recipient form for %j', (input) => {
|
||||
expect(CreatePrivateChannelRequest.safeParse(input)).toMatchObject({
|
||||
success: false,
|
||||
error: {issues: [{message: 'Either recipient_id or recipients must be provided, but not both'}]},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,12 +19,9 @@ describe('UserPartialResponse', () => {
|
||||
flags: 0,
|
||||
};
|
||||
it('accepts valid user partial response', () => {
|
||||
const result = UserPartialResponse.safeParse(validUser);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('123456789012345678');
|
||||
expect(result.data.username).toBe('testuser');
|
||||
}
|
||||
const result = UserPartialResponse.parse(validUser);
|
||||
expect(result.id).toBe('123456789012345678');
|
||||
expect(result.username).toBe('testuser');
|
||||
});
|
||||
it('accepts null global_name and avatar', () => {
|
||||
const user = {
|
||||
@@ -38,19 +35,13 @@ describe('UserPartialResponse', () => {
|
||||
});
|
||||
it('accepts optional bot flag', () => {
|
||||
const user = {...validUser, bot: true};
|
||||
const result = UserPartialResponse.safeParse(user);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.bot).toBe(true);
|
||||
}
|
||||
const result = UserPartialResponse.parse(user);
|
||||
expect(result.bot).toBe(true);
|
||||
});
|
||||
it('accepts optional system flag', () => {
|
||||
const user = {...validUser, system: true};
|
||||
const result = UserPartialResponse.safeParse(user);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.system).toBe(true);
|
||||
}
|
||||
const result = UserPartialResponse.parse(user);
|
||||
expect(result.system).toBe(true);
|
||||
});
|
||||
it('requires id', () => {
|
||||
const {id, ...userWithoutId} = validUser;
|
||||
@@ -180,12 +171,9 @@ describe('RelationshipResponse', () => {
|
||||
friend_shares_voice_activity: true,
|
||||
};
|
||||
it('accepts valid relationship', () => {
|
||||
const result = RelationshipResponse.safeParse(validRelationship);
|
||||
expect(result.success).toBe(true);
|
||||
if (result.success) {
|
||||
expect(result.data.id).toBe('123456789012345678');
|
||||
expect(result.data.type).toBe(1);
|
||||
}
|
||||
const result = RelationshipResponse.parse(validRelationship);
|
||||
expect(result.id).toBe('123456789012345678');
|
||||
expect(result.type).toBe(1);
|
||||
});
|
||||
it('accepts relationship with null nickname', () => {
|
||||
const relationship = {...validRelationship, nickname: null};
|
||||
|
||||
Reference in new issue
Block a user