fix(app): gate blocked group rendering behind an experiment (#2763)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 16:08:19 +02:00
1 parent 4b278a0da8
commit ed10f9d323
22 files changed
+938 -30

No files matched your search

@@ -19,6 +19,10 @@ import {
VoiceNoiseSuppressionConfigResponse,
VoiceNoiseSuppressionConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
BlockedMessageGroupsConfigResponse,
BlockedMessageGroupsConfigUpdateRequest,
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {
ExperimentDeliveryConfigResponse,
ExperimentDeliveryConfigUpdateRequest,
@@ -640,6 +644,7 @@ export const InstanceConfigResponse = z.object({
experiment_delivery: ExperimentDeliveryConfigResponse,
message_hover_tracking: MessageHoverTrackingConfigResponse,
message_keyboard_focus: MessageKeyboardFocusConfigResponse,
blocked_message_groups: BlockedMessageGroupsConfigResponse,
registration: InstanceRegistrationResponse,
self_hosted: z.boolean(),
app_public: AppPublicConfigResponse,
@@ -678,6 +683,7 @@ export const InstanceConfigUpdateRequest = z.object({
experiment_delivery: ExperimentDeliveryConfigUpdateRequest.nullish(),
message_hover_tracking: MessageHoverTrackingConfigUpdateRequest.nullish(),
message_keyboard_focus: MessageKeyboardFocusConfigUpdateRequest.nullish(),
blocked_message_groups: BlockedMessageGroupsConfigUpdateRequest.nullish(),
registration: z
.object({
mode: InstanceRegistrationModeSchema.optional(),
@@ -0,0 +1,34 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createClientBehaviorExperiment} from '@fluxer/schema/src/domains/experiment/ClientBehaviorExperiment';
import type {z} from 'zod';
const blockedMessageGroups = createClientBehaviorExperiment({defaultRolloutSalt: 'blocked-message-groups-v1'});
export const BlockedMessageGroupsConfigSchema = blockedMessageGroups.ConfigSchema;
export type BlockedMessageGroupsConfig = z.infer<typeof BlockedMessageGroupsConfigSchema>;
export const DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG: BlockedMessageGroupsConfig = blockedMessageGroups.DEFAULT_CONFIG;
export const BlockedMessageGroupsConfigUpdateRequest = blockedMessageGroups.ConfigUpdateRequest;
export type BlockedMessageGroupsConfigUpdateRequest = z.infer<typeof BlockedMessageGroupsConfigUpdateRequest>;
export const BlockedMessageGroupsConfigResponse = blockedMessageGroups.ConfigSchema;
export type BlockedMessageGroupsConfigResponse = BlockedMessageGroupsConfig;
export const BlockedMessageGroupsAssignmentResponse = blockedMessageGroups.AssignmentResponse;
export type BlockedMessageGroupsAssignmentResponse = z.infer<typeof BlockedMessageGroupsAssignmentResponse>;
export const INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT: BlockedMessageGroupsAssignmentResponse =
blockedMessageGroups.INERT_ASSIGNMENT;
export function resolveBlockedMessageGroupsAssignment(
config: BlockedMessageGroupsConfig,
userId: string,
): BlockedMessageGroupsAssignmentResponse {
return blockedMessageGroups.resolveAssignment(config, userId);
}
@@ -4,6 +4,10 @@ import {
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
VoiceNoiseSuppressionAssignmentResponse,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {
BlockedMessageGroupsAssignmentResponse,
INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT,
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {
INERT_MESSAGE_HOVER_TRACKING_ASSIGNMENT,
MessageHoverTrackingAssignmentResponse,
@@ -52,6 +56,7 @@ const ExperimentAssignmentsSchema = z.object({
voice_noise_suppression: VoiceNoiseSuppressionAssignmentResponse.optional(),
message_hover_tracking: MessageHoverTrackingAssignmentResponse.optional(),
message_keyboard_focus: MessageKeyboardFocusAssignmentResponse.optional(),
blocked_message_groups: BlockedMessageGroupsAssignmentResponse.optional(),
});
export const ExperimentAssignmentsResponse = z.object({
@@ -85,3 +90,9 @@ export function readMessageKeyboardFocusAssignment(
): MessageKeyboardFocusAssignmentResponse {
return response.assignments.message_keyboard_focus ?? INERT_MESSAGE_KEYBOARD_FOCUS_ASSIGNMENT;
}
export function readBlockedMessageGroupsAssignment(
response: ExperimentAssignmentsResponse,
): BlockedMessageGroupsAssignmentResponse {
return response.assignments.blocked_message_groups ?? INERT_BLOCKED_MESSAGE_GROUPS_ASSIGNMENT;
}
@@ -0,0 +1,98 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {
BlockedMessageGroupsConfigSchema,
DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG,
resolveBlockedMessageGroupsAssignment,
} from '@fluxer/schema/src/domains/experiment/BlockedMessageGroupsSchemas';
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, it} from 'vitest';
const USER_ID = '1485064866382176262';
const OTHER_USER_ID = '1485064866382176263';
function config(overrides: Partial<ReturnType<typeof BlockedMessageGroupsConfigSchema.parse>> = {}) {
return BlockedMessageGroupsConfigSchema.parse({...overrides});
}
describe('blocked message groups configuration', () => {
it('defaults to a disabled rollout that targets nobody', () => {
expect(DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG).toEqual({
enabled: false,
config_version: 0,
rollout_basis_points: 0,
rollout_salt: 'blocked-message-groups-v1',
included_user_ids: [],
excluded_user_ids: [],
});
});
it('refuses a rollout wider than the bucket resolution', () => {
expect(() => config({rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION + 1})).toThrow();
});
it('refuses a target that is not a snowflake', () => {
expect(() => config({included_user_ids: ['not-a-snowflake']})).toThrow();
});
});
describe('blocked message groups resolution', () => {
it('reports the stored revision while the rollout is disabled', () => {
const assignment = resolveBlockedMessageGroupsAssignment(config({config_version: 3}), USER_ID);
expect(assignment).toEqual({enabled: false, config_version: 3, user_targeted: false, source: null});
});
it('targets an allowlisted account regardless of its bucket', () => {
const assignment = resolveBlockedMessageGroupsAssignment(
config({enabled: true, included_user_ids: [USER_ID]}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'user_rule'});
});
it('keeps a blocklisted account out of a full rollout', () => {
const assignment = resolveBlockedMessageGroupsAssignment(
config({
enabled: true,
rollout_basis_points: EXPERIMENT_BUCKET_RESOLUTION,
excluded_user_ids: [USER_ID],
}),
USER_ID,
);
expect(assignment).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
});
it('prefers the blocklist over the allowlist', () => {
const assignment = resolveBlockedMessageGroupsAssignment(
config({enabled: true, included_user_ids: [USER_ID], excluded_user_ids: [USER_ID]}),
USER_ID,
);
expect(assignment.user_targeted).toBe(false);
});
it('targets an account whose bucket falls inside the rollout', () => {
const salt = DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG.rollout_salt;
const bucket = experimentBucket(USER_ID, salt);
const inside = resolveBlockedMessageGroupsAssignment(
config({enabled: true, rollout_basis_points: bucket + 1}),
USER_ID,
);
const outside = resolveBlockedMessageGroupsAssignment(
config({enabled: true, rollout_basis_points: bucket}),
USER_ID,
);
expect(inside).toEqual({enabled: true, config_version: 0, user_targeted: true, source: 'canary'});
expect(outside).toEqual({enabled: true, config_version: 0, user_targeted: false, source: null});
});
it('separates two accounts under the same salt', () => {
const salt = DEFAULT_BLOCKED_MESSAGE_GROUPS_CONFIG.rollout_salt;
expect(experimentBucket(USER_ID, salt)).not.toBe(experimentBucket(OTHER_USER_ID, salt));
});
it('moves an account when the salt changes', () => {
expect(experimentBucket(USER_ID, 'blocked-message-groups-v1')).not.toBe(
experimentBucket(USER_ID, 'blocked-message-groups-v2'),
);
});
});