mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
1 parent
bc17922b02
commit
ece622e800
471 files changed
+56262
-35932
No files matched your search
@@ -144,8 +144,10 @@ export interface SearchableReport {
|
||||
status: number;
|
||||
reportType: number;
|
||||
category: string;
|
||||
reason: string | null;
|
||||
additionalInfo: string | null;
|
||||
reportedUserId: string | null;
|
||||
reportedWebhookId: string | null;
|
||||
reportedGuildId: string | null;
|
||||
reportedGuildName: string | null;
|
||||
reportedMessageId: string | null;
|
||||
@@ -163,9 +165,12 @@ export interface ReportSearchFilters {
|
||||
status?: number;
|
||||
reportType?: number;
|
||||
category?: string;
|
||||
reason?: string;
|
||||
reportedUserId?: string;
|
||||
reportedWebhookId?: string;
|
||||
reportedGuildId?: string;
|
||||
reportedMessageId?: string;
|
||||
reportedChannelId?: string;
|
||||
guildContextId?: string;
|
||||
resolvedByAdminId?: string;
|
||||
isResolved?: boolean;
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {IsoTimestampStringType} from '@fluxer/schema/src/primitives/DateValidators';
|
||||
import {createStringType, SnowflakeStringType} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ReportLegalHoldRequest = z.object({
|
||||
legal_hold_until: IsoTimestampStringType.nullable().describe(
|
||||
'When the hold ends, as an ISO 8601 timestamp in the future. Null clears the hold',
|
||||
),
|
||||
legal_hold_reason: createStringType(1, 512)
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Why the report is kept past its retention period. Required to place a hold, ignored when clearing one'),
|
||||
});
|
||||
|
||||
export type ReportLegalHoldRequest = z.infer<typeof ReportLegalHoldRequest>;
|
||||
|
||||
export const ReportLegalHoldResponse = z.object({
|
||||
report_id: SnowflakeStringType,
|
||||
legal_hold_until: z.string().nullable().describe('When the hold ends. Null when the report has no hold'),
|
||||
legal_hold_reason: z.string().nullable().describe('Why the report is held. Null when the report has no hold'),
|
||||
});
|
||||
|
||||
export type ReportLegalHoldResponse = z.infer<typeof ReportLegalHoldResponse>;
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
withOpenApiType,
|
||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {EmailBlocklistEntryType} from '@fluxer/schema/src/primitives/UserValidators';
|
||||
import {WebhookTypeSchema} from '@fluxer/schema/src/primitives/WebhookValidators';
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import {z} from 'zod';
|
||||
|
||||
@@ -171,7 +172,9 @@ export const SearchReportsRequest = z.object({
|
||||
status: ReportStatusSchema.optional(),
|
||||
report_type: ReportTypeSchema.optional(),
|
||||
category: createStringType(1, 128).optional().describe('Filter by report category'),
|
||||
reason: createStringType(1, 64).optional().describe('Filter by report reason key'),
|
||||
reported_user_id: SnowflakeType.optional().describe('Filter by reported user ID'),
|
||||
reported_webhook_id: SnowflakeType.optional().describe('Filter by the webhook that sent the reported message'),
|
||||
reported_guild_id: SnowflakeType.optional().describe('Filter by reported guild ID'),
|
||||
reported_channel_id: SnowflakeType.optional().describe('Filter by reported channel ID'),
|
||||
guild_context_id: SnowflakeType.optional().describe('Filter by guild context where report was made'),
|
||||
@@ -213,8 +216,10 @@ export const ListReportsQuery = z.object({
|
||||
status: ReportStatusFilterEnum.optional().describe('Only return reports with this status'),
|
||||
report_type: ReportTypeFilterEnum.optional().describe('Only return reports about this kind of entity'),
|
||||
category: createStringType(1, 128).optional().describe('Only return reports filed under this category'),
|
||||
reason: createStringType(1, 64).optional().describe('Only return reports filed with this report reason key'),
|
||||
reporter_id: SnowflakeType.optional().describe('Only return reports submitted by this user'),
|
||||
reported_user_id: SnowflakeType.optional().describe('Only return reports about this user'),
|
||||
reported_webhook_id: SnowflakeType.optional().describe('Only return reports about messages sent by this webhook'),
|
||||
reported_guild_id: SnowflakeType.optional().describe('Only return reports about this community'),
|
||||
reported_channel_id: SnowflakeType.optional().describe('Only return reports about content in this channel'),
|
||||
guild_context_id: SnowflakeType.optional().describe('Only return reports filed from within this community'),
|
||||
@@ -241,7 +246,12 @@ const ReportResolutionEnum = createNamedStringLiteralUnion(
|
||||
export const UpdateReportRequest = z.object({
|
||||
status: z.literal('resolved').describe('The status to move the report to'),
|
||||
public_comment: createStringType(0, 512).optional().describe('Public comment to include with the resolution'),
|
||||
notify_reporter: z.boolean().default(true).describe('Whether to notify the reporter by system DM and email'),
|
||||
notify_reporter: z
|
||||
.boolean()
|
||||
.default(true)
|
||||
.describe(
|
||||
'Whether to include the public comment in the notice sent to the reporter. The reporter is notified either way',
|
||||
),
|
||||
resolution: ReportResolutionEnum.optional().describe('How the report was resolved'),
|
||||
});
|
||||
|
||||
@@ -343,7 +353,9 @@ export const BanUrlRequest = z.object({
|
||||
.min(0)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe('Severity: 0 allow, 1 warn, 2 block, 3 block+report (default 2)'),
|
||||
.describe(
|
||||
'Stored severity label: 0 allow, 1 warn, 2 block, 3 block and report (default 2). The label is informational and every row blocks whatever its severity.',
|
||||
),
|
||||
source_url: createStringType(1, 2048).optional().describe('Upstream source URL if imported from a feed'),
|
||||
notes: createStringType(1, 1024).optional().describe('Internal notes for audit trail'),
|
||||
});
|
||||
@@ -365,7 +377,9 @@ export const BanUrlDomainRequest = z.object({
|
||||
.min(0)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe('Severity: 0 allow, 1 warn, 2 block, 3 block+report (default 2)'),
|
||||
.describe(
|
||||
'Stored severity label: 0 allow, 1 warn, 2 block, 3 block and report (default 2). The label is informational and every row blocks whatever its severity.',
|
||||
),
|
||||
source_url: createStringType(1, 2048).optional().describe('Upstream source URL if imported from a feed'),
|
||||
notes: createStringType(1, 1024).optional().describe('Internal notes for audit trail'),
|
||||
});
|
||||
@@ -377,7 +391,15 @@ export const BanFileShaRequest = z.object({
|
||||
.refine((v) => /^[0-9a-fA-F]{64}$/.test(v), 'Must be a 64-character hex SHA-256')
|
||||
.describe('SHA-256 in hex'),
|
||||
category: createStringType(1, 64).optional(),
|
||||
severity: z.number().int().min(0).max(3).optional(),
|
||||
severity: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe(
|
||||
'Stored severity label: 0 allow, 1 warn, 2 block, 3 block and report (default 2). The label is informational and every row blocks whatever its severity.',
|
||||
),
|
||||
content_type: createStringType(1, 128).optional().describe('Optional MIME type hint for observability'),
|
||||
source_url: createStringType(1, 2048).optional(),
|
||||
notes: createStringType(1, 1024).optional(),
|
||||
@@ -392,7 +414,15 @@ const AvatarHashShortType = createStringType(8, 10).refine(
|
||||
export const BanAvatarHashRequest = z.object({
|
||||
hashes: z.array(AvatarHashShortType).min(1).max(1000),
|
||||
category: createStringType(1, 64).optional(),
|
||||
severity: z.number().int().min(0).max(3).optional(),
|
||||
severity: z
|
||||
.number()
|
||||
.int()
|
||||
.min(0)
|
||||
.max(3)
|
||||
.optional()
|
||||
.describe(
|
||||
'Stored severity label: 0 allow, 1 warn, 2 block, 3 block and report (default 2). The label is informational and every row blocks whatever its severity.',
|
||||
),
|
||||
source_url: createStringType(1, 2048).optional(),
|
||||
reason: createStringType(1, 1024).optional(),
|
||||
notes: createStringType(1, 1024).optional(),
|
||||
@@ -1415,6 +1445,15 @@ export const MessageShredStatusResponse = z.union([
|
||||
MessageShredStatusNotFoundResponse,
|
||||
MessageShredStatusProgressResponse,
|
||||
]);
|
||||
const ReportMissingAttachmentSchema = z.object({
|
||||
id: SnowflakeStringType,
|
||||
filename: createStringType(1, 256),
|
||||
nsfw: z.boolean().nullable(),
|
||||
content_type: z.string().nullable(),
|
||||
width: Int32Type.nullable(),
|
||||
height: Int32Type.nullable(),
|
||||
size: NonNegativeSafeIntegerType.nullable(),
|
||||
});
|
||||
const ReportMessageContextSchema = z.object({
|
||||
id: SnowflakeStringType,
|
||||
channel_id: SnowflakeStringType,
|
||||
@@ -1434,8 +1473,77 @@ const ReportMessageContextSchema = z.object({
|
||||
author_global_name: z.string().nullable(),
|
||||
author_discriminator: z.string(),
|
||||
author_avatar: z.string().nullable(),
|
||||
webhook_id: SnowflakeStringType.nullable().optional(),
|
||||
author_bot: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Whether the author is a bot account. Null for a webhook message or an account that no longer resolves.'),
|
||||
missing_attachments: z
|
||||
.array(ReportMissingAttachmentSchema)
|
||||
.optional()
|
||||
.describe('Attachments of the message that could not be copied into the report when it was filed'),
|
||||
user_prior_ncmec_report_ids: z.array(createStringType(1, 256)).max(100).optional(),
|
||||
});
|
||||
const ReportProfileSnapshotAssetSchema = z.object({
|
||||
hash: z.string().describe('The asset hash at the time the report was filed'),
|
||||
url: createStringType(1, 2048)
|
||||
.nullable()
|
||||
.describe(
|
||||
'A download URL valid for 5 minutes. Null when the asset could not be copied into the report or no download URL could be made.',
|
||||
),
|
||||
});
|
||||
const ReportProfileSnapshotUserSchema = z.object({
|
||||
id: SnowflakeStringType,
|
||||
username: z.string().nullable(),
|
||||
discriminator: z.string().nullable(),
|
||||
global_name: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
pronouns: z.string().nullable(),
|
||||
avatar: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
banner: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
});
|
||||
const ReportProfileSnapshotMemberSchema = z.object({
|
||||
guild_id: SnowflakeStringType,
|
||||
nick: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
pronouns: z.string().nullable(),
|
||||
joined_at: z.string().nullable(),
|
||||
avatar: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
banner: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
});
|
||||
const ReportProfileSnapshotGuildSchema = z.object({
|
||||
id: SnowflakeStringType,
|
||||
name: z.string().nullable(),
|
||||
vanity_url_code: z.string().nullable(),
|
||||
icon: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
banner: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
splash: ReportProfileSnapshotAssetSchema.nullable(),
|
||||
});
|
||||
const ReportProfileSnapshotAdminSchema = z.object({
|
||||
captured_at: z.string().describe('When the snapshot was taken'),
|
||||
user: ReportProfileSnapshotUserSchema.nullable().describe('The reported account as it was when the report was filed'),
|
||||
member: ReportProfileSnapshotMemberSchema.nullable().describe(
|
||||
'The community profile of the reported account when the report was filed from within a community',
|
||||
),
|
||||
guild: ReportProfileSnapshotGuildSchema.nullable().describe(
|
||||
'The reported community as it was when the report was filed',
|
||||
),
|
||||
});
|
||||
const ReportFlowAnswerItemSchema = z.object({id: z.string(), label: z.string()});
|
||||
const ReportFlowAnswerStepSchema = z.object({
|
||||
screen_id: z.string(),
|
||||
screen_title: z.string(),
|
||||
option_id: z.string().nullable(),
|
||||
option_label: z.string().nullable(),
|
||||
items: z.array(ReportFlowAnswerItemSchema),
|
||||
});
|
||||
const ReportFlowAnswersSchema = z.object({
|
||||
revision_hash: z.string(),
|
||||
surface: z.string(),
|
||||
locale: z.string().nullable(),
|
||||
steps: z.array(ReportFlowAnswerStepSchema),
|
||||
});
|
||||
export const ReportAdminResponseSchema = z.object({
|
||||
report_id: SnowflakeStringType,
|
||||
reporter_id: SnowflakeStringType.nullable(),
|
||||
@@ -1457,6 +1565,29 @@ export const ReportAdminResponseSchema = z.object({
|
||||
reported_user_global_name: z.string().nullable(),
|
||||
reported_user_discriminator: z.string().nullable(),
|
||||
reported_user_avatar_hash: z.string().nullable(),
|
||||
reported_user_bot: z
|
||||
.boolean()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'Whether the reported account is a bot. Null when there is no reported account or it no longer resolves.',
|
||||
),
|
||||
reported_webhook_id: SnowflakeStringType.nullable().optional(),
|
||||
reported_webhook_name: z.string().nullable().optional(),
|
||||
reported_webhook_avatar_hash: z.string().nullable().optional(),
|
||||
reported_webhook_default_name: z.string().nullable().optional(),
|
||||
reported_webhook_default_avatar_hash: z.string().nullable().optional(),
|
||||
reported_webhook_type: WebhookTypeSchema.nullable().optional(),
|
||||
reported_webhook_application_id: SnowflakeStringType.nullable().optional(),
|
||||
reported_webhook_channel_id: SnowflakeStringType.nullable().optional(),
|
||||
reported_webhook_guild_id: SnowflakeStringType.nullable().optional(),
|
||||
reported_webhook_created_at: z.string().nullable().optional(),
|
||||
reported_webhook_creator_id: SnowflakeStringType.nullable().optional(),
|
||||
reported_webhook_creator_tag: z.string().nullable().optional(),
|
||||
reported_webhook_creator_username: z.string().nullable().optional(),
|
||||
reported_webhook_creator_global_name: z.string().nullable().optional(),
|
||||
reported_webhook_creator_discriminator: z.string().nullable().optional(),
|
||||
reported_webhook_creator_avatar_hash: z.string().nullable().optional(),
|
||||
reported_guild_id: SnowflakeStringType.nullable(),
|
||||
reported_guild_name: z.string().nullable(),
|
||||
reported_guild_icon_hash: z.string().nullable(),
|
||||
@@ -1483,6 +1614,28 @@ export const ReportAdminResponseSchema = z.object({
|
||||
mutual_dm_channel_id: SnowflakeStringType.nullable().optional(),
|
||||
message_context: z.array(ReportMessageContextSchema).optional(),
|
||||
message_responses: z.array(MessageResponseSchema).optional(),
|
||||
reported_profile_snapshot: ReportProfileSnapshotAdminSchema.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'The reported profile as it was when the report was filed. Returned by the report detail only. Null for a report filed before snapshots were stored.',
|
||||
),
|
||||
legal_hold_until: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
'When the legal hold on the report ends. The report is kept past its retention period until then. Returned by the report detail only. Null when the report has no hold.',
|
||||
),
|
||||
legal_hold_reason: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Why the report is held. Returned by the report detail only. Null when the report has no hold.'),
|
||||
reason: z.string().nullable().optional(),
|
||||
reason_label: z.string().nullable().optional(),
|
||||
reason_highest_priority: z.boolean().nullable().optional(),
|
||||
flow: ReportFlowAnswersSchema.nullable().optional(),
|
||||
reporter_good_faith_confirmed: z.boolean().nullable().optional(),
|
||||
});
|
||||
export const ResolveReportResponse = z.object({
|
||||
report_id: SnowflakeStringType,
|
||||
@@ -1492,11 +1645,23 @@ export const ResolveReportResponse = z.object({
|
||||
});
|
||||
const SearchReportsResponse = z.object({
|
||||
reports: z.array(ReportAdminResponseSchema),
|
||||
total: z.number(),
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
total: z.number().describe('Number of reports in the search index that match the filters'),
|
||||
offset: z.number().describe('The offset this page was read from'),
|
||||
limit: z.number().describe('The limit this page was read with'),
|
||||
});
|
||||
export const AdminReportListResponse = SearchReportsResponse;
|
||||
const ReportReasonSchema = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
highest_priority: z.boolean(),
|
||||
legacy_category_message: z.string(),
|
||||
legacy_category_user: z.string(),
|
||||
legacy_category_guild: z.string(),
|
||||
});
|
||||
export const AdminReportReasonsResponse = z.object({
|
||||
reasons: z.array(ReportReasonSchema),
|
||||
});
|
||||
export type AdminReportReasonsResponse = z.infer<typeof AdminReportReasonsResponse>;
|
||||
const LimitKeyMetadataSchema = z.object({
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
|
||||
@@ -12,7 +12,7 @@ export const ValidationErrorItem = z.object({
|
||||
export type ValidationErrorItem = z.infer<typeof ValidationErrorItem>;
|
||||
|
||||
export const ErrorResponse = z.looseObject({
|
||||
code: z.string().describe('Machine-readable error code'),
|
||||
code: z.string().describe('Machine-readable error code. Known values are listed in the APIErrorCode schema'),
|
||||
message: z.string().describe('Human-readable error message'),
|
||||
errors: z.array(ValidationErrorItem).optional().describe('Field-specific validation errors'),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
ReportFlowMessageSubmissionRequest,
|
||||
ReportFlowQuery,
|
||||
ReportFlowResponse,
|
||||
ReportFlowUserSubmissionRequest,
|
||||
} from '@fluxer/schema/src/domains/report/ReportFlowSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const userFlowResponse = {
|
||||
target_type: 'user',
|
||||
surface: 'in_app',
|
||||
revision_hash: '9c41d2a07e5b18f3',
|
||||
locale: 'en-US',
|
||||
start_screen_id: 'profile_intro',
|
||||
guidelines_url: 'https://fluxer.app/guidelines',
|
||||
screens: [
|
||||
{
|
||||
id: 'profile_intro',
|
||||
title: 'Report profile',
|
||||
subtitle: "Tell us what's wrong with this profile. They won't be told who reported them.",
|
||||
urgent: false,
|
||||
options: [
|
||||
{
|
||||
id: 'learn_more',
|
||||
label: 'Read the Fluxer Community Guidelines',
|
||||
outcome: {
|
||||
type: 'link',
|
||||
screen_id: null,
|
||||
reason: null,
|
||||
notice_id: null,
|
||||
url: 'https://fluxer.app/guidelines',
|
||||
},
|
||||
},
|
||||
],
|
||||
options_heading: 'Learn more',
|
||||
checklist: null,
|
||||
next_screen_id: 'profile_parts',
|
||||
},
|
||||
{
|
||||
id: 'profile_parts',
|
||||
title: 'Which parts of their profile are a problem?',
|
||||
subtitle: 'Pick as many as you need.',
|
||||
urgent: false,
|
||||
options: [],
|
||||
options_heading: null,
|
||||
checklist: {
|
||||
items: [
|
||||
{id: 'photo', label: 'Pictures', description: 'What they use as avatar and banner'},
|
||||
{id: 'name', label: 'Names', description: 'Username, display name or community nickname'},
|
||||
{id: 'descriptors', label: 'Profile text', description: 'Bio, custom status or pronouns'},
|
||||
],
|
||||
min_checked: 1,
|
||||
outcome: {type: 'screen', screen_id: 'root_user', reason: null, notice_id: null, url: null},
|
||||
},
|
||||
next_screen_id: null,
|
||||
},
|
||||
],
|
||||
notices: [
|
||||
{
|
||||
id: 'need_more_info',
|
||||
title: "We can't act on this yet",
|
||||
body: "To review this, we need a message where they say how old they are or make it clear they're under Fluxer's minimum age. Report that message instead.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const messageSubmission = {
|
||||
channel_id: '1427410010101010101',
|
||||
message_id: '1427410099999999999',
|
||||
revision_hash: '6b1f0c9e2a7d4413',
|
||||
locale: 'de',
|
||||
steps: [
|
||||
{screen_id: 'root_message', option_id: 'abuse'},
|
||||
{screen_id: 'abuse', option_id: 'sexual'},
|
||||
{screen_id: 'sexual', option_id: 'minor_sexual'},
|
||||
{screen_id: 'minor_sexual', option_id: 'csam'},
|
||||
],
|
||||
};
|
||||
|
||||
const userSubmission = {
|
||||
user_id: '1427410010101010102',
|
||||
guild_id: '1427410010101010103',
|
||||
revision_hash: '9c41d2a07e5b18f3',
|
||||
locale: 'en-US',
|
||||
steps: [
|
||||
{screen_id: 'profile_intro'},
|
||||
{screen_id: 'profile_parts', item_ids: ['photo']},
|
||||
{screen_id: 'root_user', option_id: 'abuse'},
|
||||
{screen_id: 'profile_abuse', option_id: 'harassment'},
|
||||
],
|
||||
};
|
||||
|
||||
describe('ReportFlowResponse', () => {
|
||||
it('parses the user flow example', () => {
|
||||
expect(ReportFlowResponse.parse(userFlowResponse)).toEqual(userFlowResponse);
|
||||
});
|
||||
|
||||
it('accepts reasons and locales it does not know', () => {
|
||||
const response = {
|
||||
...userFlowResponse,
|
||||
locale: 'tlh-Latn',
|
||||
screens: [
|
||||
{
|
||||
...userFlowResponse.screens[0],
|
||||
options: [
|
||||
{
|
||||
id: 'future_option',
|
||||
label: 'A future option',
|
||||
outcome: {type: 'submit', screen_id: null, reason: 'reason_added_later', notice_id: null, url: null},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(ReportFlowResponse.safeParse(response).success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown outcome type', () => {
|
||||
const response = {
|
||||
...userFlowResponse,
|
||||
screens: [
|
||||
{
|
||||
...userFlowResponse.screens[0],
|
||||
options: [
|
||||
{
|
||||
id: 'x',
|
||||
label: 'X',
|
||||
outcome: {type: 'redirect', screen_id: null, reason: null, notice_id: null, url: null},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(ReportFlowResponse.safeParse(response).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReportFlowQuery', () => {
|
||||
it('defaults the surface to in_app', () => {
|
||||
expect(ReportFlowQuery.parse({})).toEqual({surface: 'in_app'});
|
||||
});
|
||||
|
||||
it('accepts any locale tag up to 35 characters', () => {
|
||||
expect(ReportFlowQuery.parse({surface: 'dsa', locale: 'xx-YY'})).toEqual({surface: 'dsa', locale: 'xx-YY'});
|
||||
expect(ReportFlowQuery.safeParse({locale: 'a'.repeat(36)}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an unknown surface', () => {
|
||||
expect(ReportFlowQuery.safeParse({surface: 'nope'}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReportFlowMessageSubmissionRequest', () => {
|
||||
it('parses the message submission example', () => {
|
||||
const parsed = ReportFlowMessageSubmissionRequest.parse(messageSubmission);
|
||||
expect(parsed.steps).toEqual(messageSubmission.steps);
|
||||
expect(parsed.revision_hash).toBe('6b1f0c9e2a7d4413');
|
||||
expect(parsed.locale).toBe('de');
|
||||
});
|
||||
|
||||
it('accepts a checklist step and an unknown locale string', () => {
|
||||
const body = {
|
||||
...messageSubmission,
|
||||
locale: 'zz-Unknown',
|
||||
steps: [
|
||||
{screen_id: 'root_message', option_id: 'private_info'},
|
||||
{screen_id: 'private_info', item_ids: ['email', 'phone']},
|
||||
],
|
||||
};
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(body).success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts 16 steps and rejects 17', () => {
|
||||
const step = {screen_id: 'root_message', option_id: 'abuse'};
|
||||
const sixteen = {...messageSubmission, steps: Array.from({length: 16}, () => step)};
|
||||
const seventeen = {...messageSubmission, steps: Array.from({length: 17}, () => step)};
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(sixteen).success).toBe(true);
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(seventeen).success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects an empty walk', () => {
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse({...messageSubmission, steps: []}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts 32 item ids and rejects 33 or none', () => {
|
||||
const items = (count: number) => Array.from({length: count}, (_, index) => `item_${index}`);
|
||||
const body = (itemIds: Array<string>) => ({
|
||||
...messageSubmission,
|
||||
steps: [{screen_id: 'private_info', item_ids: itemIds}],
|
||||
});
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(body(items(32))).success).toBe(true);
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(body(items(33))).success).toBe(false);
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(body([])).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a 48-character id and rejects a 49-character id', () => {
|
||||
const body = (id: string) => ({...messageSubmission, steps: [{screen_id: id, option_id: 'abuse'}]});
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(body('a'.repeat(48))).success).toBe(true);
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(body('a'.repeat(49))).success).toBe(false);
|
||||
expect(
|
||||
ReportFlowMessageSubmissionRequest.safeParse({
|
||||
...messageSubmission,
|
||||
steps: [{screen_id: 'root_message', option_id: 'b'.repeat(49)}],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
ReportFlowMessageSubmissionRequest.safeParse({
|
||||
...messageSubmission,
|
||||
steps: [{screen_id: 'private_info', item_ids: ['c'.repeat(49)]}],
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('requires the revision hash and the message ids', () => {
|
||||
const {revision_hash: _revisionHash, ...withoutHash} = messageSubmission;
|
||||
const {message_id: _messageId, ...withoutMessage} = messageSubmission;
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(withoutHash).success).toBe(false);
|
||||
expect(ReportFlowMessageSubmissionRequest.safeParse(withoutMessage).success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ReportFlowUserSubmissionRequest', () => {
|
||||
it('parses the user submission example with info steps', () => {
|
||||
const parsed = ReportFlowUserSubmissionRequest.parse(userSubmission);
|
||||
expect(parsed.steps).toEqual(userSubmission.steps);
|
||||
expect(parsed.locale).toBe('en-US');
|
||||
});
|
||||
|
||||
it('makes the guild and locale optional', () => {
|
||||
const {guild_id: _guildId, locale: _locale, ...body} = userSubmission;
|
||||
expect(ReportFlowUserSubmissionRequest.safeParse(body).success).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
createNamedStringLiteralUnion,
|
||||
createStringType,
|
||||
SnowflakeType,
|
||||
withOpenApiType,
|
||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ReportFlowTargetType = withOpenApiType(
|
||||
createNamedStringLiteralUnion(
|
||||
[
|
||||
['message', 'message', 'A message'],
|
||||
['user', 'user', 'A user profile'],
|
||||
['guild', 'guild', 'A community, on the DSA form only'],
|
||||
],
|
||||
'Kind of entity a report flow is about',
|
||||
),
|
||||
'ReportFlowTargetType',
|
||||
);
|
||||
|
||||
export type ReportFlowTargetType = z.infer<typeof ReportFlowTargetType>;
|
||||
|
||||
export const ReportFlowSurface = withOpenApiType(
|
||||
createNamedStringLiteralUnion(
|
||||
[
|
||||
['in_app', 'in_app', 'The in-app report flow'],
|
||||
['dsa', 'dsa', 'The public Digital Services Act report form'],
|
||||
],
|
||||
'Where the flow is shown',
|
||||
),
|
||||
'ReportFlowSurface',
|
||||
);
|
||||
|
||||
export type ReportFlowSurface = z.infer<typeof ReportFlowSurface>;
|
||||
|
||||
export const ReportFlowOutcomeType = withOpenApiType(
|
||||
createNamedStringLiteralUnion(
|
||||
[
|
||||
['screen', 'screen', 'Go to another screen'],
|
||||
['submit', 'submit', 'Show the summary, then file a report'],
|
||||
['end', 'end', 'Finish without filing a report'],
|
||||
['link', 'link', 'Open an external page'],
|
||||
],
|
||||
'What choosing an option does',
|
||||
),
|
||||
'ReportFlowOutcomeType',
|
||||
);
|
||||
|
||||
export type ReportFlowOutcomeType = z.infer<typeof ReportFlowOutcomeType>;
|
||||
|
||||
export const ReportFlowPathParams = z.object({target_type: ReportFlowTargetType});
|
||||
|
||||
export type ReportFlowPathParams = z.infer<typeof ReportFlowPathParams>;
|
||||
|
||||
export const ReportFlowQuery = z.object({
|
||||
surface: ReportFlowSurface.default('in_app'),
|
||||
locale: createStringType(1, 35)
|
||||
.optional()
|
||||
.describe(
|
||||
'Language tag of the client UI. The server picks the closest supported locale and reports it in the locale field',
|
||||
),
|
||||
});
|
||||
|
||||
export type ReportFlowQuery = z.infer<typeof ReportFlowQuery>;
|
||||
|
||||
export const ReportFlowOutcome = z.object({
|
||||
type: ReportFlowOutcomeType,
|
||||
screen_id: z.string().nullable().describe('Next screen when type is screen'),
|
||||
reason: z.string().nullable().describe('Report reason when type is submit'),
|
||||
notice_id: z.string().nullable().describe('Notice to show when type is end, null for the thank-you screen'),
|
||||
url: z.string().nullable().describe('Page to open when type is link'),
|
||||
});
|
||||
|
||||
export type ReportFlowOutcome = z.infer<typeof ReportFlowOutcome>;
|
||||
|
||||
export const ReportFlowOption = z.object({id: z.string(), label: z.string(), outcome: ReportFlowOutcome});
|
||||
|
||||
export type ReportFlowOption = z.infer<typeof ReportFlowOption>;
|
||||
|
||||
export const ReportFlowChecklistItem = z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type ReportFlowChecklistItem = z.infer<typeof ReportFlowChecklistItem>;
|
||||
|
||||
export const ReportFlowChecklist = z.object({
|
||||
items: z.array(ReportFlowChecklistItem),
|
||||
min_checked: z.number().int(),
|
||||
outcome: ReportFlowOutcome,
|
||||
});
|
||||
|
||||
export type ReportFlowChecklist = z.infer<typeof ReportFlowChecklist>;
|
||||
|
||||
export const ReportFlowScreen = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().nullable(),
|
||||
urgent: z.boolean(),
|
||||
options: z.array(ReportFlowOption),
|
||||
options_heading: z.string().nullable(),
|
||||
checklist: ReportFlowChecklist.nullable(),
|
||||
next_screen_id: z.string().nullable().describe('Set on an info screen, which has a Next button instead of choices'),
|
||||
});
|
||||
|
||||
export type ReportFlowScreen = z.infer<typeof ReportFlowScreen>;
|
||||
|
||||
export const ReportFlowNotice = z.object({id: z.string(), title: z.string(), body: z.string()});
|
||||
|
||||
export type ReportFlowNotice = z.infer<typeof ReportFlowNotice>;
|
||||
|
||||
export const ReportFlowResponse = z.object({
|
||||
target_type: ReportFlowTargetType,
|
||||
surface: ReportFlowSurface,
|
||||
revision_hash: z.string(),
|
||||
locale: z.string().describe('Locale the copy was rendered in'),
|
||||
start_screen_id: z.string(),
|
||||
guidelines_url: z.string().nullable().describe('Community guidelines page, null when the instance has none'),
|
||||
screens: z.array(ReportFlowScreen),
|
||||
notices: z.array(ReportFlowNotice),
|
||||
});
|
||||
|
||||
export type ReportFlowResponse = z.infer<typeof ReportFlowResponse>;
|
||||
|
||||
const REPORT_FLOW_ID = createStringType(1, 48);
|
||||
|
||||
export const ReportFlowStep = z.object({
|
||||
screen_id: REPORT_FLOW_ID,
|
||||
option_id: REPORT_FLOW_ID.optional(),
|
||||
item_ids: z.array(REPORT_FLOW_ID).min(1).max(32).optional(),
|
||||
});
|
||||
|
||||
export type ReportFlowStep = z.infer<typeof ReportFlowStep>;
|
||||
|
||||
export const ReportFlowAnswerFields = {
|
||||
revision_hash: createStringType(1, 64),
|
||||
steps: z.array(ReportFlowStep).min(1).max(16),
|
||||
locale: createStringType(1, 35).optional().describe('Language tag the reporter saw the flow in'),
|
||||
};
|
||||
|
||||
export const ReportFlowMessageSubmissionRequest = z.object({
|
||||
...ReportFlowAnswerFields,
|
||||
channel_id: SnowflakeType,
|
||||
message_id: SnowflakeType,
|
||||
});
|
||||
|
||||
export type ReportFlowMessageSubmissionRequest = z.infer<typeof ReportFlowMessageSubmissionRequest>;
|
||||
|
||||
export const ReportFlowUserSubmissionRequest = z.object({
|
||||
...ReportFlowAnswerFields,
|
||||
user_id: SnowflakeType,
|
||||
guild_id: z.optional(SnowflakeType),
|
||||
});
|
||||
|
||||
export type ReportFlowUserSubmissionRequest = z.infer<typeof ReportFlowUserSubmissionRequest>;
|
||||
@@ -0,0 +1,101 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
listReportProfileSnapshotAssets,
|
||||
parseReportProfileSnapshot,
|
||||
type ReportProfileSnapshot,
|
||||
serializeReportProfileSnapshot,
|
||||
} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const fullSnapshot: ReportProfileSnapshot = {
|
||||
captured_at: '2026-10-04T12:00:00.000Z',
|
||||
user: {
|
||||
id: '1427410010101010101',
|
||||
username: 'target',
|
||||
discriminator: 1,
|
||||
global_name: 'Target',
|
||||
bio: 'Original bio',
|
||||
pronouns: 'they/them',
|
||||
avatar: {hash: 'a_user', key: 'reports/1/profile/avatar/a_user'},
|
||||
banner: {hash: 'user-banner', key: null},
|
||||
},
|
||||
member: {
|
||||
guild_id: '1427410010101010102',
|
||||
nick: 'Nick',
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
joined_at: '2026-01-01T00:00:00.000Z',
|
||||
avatar: {hash: 'member-avatar', key: 'reports/1/profile/avatar/member-avatar'},
|
||||
banner: null,
|
||||
},
|
||||
guild: {
|
||||
id: '1427410010101010102',
|
||||
name: 'Community',
|
||||
vanity_url_code: null,
|
||||
icon: {hash: 'icon', key: 'reports/1/profile/icon/icon'},
|
||||
banner: null,
|
||||
splash: {hash: 'splash', key: 'reports/1/profile/splash/splash'},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ReportProfileSnapshot', () => {
|
||||
it('round-trips a full snapshot', () => {
|
||||
expect(parseReportProfileSnapshot(serializeReportProfileSnapshot(fullSnapshot))).toEqual(fullSnapshot);
|
||||
});
|
||||
|
||||
it('accepts a snapshot with only some parts', () => {
|
||||
const userOnly: ReportProfileSnapshot = {
|
||||
captured_at: '2026-10-04T12:00:00.000Z',
|
||||
user: {
|
||||
id: '1',
|
||||
username: null,
|
||||
discriminator: null,
|
||||
global_name: null,
|
||||
bio: null,
|
||||
pronouns: null,
|
||||
avatar: null,
|
||||
banner: null,
|
||||
},
|
||||
member: null,
|
||||
guild: null,
|
||||
};
|
||||
expect(parseReportProfileSnapshot(JSON.stringify(userOnly))).toEqual(userOnly);
|
||||
});
|
||||
|
||||
it('drops unknown keys', () => {
|
||||
const parsed = parseReportProfileSnapshot(JSON.stringify({...fullSnapshot, extra: true}));
|
||||
expect(parsed).toEqual(fullSnapshot);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['null', null],
|
||||
['undefined', undefined],
|
||||
['an empty string', ''],
|
||||
['bad JSON', '{"captured_at":'],
|
||||
['a JSON array', '[]'],
|
||||
['a JSON string', '"snapshot"'],
|
||||
['a missing part', JSON.stringify({captured_at: '2026-10-04T12:00:00.000Z', user: null, member: null})],
|
||||
['a wrong field type', JSON.stringify({...fullSnapshot, user: {...fullSnapshot.user, discriminator: '0001'}})],
|
||||
['an asset without a hash', JSON.stringify({...fullSnapshot, guild: {...fullSnapshot.guild, icon: {key: null}}})],
|
||||
])('reads %s as null', (_label, json) => {
|
||||
expect(parseReportProfileSnapshot(json)).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses to serialize an invalid snapshot', () => {
|
||||
expect(() =>
|
||||
serializeReportProfileSnapshot({...fullSnapshot, captured_at: 1} as unknown as ReportProfileSnapshot),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it('lists every stored asset', () => {
|
||||
expect(listReportProfileSnapshotAssets(fullSnapshot)).toEqual([
|
||||
{hash: 'a_user', key: 'reports/1/profile/avatar/a_user'},
|
||||
{hash: 'user-banner', key: null},
|
||||
{hash: 'member-avatar', key: 'reports/1/profile/avatar/member-avatar'},
|
||||
{hash: 'icon', key: 'reports/1/profile/icon/icon'},
|
||||
{hash: 'splash', key: 'reports/1/profile/splash/splash'},
|
||||
]);
|
||||
expect(listReportProfileSnapshotAssets(null)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {z} from 'zod';
|
||||
|
||||
export const ReportProfileAssetSnapshot = z.object({
|
||||
hash: z.string(),
|
||||
key: z.string().nullable(),
|
||||
});
|
||||
|
||||
export type ReportProfileAssetSnapshot = z.infer<typeof ReportProfileAssetSnapshot>;
|
||||
|
||||
export const ReportProfileUserSnapshot = z.object({
|
||||
id: z.string(),
|
||||
username: z.string().nullable(),
|
||||
discriminator: z.number().int().nullable(),
|
||||
global_name: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
pronouns: z.string().nullable(),
|
||||
avatar: ReportProfileAssetSnapshot.nullable(),
|
||||
banner: ReportProfileAssetSnapshot.nullable(),
|
||||
});
|
||||
|
||||
export type ReportProfileUserSnapshot = z.infer<typeof ReportProfileUserSnapshot>;
|
||||
|
||||
export const ReportProfileMemberSnapshot = z.object({
|
||||
guild_id: z.string(),
|
||||
nick: z.string().nullable(),
|
||||
bio: z.string().nullable(),
|
||||
pronouns: z.string().nullable(),
|
||||
joined_at: z.string().nullable(),
|
||||
avatar: ReportProfileAssetSnapshot.nullable(),
|
||||
banner: ReportProfileAssetSnapshot.nullable(),
|
||||
});
|
||||
|
||||
export type ReportProfileMemberSnapshot = z.infer<typeof ReportProfileMemberSnapshot>;
|
||||
|
||||
export const ReportProfileGuildSnapshot = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
vanity_url_code: z.string().nullable(),
|
||||
icon: ReportProfileAssetSnapshot.nullable(),
|
||||
banner: ReportProfileAssetSnapshot.nullable(),
|
||||
splash: ReportProfileAssetSnapshot.nullable(),
|
||||
});
|
||||
|
||||
export type ReportProfileGuildSnapshot = z.infer<typeof ReportProfileGuildSnapshot>;
|
||||
|
||||
export const ReportProfileSnapshot = z.object({
|
||||
captured_at: z.string(),
|
||||
user: ReportProfileUserSnapshot.nullable(),
|
||||
member: ReportProfileMemberSnapshot.nullable(),
|
||||
guild: ReportProfileGuildSnapshot.nullable(),
|
||||
});
|
||||
|
||||
export type ReportProfileSnapshot = z.infer<typeof ReportProfileSnapshot>;
|
||||
|
||||
export function parseReportProfileSnapshot(json: string | null | undefined): ReportProfileSnapshot | null {
|
||||
if (!json) {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(json);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const result = ReportProfileSnapshot.safeParse(parsed);
|
||||
return result.success ? result.data : null;
|
||||
}
|
||||
|
||||
export function serializeReportProfileSnapshot(snapshot: ReportProfileSnapshot): string {
|
||||
return JSON.stringify(ReportProfileSnapshot.parse(snapshot));
|
||||
}
|
||||
|
||||
export function listReportProfileSnapshotAssets(
|
||||
snapshot: ReportProfileSnapshot | null,
|
||||
): Array<ReportProfileAssetSnapshot> {
|
||||
if (!snapshot) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
snapshot.user?.avatar,
|
||||
snapshot.user?.banner,
|
||||
snapshot.member?.avatar,
|
||||
snapshot.member?.banner,
|
||||
snapshot.guild?.icon,
|
||||
snapshot.guild?.banner,
|
||||
snapshot.guild?.splash,
|
||||
].filter((asset): asset is ReportProfileAssetSnapshot => asset != null);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {DsaReportFlowRequest, DsaReportRequest} from '@fluxer/schema/src/domains/report/ReportSchemas';
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
const legacyBodies: Array<Record<string, unknown>> = [
|
||||
{
|
||||
ticket: 'ticket-message',
|
||||
report_type: 'message',
|
||||
category: 'harassment',
|
||||
message_link: 'https://web.fluxer.app/channels/1/2/3',
|
||||
reported_user_tag: 'target#0001',
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'DE',
|
||||
reporter_fluxer_tag: 'reporter#1234',
|
||||
additional_info: 'DSA report for harassment',
|
||||
},
|
||||
{
|
||||
ticket: 'ticket-user-id',
|
||||
report_type: 'user',
|
||||
category: 'inappropriate_profile',
|
||||
user_id: '1427410010101010101',
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'SE',
|
||||
},
|
||||
{
|
||||
ticket: 'ticket-user-tag',
|
||||
report_type: 'user',
|
||||
category: 'underage_user',
|
||||
user_tag: 'target#0001',
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
reporter_country_of_residence: 'FR',
|
||||
},
|
||||
{
|
||||
ticket: 'ticket-guild',
|
||||
report_type: 'guild',
|
||||
category: 'illegal_activity',
|
||||
guild_id: '1427410010101010102',
|
||||
invite_code: 'abcdef',
|
||||
reporter_full_legal_name: 'Jane Doe',
|
||||
reporter_country_of_residence: 'FR',
|
||||
},
|
||||
];
|
||||
|
||||
const flowFields = {
|
||||
revision_hash: '6b1f0c9e2a7d4413',
|
||||
good_faith_confirmed: true,
|
||||
locale: 'de',
|
||||
additional_info: 'This message breaks section 130 of the German criminal code.',
|
||||
reporter_country_of_residence: 'DE',
|
||||
};
|
||||
|
||||
const flowBodies: Array<Record<string, unknown>> = [
|
||||
{
|
||||
...flowFields,
|
||||
ticket: 'ticket-message',
|
||||
report_type: 'message',
|
||||
message_link: 'https://web.fluxer.app/channels/1/2/3',
|
||||
reporter_full_legal_name: 'John Doe',
|
||||
steps: [
|
||||
{screen_id: 'root_message', option_id: 'abuse'},
|
||||
{screen_id: 'abuse', option_id: 'hate'},
|
||||
{screen_id: 'hate', option_id: 'hate_incitement'},
|
||||
],
|
||||
},
|
||||
{
|
||||
...flowFields,
|
||||
ticket: 'ticket-user',
|
||||
report_type: 'user',
|
||||
user_id: '1427410010101010101',
|
||||
steps: [
|
||||
{screen_id: 'profile_parts', item_ids: ['photo']},
|
||||
{screen_id: 'root_user', option_id: 'sexual'},
|
||||
{screen_id: 'profile_sexual', option_id: 'minor_sexual'},
|
||||
{screen_id: 'profile_minor_sexual', option_id: 'csam'},
|
||||
],
|
||||
},
|
||||
{
|
||||
...flowFields,
|
||||
ticket: 'ticket-guild',
|
||||
report_type: 'guild',
|
||||
guild_id: '1427410010101010102',
|
||||
reporter_full_legal_name: 'Jane Doe',
|
||||
steps: [
|
||||
{screen_id: 'community_parts', item_ids: ['messages']},
|
||||
{screen_id: 'root_guild', option_id: 'raid'},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function issuePaths(result: {success: boolean; error?: {issues: Array<{path: Array<PropertyKey>}>}}): Array<string> {
|
||||
return (result.error?.issues ?? []).map((issue) => issue.path.map(String).join('.'));
|
||||
}
|
||||
|
||||
describe('DsaReportRequest', () => {
|
||||
it('accepts every legacy body shape', () => {
|
||||
for (const body of legacyBodies) {
|
||||
expect(DsaReportRequest.safeParse(body).success, String(body.ticket)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts every flow body shape', () => {
|
||||
for (const body of flowBodies) {
|
||||
expect(DsaReportRequest.safeParse(body).success, String(body.ticket)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a legacy body without a category', () => {
|
||||
const {category: _category, ...body} = legacyBodies[0];
|
||||
const result = DsaReportRequest.safeParse(body);
|
||||
expect(result.success).toBe(false);
|
||||
expect(issuePaths(result)).toContain('category');
|
||||
});
|
||||
|
||||
it('rejects a legacy body without a legal name', () => {
|
||||
const {reporter_full_legal_name: _name, ...body} = legacyBodies[3];
|
||||
const result = DsaReportRequest.safeParse(body);
|
||||
expect(result.success).toBe(false);
|
||||
expect(issuePaths(result)).toContain('reporter_full_legal_name');
|
||||
});
|
||||
|
||||
it('keeps the user_id or user_tag rule on the user branch', () => {
|
||||
const {user_id: _userId, ...body} = legacyBodies[1];
|
||||
expect(DsaReportRequest.safeParse(body).success).toBe(false);
|
||||
const {user_id: _flowUserId, ...flowBody} = flowBodies[1];
|
||||
expect(DsaReportRequest.safeParse(flowBody).success).toBe(false);
|
||||
});
|
||||
|
||||
it('requires the revision hash, good-faith statement and explanation with steps', () => {
|
||||
for (const field of ['revision_hash', 'good_faith_confirmed', 'additional_info'] as const) {
|
||||
const {[field]: _removed, ...body} = flowBodies[0];
|
||||
const result = DsaReportRequest.safeParse(body);
|
||||
expect(result.success, field).toBe(false);
|
||||
expect(issuePaths(result)).toContain(field);
|
||||
}
|
||||
expect(DsaReportRequest.safeParse({...flowBodies[0], good_faith_confirmed: false}).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a body that still sends reporter_fluxer_tag and drops the field', () => {
|
||||
const parsed = DsaReportRequest.parse(legacyBodies[0]);
|
||||
expect(legacyBodies[0]).toHaveProperty('reporter_fluxer_tag');
|
||||
expect(parsed).not.toHaveProperty('reporter_fluxer_tag');
|
||||
const flowParsed = DsaReportRequest.parse({...flowBodies[0], reporter_fluxer_tag: 'reporter#1234'});
|
||||
expect(flowParsed).not.toHaveProperty('reporter_fluxer_tag');
|
||||
});
|
||||
|
||||
it('accepts a flow body without a legal name or with a category alongside', () => {
|
||||
const {reporter_full_legal_name: _name, ...body} = flowBodies[0];
|
||||
expect(DsaReportRequest.safeParse(body).success).toBe(true);
|
||||
const withCategory = DsaReportRequest.safeParse({...flowBodies[0], category: 'spam'});
|
||||
expect(withCategory.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DsaReportFlowRequest', () => {
|
||||
it('accepts every flow body, and each one also passes DsaReportRequest', () => {
|
||||
for (const body of flowBodies) {
|
||||
expect(DsaReportFlowRequest.safeParse(body).success, String(body.ticket)).toBe(true);
|
||||
expect(DsaReportRequest.safeParse(body).success, String(body.ticket)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects legacy bodies', () => {
|
||||
for (const body of legacyBodies) {
|
||||
expect(DsaReportFlowRequest.safeParse(body).success, String(body.ticket)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('drops reporter_fluxer_tag from flow bodies', () => {
|
||||
const parsed = DsaReportFlowRequest.parse({...flowBodies[1], reporter_fluxer_tag: 'reporter#1234'});
|
||||
expect(parsed).not.toHaveProperty('reporter_fluxer_tag');
|
||||
});
|
||||
|
||||
it('has no category field', () => {
|
||||
const parsed = DsaReportFlowRequest.parse({...flowBodies[2], category: 'spam'});
|
||||
expect('category' in parsed).toBe(false);
|
||||
});
|
||||
|
||||
it('requires steps, revision hash, good-faith statement and a non-empty explanation', () => {
|
||||
for (const field of ['steps', 'revision_hash', 'good_faith_confirmed', 'additional_info'] as const) {
|
||||
const {[field]: _removed, ...body} = flowBodies[2];
|
||||
expect(DsaReportFlowRequest.safeParse(body).success, field).toBe(false);
|
||||
}
|
||||
expect(DsaReportFlowRequest.safeParse({...flowBodies[2], additional_info: ''}).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {ReportFlowAnswerFields} from '@fluxer/schema/src/domains/report/ReportFlowSchemas';
|
||||
import {
|
||||
createNamedStringLiteralUnion,
|
||||
createStringType,
|
||||
SnowflakeStringType,
|
||||
SnowflakeType,
|
||||
withFieldDescription,
|
||||
withOpenApiType,
|
||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {EmailType} from '@fluxer/schema/src/primitives/UserValidators';
|
||||
@@ -64,7 +66,7 @@ const GuildReportCategoryEnum = withOpenApiType(
|
||||
);
|
||||
export const ReportResponse = z.object({
|
||||
report_id: SnowflakeStringType.describe('The unique identifier for this report'),
|
||||
status: z.string().describe('Current status of the report (pending, reviewed, resolved)'),
|
||||
status: z.string().describe('Current status of the report (pending, resolved)'),
|
||||
reported_at: z.string().describe('ISO 8601 timestamp when the report was submitted'),
|
||||
});
|
||||
|
||||
@@ -89,37 +91,40 @@ const FLUXER_TAG_TYPE = z
|
||||
.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');
|
||||
const EU_COUNTRY_CODE_ENUM = createNamedStringLiteralUnion(
|
||||
[
|
||||
['AT', 'AT', 'Austria'],
|
||||
['BE', 'BE', 'Belgium'],
|
||||
['BG', 'BG', 'Bulgaria'],
|
||||
['HR', 'HR', 'Croatia'],
|
||||
['CY', 'CY', 'Cyprus'],
|
||||
['CZ', 'CZ', 'Czechia'],
|
||||
['DK', 'DK', 'Denmark'],
|
||||
['EE', 'EE', 'Estonia'],
|
||||
['FI', 'FI', 'Finland'],
|
||||
['FR', 'FR', 'France'],
|
||||
['DE', 'DE', 'Germany'],
|
||||
['GR', 'GR', 'Greece'],
|
||||
['HU', 'HU', 'Hungary'],
|
||||
['IE', 'IE', 'Ireland'],
|
||||
['IT', 'IT', 'Italy'],
|
||||
['LV', 'LV', 'Latvia'],
|
||||
['LT', 'LT', 'Lithuania'],
|
||||
['LU', 'LU', 'Luxembourg'],
|
||||
['MT', 'MT', 'Malta'],
|
||||
['NL', 'NL', 'Netherlands'],
|
||||
['PL', 'PL', 'Poland'],
|
||||
['PT', 'PT', 'Portugal'],
|
||||
['RO', 'RO', 'Romania'],
|
||||
['SK', 'SK', 'Slovakia'],
|
||||
['SI', 'SI', 'Slovenia'],
|
||||
['ES', 'ES', 'Spain'],
|
||||
['SE', 'SE', 'Sweden'],
|
||||
] as const,
|
||||
'EU country code of residence',
|
||||
const EuCountryCodeEnum = withOpenApiType(
|
||||
createNamedStringLiteralUnion(
|
||||
[
|
||||
['AT', 'AT', 'Austria'],
|
||||
['BE', 'BE', 'Belgium'],
|
||||
['BG', 'BG', 'Bulgaria'],
|
||||
['HR', 'HR', 'Croatia'],
|
||||
['CY', 'CY', 'Cyprus'],
|
||||
['CZ', 'CZ', 'Czechia'],
|
||||
['DK', 'DK', 'Denmark'],
|
||||
['EE', 'EE', 'Estonia'],
|
||||
['FI', 'FI', 'Finland'],
|
||||
['FR', 'FR', 'France'],
|
||||
['DE', 'DE', 'Germany'],
|
||||
['GR', 'GR', 'Greece'],
|
||||
['HU', 'HU', 'Hungary'],
|
||||
['IE', 'IE', 'Ireland'],
|
||||
['IT', 'IT', 'Italy'],
|
||||
['LV', 'LV', 'Latvia'],
|
||||
['LT', 'LT', 'Lithuania'],
|
||||
['LU', 'LU', 'Luxembourg'],
|
||||
['MT', 'MT', 'Malta'],
|
||||
['NL', 'NL', 'Netherlands'],
|
||||
['PL', 'PL', 'Poland'],
|
||||
['PT', 'PT', 'Portugal'],
|
||||
['RO', 'RO', 'Romania'],
|
||||
['SK', 'SK', 'Slovakia'],
|
||||
['SI', 'SI', 'Slovenia'],
|
||||
['ES', 'ES', 'Spain'],
|
||||
['SE', 'SE', 'Sweden'],
|
||||
] as const,
|
||||
'EU country code of residence',
|
||||
),
|
||||
'EuCountryCode',
|
||||
);
|
||||
export const ReportMessageRequest = z.object({
|
||||
channel_id: SnowflakeType.describe('ID of the accessible channel containing the reported message'),
|
||||
@@ -164,26 +169,48 @@ export const DsaReportEmailVerifyRequest = z.object({
|
||||
|
||||
export type DsaReportEmailVerifyRequest = z.infer<typeof DsaReportEmailVerifyRequest>;
|
||||
|
||||
const DsaReportBase = z.object({
|
||||
ticket: createStringType(1, 128).describe('Verification ticket obtained from email verification'),
|
||||
additional_info: z.optional(createStringType(0, 1000)).describe('Additional context or details about the report'),
|
||||
reporter_full_legal_name: createStringType(1, 160).describe('Full legal name of the person filing the report'),
|
||||
reporter_country_of_residence: EU_COUNTRY_CODE_ENUM.describe('EU country code of the reporter residence'),
|
||||
reporter_fluxer_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the reporter if they have an account'),
|
||||
});
|
||||
const DsaReportMessageRequest = DsaReportBase.extend({
|
||||
report_type: z.literal('message').describe('Type of report'),
|
||||
category: MessageReportCategoryEnum,
|
||||
message_link: createStringType(1, 2048).describe('Link to the message being reported'),
|
||||
reported_user_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the user who sent the message'),
|
||||
});
|
||||
interface DsaReportRefinementInput {
|
||||
category?: string | undefined;
|
||||
reporter_full_legal_name?: string | undefined;
|
||||
additional_info?: string | undefined;
|
||||
revision_hash?: string | undefined;
|
||||
steps?: Array<unknown> | undefined;
|
||||
good_faith_confirmed?: true | undefined;
|
||||
}
|
||||
|
||||
const DsaReportUserRequest = DsaReportBase.extend({
|
||||
report_type: z.literal('user').describe('Type of report'),
|
||||
category: UserReportCategoryEnum,
|
||||
user_id: SnowflakeType.optional().describe('ID of the user being reported'),
|
||||
user_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the user being reported'),
|
||||
}).superRefine((value, ctx) => {
|
||||
function requireDsaReportField(ctx: z.RefinementCtx, field: string, message: string): void {
|
||||
ctx.addIssue({code: 'custom', message, path: [field]});
|
||||
}
|
||||
|
||||
function refineDsaReport(value: DsaReportRefinementInput, ctx: z.RefinementCtx): void {
|
||||
if (value.steps === undefined) {
|
||||
if (value.category === undefined) {
|
||||
requireDsaReportField(ctx, 'category', 'category is required when steps are not provided');
|
||||
}
|
||||
if (value.reporter_full_legal_name === undefined) {
|
||||
requireDsaReportField(
|
||||
ctx,
|
||||
'reporter_full_legal_name',
|
||||
'reporter_full_legal_name is required when steps are not provided',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (value.revision_hash === undefined) {
|
||||
requireDsaReportField(ctx, 'revision_hash', 'revision_hash is required with steps');
|
||||
}
|
||||
if (value.good_faith_confirmed !== true) {
|
||||
requireDsaReportField(ctx, 'good_faith_confirmed', 'good_faith_confirmed is required with steps');
|
||||
}
|
||||
if (!value.additional_info) {
|
||||
requireDsaReportField(ctx, 'additional_info', 'additional_info is required with steps');
|
||||
}
|
||||
}
|
||||
|
||||
function refineDsaUserTarget(
|
||||
value: {user_id?: bigint | undefined; user_tag?: string | undefined},
|
||||
ctx: z.RefinementCtx,
|
||||
): void {
|
||||
if (!value.user_id && !value.user_tag) {
|
||||
ctx.addIssue({
|
||||
code: 'custom',
|
||||
@@ -191,14 +218,77 @@ const DsaReportUserRequest = DsaReportBase.extend({
|
||||
path: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const DsaReportCommonFields = {
|
||||
ticket: createStringType(1, 128).describe('Verification ticket obtained from email verification'),
|
||||
reporter_country_of_residence: withFieldDescription(EuCountryCodeEnum, 'EU country code of the reporter residence'),
|
||||
};
|
||||
const DSA_REPORT_LEGAL_NAME_TYPE = z
|
||||
.optional(createStringType(1, 160))
|
||||
.describe('Full legal name of the person filing the report, required unless the report is about child sexual abuse');
|
||||
const DsaReportFlowFields = {
|
||||
revision_hash: ReportFlowAnswerFields.revision_hash.describe(
|
||||
'revision_hash of the DSA report flow the reporter answered',
|
||||
),
|
||||
steps: ReportFlowAnswerFields.steps.describe(
|
||||
'Screens of the DSA report flow and the answers chosen on each, in order',
|
||||
),
|
||||
good_faith_confirmed: z
|
||||
.literal(true)
|
||||
.describe('Confirms in good faith that the information and allegations in the notice are accurate and complete'),
|
||||
locale: ReportFlowAnswerFields.locale,
|
||||
};
|
||||
const DsaReportBase = z.object({
|
||||
...DsaReportCommonFields,
|
||||
additional_info: z.optional(createStringType(0, 1000)).describe('Additional context or details about the report'),
|
||||
reporter_full_legal_name: DSA_REPORT_LEGAL_NAME_TYPE,
|
||||
revision_hash: DsaReportFlowFields.revision_hash.optional(),
|
||||
steps: DsaReportFlowFields.steps.optional(),
|
||||
good_faith_confirmed: DsaReportFlowFields.good_faith_confirmed.optional(),
|
||||
locale: DsaReportFlowFields.locale,
|
||||
});
|
||||
const DsaReportFlowBase = z.object({
|
||||
...DsaReportCommonFields,
|
||||
...DsaReportFlowFields,
|
||||
additional_info: createStringType(1, 1000).describe(
|
||||
'Explanation of the problem. If the reporter believes the content is illegal, which law it breaks and why',
|
||||
),
|
||||
reporter_full_legal_name: DSA_REPORT_LEGAL_NAME_TYPE,
|
||||
});
|
||||
const DsaReportMessageTarget = {
|
||||
report_type: z.literal('message').describe('Type of report'),
|
||||
message_link: createStringType(1, 2048).describe('Link to the message being reported'),
|
||||
reported_user_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the user who sent the message'),
|
||||
};
|
||||
const DsaReportUserTarget = {
|
||||
report_type: z.literal('user').describe('Type of report'),
|
||||
user_id: SnowflakeType.optional().describe('ID of the user being reported'),
|
||||
user_tag: z.optional(FLUXER_TAG_TYPE).describe('Fluxer tag of the user being reported'),
|
||||
};
|
||||
const DsaReportGuildTarget = {
|
||||
report_type: z.literal('guild').describe('Type of report'),
|
||||
guild_id: SnowflakeType.describe('ID of the guild being reported'),
|
||||
invite_code: z.optional(createStringType(1, 64)).describe('Invite code used to access the guild'),
|
||||
};
|
||||
|
||||
const DsaReportMessageRequest = DsaReportBase.extend({
|
||||
...DsaReportMessageTarget,
|
||||
category: MessageReportCategoryEnum.optional(),
|
||||
}).superRefine(refineDsaReport);
|
||||
|
||||
const DsaReportUserRequest = DsaReportBase.extend({
|
||||
...DsaReportUserTarget,
|
||||
category: UserReportCategoryEnum.optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
refineDsaReport(value, ctx);
|
||||
refineDsaUserTarget(value, ctx);
|
||||
});
|
||||
|
||||
const DsaReportGuildRequest = DsaReportBase.extend({
|
||||
report_type: z.literal('guild').describe('Type of report'),
|
||||
category: GuildReportCategoryEnum,
|
||||
guild_id: SnowflakeType.describe('ID of the guild being reported'),
|
||||
invite_code: z.optional(createStringType(1, 64)).describe('Invite code used to access the guild'),
|
||||
});
|
||||
...DsaReportGuildTarget,
|
||||
category: GuildReportCategoryEnum.optional(),
|
||||
}).superRefine(refineDsaReport);
|
||||
|
||||
export const DsaReportRequest = z.discriminatedUnion('report_type', [
|
||||
DsaReportMessageRequest,
|
||||
@@ -207,3 +297,11 @@ export const DsaReportRequest = z.discriminatedUnion('report_type', [
|
||||
]);
|
||||
|
||||
export type DsaReportRequest = z.infer<typeof DsaReportRequest>;
|
||||
|
||||
export const DsaReportFlowRequest = z.discriminatedUnion('report_type', [
|
||||
DsaReportFlowBase.extend(DsaReportMessageTarget),
|
||||
DsaReportFlowBase.extend(DsaReportUserTarget).superRefine(refineDsaUserTarget),
|
||||
DsaReportFlowBase.extend(DsaReportGuildTarget),
|
||||
]);
|
||||
|
||||
export type DsaReportFlowRequest = z.infer<typeof DsaReportFlowRequest>;
|
||||
@@ -258,11 +258,14 @@ function createNamedUnion<T extends string | number>(pairs: NamedLiteralPairs<T>
|
||||
});
|
||||
}
|
||||
|
||||
export function createNamedLiteralUnion<T extends number>(pairs: NamedLiteralPairs<T>, description?: string) {
|
||||
export function createNamedLiteralUnion<const T extends number>(pairs: NamedLiteralPairs<T>, description?: string) {
|
||||
return createNamedUnion(pairs, description);
|
||||
}
|
||||
|
||||
export function createNamedStringLiteralUnion<T extends string>(pairs: NamedLiteralPairs<T>, description?: string) {
|
||||
export function createNamedStringLiteralUnion<const T extends string>(
|
||||
pairs: NamedLiteralPairs<T>,
|
||||
description?: string,
|
||||
) {
|
||||
return createNamedUnion(pairs, description);
|
||||
}
|
||||
|
||||
@@ -286,7 +289,7 @@ export function createFlexibleStringLiteralUnion<T extends string>(pairs: NamedL
|
||||
);
|
||||
}
|
||||
|
||||
export function createInt32EnumType<T extends number>(
|
||||
export function createInt32EnumType<const T extends number>(
|
||||
pairs: NamedLiteralPairs<T>,
|
||||
description?: string,
|
||||
typeName?: string,
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {
|
||||
type ReportAdminResponseSchema,
|
||||
type ResolveReportResponse,
|
||||
SearchReportsRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
ReportFlowQuery,
|
||||
ReportFlowSurface,
|
||||
ReportFlowTargetType,
|
||||
} from '@fluxer/schema/src/domains/report/ReportFlowSchemas';
|
||||
import {
|
||||
createInt32EnumType,
|
||||
createNamedLiteralUnion,
|
||||
createNamedStringLiteralUnion,
|
||||
withFieldDescription,
|
||||
withOpenApiType,
|
||||
} from '@fluxer/schema/src/primitives/SchemaPrimitives';
|
||||
import {schemaMetadata} from '@fluxer/schema/src/SchemaMetadata';
|
||||
import {describe, expect, expectTypeOf, it} from 'vitest';
|
||||
import {z} from 'zod';
|
||||
|
||||
describe('named literal union inference', () => {
|
||||
it('infers string literals from a bare union', () => {
|
||||
const schema = createNamedStringLiteralUnion([
|
||||
['asc', 'asc', 'Ascending'],
|
||||
['desc', 'desc', 'Descending'],
|
||||
]);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<'asc' | 'desc'>();
|
||||
expect(schema.options.map((option) => option.value)).toEqual(['asc', 'desc']);
|
||||
});
|
||||
it('infers string literals through withOpenApiType', () => {
|
||||
const schema = withOpenApiType(
|
||||
createNamedStringLiteralUnion([
|
||||
['asc', 'asc', 'Ascending'],
|
||||
['desc', 'desc', 'Descending'],
|
||||
]),
|
||||
'TypeTestSortOrder',
|
||||
);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<'asc' | 'desc'>();
|
||||
expect(schemaMetadata.get(schema)?.name).toBe('TypeTestSortOrder');
|
||||
});
|
||||
it('infers string literals through withFieldDescription', () => {
|
||||
const schema = withFieldDescription(
|
||||
createNamedStringLiteralUnion([
|
||||
['asc', 'asc', 'Ascending'],
|
||||
['desc', 'desc', 'Descending'],
|
||||
]),
|
||||
'Sort order',
|
||||
);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<'asc' | 'desc'>();
|
||||
expect(schema.description).toBe('Sort order');
|
||||
});
|
||||
it('infers string literals through both wrappers and object modifiers', () => {
|
||||
const schema = z.object({
|
||||
order: withFieldDescription(
|
||||
withOpenApiType(
|
||||
createNamedStringLiteralUnion([
|
||||
['asc', 'asc', 'Ascending'],
|
||||
['desc', 'desc', 'Descending'],
|
||||
]),
|
||||
'TypeTestNestedSortOrder',
|
||||
),
|
||||
'Sort order',
|
||||
).optional(),
|
||||
fallback: withOpenApiType(
|
||||
createNamedStringLiteralUnion([
|
||||
['asc', 'asc'],
|
||||
['desc', 'desc'],
|
||||
]),
|
||||
'TypeTestFallbackSortOrder',
|
||||
).default('asc'),
|
||||
});
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<{order?: 'asc' | 'desc'; fallback: 'asc' | 'desc'}>();
|
||||
expect(schema.parse({})).toEqual({fallback: 'asc'});
|
||||
});
|
||||
it('infers number literals from a bare union', () => {
|
||||
const schema = createNamedLiteralUnion([
|
||||
[0, 'ROLE', 'A role'],
|
||||
[1, 'MEMBER', 'A member'],
|
||||
]);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<0 | 1>();
|
||||
expect(schema.options.map((option) => option.value)).toEqual([0, 1]);
|
||||
});
|
||||
it('infers number literals through withOpenApiType and withFieldDescription', () => {
|
||||
const named = withOpenApiType(
|
||||
createNamedLiteralUnion([
|
||||
[0, 'ROLE', 'A role'],
|
||||
[1, 'MEMBER', 'A member'],
|
||||
]),
|
||||
'TypeTestOverwriteType',
|
||||
);
|
||||
const described = withFieldDescription(
|
||||
createNamedLiteralUnion([
|
||||
[0, 'ROLE', 'A role'],
|
||||
[1, 'MEMBER', 'A member'],
|
||||
]),
|
||||
'Overwrite type',
|
||||
);
|
||||
expectTypeOf<z.infer<typeof named>>().toEqualTypeOf<0 | 1>();
|
||||
expectTypeOf<z.infer<typeof described>>().toEqualTypeOf<0 | 1>();
|
||||
expect(named.safeParse(2).success).toBe(false);
|
||||
expect(described.safeParse(1).success).toBe(true);
|
||||
});
|
||||
it('infers the widened value type from pairs that are not literals', () => {
|
||||
const pairs: ReadonlyArray<readonly [string, string]> = [
|
||||
['asc', 'asc'],
|
||||
['desc', 'desc'],
|
||||
];
|
||||
const schema = createNamedStringLiteralUnion(pairs);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<string>();
|
||||
expect(schema.safeParse('asc').success).toBe(true);
|
||||
});
|
||||
it('infers the report flow target and surface unions', () => {
|
||||
expectTypeOf<ReportFlowTargetType>().toEqualTypeOf<'message' | 'user' | 'guild'>();
|
||||
expectTypeOf<ReportFlowSurface>().toEqualTypeOf<'in_app' | 'dsa'>();
|
||||
expectTypeOf<ReportFlowQuery['surface']>().toEqualTypeOf<'in_app' | 'dsa'>();
|
||||
expect(ReportFlowTargetType.options.map((option) => option.value)).toEqual(['message', 'user', 'guild']);
|
||||
expect(ReportFlowSurface.options.map((option) => option.value)).toEqual(['in_app', 'dsa']);
|
||||
expect(ReportFlowQuery.parse({}).surface).toBe('in_app');
|
||||
});
|
||||
});
|
||||
|
||||
describe('int32 enum inference', () => {
|
||||
it('infers number literals from a bare enum', () => {
|
||||
const schema = createInt32EnumType(
|
||||
[
|
||||
[0, 'PENDING', 'Pending'],
|
||||
[1, 'RESOLVED', 'Resolved'],
|
||||
],
|
||||
'Status',
|
||||
'TypeTestBareStatus',
|
||||
);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<0 | 1>();
|
||||
expect(schema.parse(1)).toBe(1);
|
||||
});
|
||||
it('infers number literals through withOpenApiType and withFieldDescription', () => {
|
||||
const named = withOpenApiType(
|
||||
createInt32EnumType(
|
||||
[
|
||||
[0, 'PENDING', 'Pending'],
|
||||
[1, 'RESOLVED', 'Resolved'],
|
||||
],
|
||||
'Status',
|
||||
'TypeTestWrappedStatus',
|
||||
),
|
||||
'TypeTestWrappedStatus',
|
||||
);
|
||||
const described = withFieldDescription(
|
||||
createInt32EnumType([
|
||||
[0, 'MESSAGE'],
|
||||
[1, 'USER'],
|
||||
[2, 'GUILD'],
|
||||
]),
|
||||
'Report type',
|
||||
);
|
||||
expectTypeOf<z.infer<typeof named>>().toEqualTypeOf<0 | 1>();
|
||||
expectTypeOf<z.infer<typeof described>>().toEqualTypeOf<0 | 1 | 2>();
|
||||
expect(named.safeParse(2).success).toBe(false);
|
||||
expect(described.safeParse(2).success).toBe(true);
|
||||
expect(schemaMetadata.get(named)).toEqual({
|
||||
name: 'TypeTestWrappedStatus',
|
||||
format: 'int32',
|
||||
enumEntries: [
|
||||
{value: 0, name: 'PENDING', description: 'Pending'},
|
||||
{value: 1, name: 'RESOLVED', description: 'Resolved'},
|
||||
],
|
||||
});
|
||||
});
|
||||
it('infers the widened value type from pairs that are not literals', () => {
|
||||
const pairs: ReadonlyArray<readonly [number, string]> = [
|
||||
[0, 'PENDING'],
|
||||
[1, 'RESOLVED'],
|
||||
];
|
||||
const schema = createInt32EnumType(pairs);
|
||||
expectTypeOf<z.infer<typeof schema>>().toEqualTypeOf<number>();
|
||||
expect(schema.safeParse(1).success).toBe(true);
|
||||
});
|
||||
it('infers the report status and type literals on the admin schemas', () => {
|
||||
expectTypeOf<z.infer<typeof ReportAdminResponseSchema>['status']>().toEqualTypeOf<0 | 1>();
|
||||
expectTypeOf<z.infer<typeof ReportAdminResponseSchema>['report_type']>().toEqualTypeOf<0 | 1 | 2>();
|
||||
expectTypeOf<z.infer<typeof ResolveReportResponse>['status']>().toEqualTypeOf<0 | 1>();
|
||||
expectTypeOf<SearchReportsRequest['status']>().toEqualTypeOf<0 | 1 | undefined>();
|
||||
expectTypeOf<SearchReportsRequest['report_type']>().toEqualTypeOf<0 | 1 | 2 | undefined>();
|
||||
expect(SearchReportsRequest.safeParse({status: 2}).success).toBe(false);
|
||||
expect(SearchReportsRequest.safeParse({report_type: 2}).success).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user