mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(threads): ship threads and the Plutonium page to everyone (#3277)
This commit is contained in:
1 parent
1515a8487f
commit
f8505d19f7
53 files changed
+296
-2008
No files matched your search
@@ -4,7 +4,6 @@ import {AdminAuditReadActions} from '@app/api/admin/AdminAuditActions';
|
||||
import {recordAdminRead, recordAdminWrite} from '@app/api/admin/AdminAuditRecorder';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import {enqueueRebuildThreadAutoArchiveQueue, enqueueThreadSearchBackfill} from '@app/api/channel/threads/ThreadJobs';
|
||||
import {
|
||||
type InstancePolicyConfig,
|
||||
REGISTRATION_PENDING_APPROVAL_TRAIT,
|
||||
@@ -15,7 +14,6 @@ import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {
|
||||
getChannelThreadsConfigPublisher,
|
||||
getGatewayRolloutConfigPublisher,
|
||||
getInstanceConfigRepository,
|
||||
getPushRelayConfigPublisher,
|
||||
@@ -23,11 +21,6 @@ import {
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp, HonoEnv} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
enabledThreadGuildIds,
|
||||
enqueueThreadPermissionSeeds,
|
||||
newlyEnabledThreadGuildIds,
|
||||
} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {InstancePolicyTransitionNotAllowedError} from '@fluxer/errors/src/domains/core/InstancePolicyTransitionNotAllowedError';
|
||||
@@ -42,13 +35,8 @@ import {
|
||||
PendingRegistrationActionRequest,
|
||||
RegistrationUrlIdParam,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
type ChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {GatewayRolloutConfigSchema} from '@fluxer/schema/src/domains/admin/GatewayRolloutSchemas';
|
||||
import {PlutoniumPageConfigSchema} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
import type {PushRelayConfig, PushRelayConfigUpdateRequest} from '@fluxer/schema/src/domains/admin/PushRelaySchemas';
|
||||
import {UserIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {ExperimentDeliveryConfigSchema} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
@@ -78,9 +66,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gatewayRollout,
|
||||
pushRelay,
|
||||
domainMigration,
|
||||
plutoniumPage,
|
||||
captcha,
|
||||
channelThreads,
|
||||
experimentDelivery,
|
||||
registrationConfig,
|
||||
registrationUrls,
|
||||
@@ -90,9 +76,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||
instanceConfigRepository.getPushRelayConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getPlutoniumPageConfig(),
|
||||
instanceConfigRepository.getCaptchaConfig(),
|
||||
instanceConfigRepository.getChannelThreadsConfig(),
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getRegistrationConfig(),
|
||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||
@@ -129,9 +113,7 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
||||
gateway_rollout: gatewayRollout,
|
||||
push_relay: pushRelay,
|
||||
domain_migration: domainMigration,
|
||||
plutonium_page: plutoniumPage,
|
||||
captcha,
|
||||
channel_threads: channelThreads,
|
||||
experiment_delivery: experimentDelivery,
|
||||
registration: {
|
||||
...registrationConfig,
|
||||
@@ -414,43 +396,12 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.plutonium_page) {
|
||||
const patch = omitUndefinedFields(data.plutonium_page);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updatePlutoniumPageConfig((current) =>
|
||||
PlutoniumPageConfigSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
if (data.captcha) {
|
||||
const patch = omitUndefinedFields(data.captcha);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await instanceConfigRepository.updateCaptchaConfig(patch);
|
||||
}
|
||||
}
|
||||
let channelThreadsConfigVersion: number | undefined;
|
||||
if (data.channel_threads) {
|
||||
const patch = omitUndefinedFields(data.channel_threads);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
let previous: ChannelThreadsConfig | undefined;
|
||||
const landed = await instanceConfigRepository.updateChannelThreadsConfig((current) => {
|
||||
previous = current;
|
||||
return applyChannelThreadsConfigUpdate(current, patch);
|
||||
});
|
||||
channelThreadsConfigVersion = landed.config_version;
|
||||
await enqueueThreadPermissionSeeds(ctx.get('channelRepository').threads, landed);
|
||||
const newlyEnabled = previous ? newlyEnabledThreadGuildIds(previous, landed) : enabledThreadGuildIds(landed);
|
||||
for (const guildId of newlyEnabled) {
|
||||
await enqueueRebuildThreadAutoArchiveQueue(guildId);
|
||||
await enqueueThreadSearchBackfill(guildId);
|
||||
}
|
||||
await getChannelThreadsConfigPublisher().publish(landed);
|
||||
}
|
||||
}
|
||||
if (data.experiment_delivery) {
|
||||
const patch = data.experiment_delivery;
|
||||
await instanceConfigRepository.updateExperimentDeliveryConfig((current) =>
|
||||
@@ -646,7 +597,6 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
||||
action: 'update_instance_config',
|
||||
metadata: {
|
||||
sections: listSuppliedSections(data),
|
||||
channel_threads_config_version: channelThreadsConfigVersion?.toString(),
|
||||
granted_acls: grantedSetupCompleterAdmin ? AdminACLs.WILDCARD : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -8,13 +8,11 @@ import {
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
|
||||
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS, TEST_IDS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {CreateRegistrationUrlResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {vi} from 'vitest';
|
||||
|
||||
async function createRegistrationUrl(harness: ApiTestHarness, admin: TestAccount): Promise<string> {
|
||||
const created = await createBuilder<CreateRegistrationUrlResponse>(harness, admin.token)
|
||||
@@ -82,26 +80,6 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/instance/config',
|
||||
name: 'channel threads',
|
||||
async prepare() {
|
||||
vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
return {
|
||||
request: {
|
||||
path: '/admin/instance/config',
|
||||
body: {channel_threads: {enabled: true, enabled_guild_ids: ['1']}},
|
||||
},
|
||||
expected: {
|
||||
action: 'update_instance_config',
|
||||
targetType: 'instance_config',
|
||||
targetId: '0',
|
||||
metadata: {sections: 'channel_threads', channel_threads_config_version: '1'},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/instance/config/branding-assets',
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
|
||||
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
import {beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const GUILD_ID = createGuildID(100n);
|
||||
const CHANNEL_ID = createChannelID(300n);
|
||||
@@ -54,7 +54,7 @@ function enroll(): void {
|
||||
const capableViewer: ThreadViewer = {kind: 'user', userId: USER_ID, bot: false, capable: true};
|
||||
|
||||
describe('getChannelAuthenticated thread gate', () => {
|
||||
afterEach(() => {
|
||||
beforeEach(() => {
|
||||
syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({}));
|
||||
});
|
||||
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
getCompiledChannelThreadsConfig,
|
||||
pinChannelThreadsConfigForTesting,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import type {ApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder, type TestRequestBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
|
||||
export const THREADS_FEATURE_HEADER = 'X-Fluxer-Features';
|
||||
@@ -22,7 +23,8 @@ export const ALL_THREADS_ACTIVE: Partial<ChannelThreadsConfig> = {
|
||||
};
|
||||
|
||||
export async function setChannelThreadsConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
const current = getCompiledChannelThreadsConfig().config;
|
||||
pinChannelThreadsConfigForTesting(
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
...patch,
|
||||
ever_enabled: current.ever_enabled || patch.enabled === true,
|
||||
@@ -33,7 +35,7 @@ export async function setChannelThreadsConfig(patch: Partial<ChannelThreadsConfi
|
||||
}
|
||||
|
||||
export function resetChannelThreadsConfig(): void {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
pinChannelThreadsConfigForTesting(DEFAULT_CHANNEL_THREADS_CONFIG);
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,14 +28,6 @@ export async function enqueueRemoveThreadMemberships(guildId: GuildID, userId: U
|
||||
);
|
||||
}
|
||||
|
||||
export async function enqueueRebuildThreadAutoArchiveQueue(guildId: string): Promise<void> {
|
||||
await (await workerService()).addJob(
|
||||
'rebuildThreadAutoArchiveQueue',
|
||||
{guildId, configVersion: getCompiledChannelThreadsConfig().config.config_version},
|
||||
{jobKey: `rebuild-thread-archive-queue-${guildId}`},
|
||||
);
|
||||
}
|
||||
|
||||
export function enqueueRepairThreadIndexes(threadIds: Array<ChannelID>): void {
|
||||
if (threadIds.length === 0) return;
|
||||
const window = Math.floor(Date.now() / REPAIR_DEBOUNCE_MS);
|
||||
|
||||
@@ -2,21 +2,14 @@
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {CassandraParams, KvQueryMeta, PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
class RecordingExecutor extends InMemoryCassandraQueryExecutor {
|
||||
@@ -38,11 +31,6 @@ class RecordingExecutor extends InMemoryCassandraQueryExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
function resetConfig(): void {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
describe('channel threads control identity', () => {
|
||||
let harness: ApiTestHarness;
|
||||
let executor: RecordingExecutor;
|
||||
@@ -53,13 +41,13 @@ describe('channel threads control identity', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetConfig();
|
||||
resetChannelThreadsConfig();
|
||||
executor = new RecordingExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -93,10 +81,8 @@ describe('channel threads control identity', () => {
|
||||
const baselineDm = await recorded(() => createDmChannel(harness, baseline.owner.token, baseline.friend.userId));
|
||||
const baselineChannels = await loadChannels(baselineGuild.result.id);
|
||||
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['1']}),
|
||||
);
|
||||
expect((await getInstanceConfigRepository().refreshChannelThreadsConfig()).config.ever_enabled).toBe(true);
|
||||
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: ['1']});
|
||||
expect(getCompiledChannelThreadsConfig().config.ever_enabled).toBe(true);
|
||||
|
||||
const enrolledOff = await createPair();
|
||||
const guild = await recorded(() => createGuild(harness, enrolledOff.owner.token, 'control'));
|
||||
|
||||
@@ -3,22 +3,20 @@
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {createChannel, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {ChannelThreadsRouteGuard} from '@app/api/channel/threads/ChannelThreadsRouteGuard';
|
||||
import {setCassandraQueryExecutorForTesting} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {CassandraParams, PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {getCompiledChannelThreadsConfig} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {BotOnly, LoginRequired} from '@app/api/middleware/AuthMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
type ChannelThreadsConfigUpdateRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {ChannelIdParam, GuildIdParam} from '@fluxer/schema/src/domains/common/CommonParamSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
import {z} from 'zod';
|
||||
@@ -96,6 +94,7 @@ describe('ChannelThreadsRouteGuard', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetChannelThreadsConfig();
|
||||
executor = new RecordingExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
owner = await createTestAccount(harness);
|
||||
@@ -109,10 +108,8 @@ describe('ChannelThreadsRouteGuard', () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
async function setConfig(update: ChannelThreadsConfigUpdateRequest): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, update),
|
||||
);
|
||||
async function setConfig(update: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await setChannelThreadsConfig({...getCompiledChannelThreadsConfig().config, ...update});
|
||||
}
|
||||
|
||||
async function snapshot(
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
channelThreadsUserActive,
|
||||
channelThreadsUserExcluded,
|
||||
compileChannelThreadsConfig,
|
||||
DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG,
|
||||
everyoneChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {Context} from 'hono';
|
||||
|
||||
@@ -39,19 +39,31 @@ const FETCH_GUILD_THREAD_STATE_CQL = GuildThreadState.selectCql({
|
||||
|
||||
const taintCache = new Map<string, {tainted: boolean; checkedAt: number}>();
|
||||
|
||||
let compiled: CompiledChannelThreadsConfig = DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG;
|
||||
const INITIAL_COMPILED_CONFIG = compileChannelThreadsConfig(everyoneChannelThreadsConfig(0));
|
||||
|
||||
let compiled: CompiledChannelThreadsConfig = INITIAL_COMPILED_CONFIG;
|
||||
let compiledSource: string | null | undefined;
|
||||
let compiledParser: ((raw: string | null) => ChannelThreadsConfig) | undefined;
|
||||
let pinnedForTesting = false;
|
||||
|
||||
export function syncChannelThreadsConfig(
|
||||
raw: string | null,
|
||||
parse: (raw: string | null) => ChannelThreadsConfig,
|
||||
): CompiledChannelThreadsConfig {
|
||||
if (raw === compiledSource) return compiled;
|
||||
compiled = raw === null ? DEFAULT_COMPILED_CHANNEL_THREADS_CONFIG : compileChannelThreadsConfig(parse(raw));
|
||||
if (pinnedForTesting || (raw === compiledSource && parse === compiledParser)) return compiled;
|
||||
compiled = compileChannelThreadsConfig(parse(raw));
|
||||
compiledSource = raw;
|
||||
compiledParser = parse;
|
||||
return compiled;
|
||||
}
|
||||
|
||||
export function pinChannelThreadsConfigForTesting(config: ChannelThreadsConfig | null): void {
|
||||
compiled = config === null ? INITIAL_COMPILED_CONFIG : compileChannelThreadsConfig(config);
|
||||
compiledSource = undefined;
|
||||
compiledParser = undefined;
|
||||
pinnedForTesting = config !== null;
|
||||
}
|
||||
|
||||
export function getCompiledChannelThreadsConfig(): CompiledChannelThreadsConfig {
|
||||
return compiled;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ import {entityTagMatches} from '@app/api/utils/EntityTag';
|
||||
import {Headers as HttpHeaders} from '@fluxer/constants/src/Headers';
|
||||
import {resolveChannelThreadsAssignment} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {resolveDomainMigrationAssignment} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {resolvePlutoniumPageAssignment} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
import {ExperimentAssignmentsResponse} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
|
||||
export function ExperimentController(app: HonoApp) {
|
||||
@@ -31,21 +30,20 @@ export function ExperimentController(app: HonoApp) {
|
||||
}),
|
||||
async (ctx) => {
|
||||
const instanceConfigRepository = ctx.get('instanceConfigRepository');
|
||||
const [delivery, domainMigrationConfig, channelThreadsConfig, plutoniumPageConfig] = await Promise.all([
|
||||
const [delivery, domainMigrationConfig, channelThreadsConfig] = await Promise.all([
|
||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||
instanceConfigRepository.getDomainMigrationConfig(),
|
||||
instanceConfigRepository.getCompiledChannelThreadsConfig(),
|
||||
instanceConfigRepository.getPlutoniumPageConfig(),
|
||||
]);
|
||||
const user = ctx.get('user');
|
||||
const userId = user.id.toString();
|
||||
const targeting = await resolveExperimentTargeting(user, [domainMigrationConfig, plutoniumPageConfig]);
|
||||
const targeting = await resolveExperimentTargeting(user, domainMigrationConfig);
|
||||
const body: ExperimentAssignmentsResponse = {
|
||||
poll_interval_seconds: delivery.poll_interval_seconds,
|
||||
poll_jitter_percent: delivery.poll_jitter_percent,
|
||||
assignments: {
|
||||
domain_migration: resolveDomainMigrationAssignment(domainMigrationConfig, userId, targeting),
|
||||
plutonium_page: resolvePlutoniumPageAssignment(plutoniumPageConfig, userId, targeting),
|
||||
plutonium_page: {enabled: true},
|
||||
},
|
||||
};
|
||||
const channelThreads = resolveChannelThreadsAssignment(channelThreadsConfig, userId);
|
||||
|
||||
@@ -13,9 +13,9 @@ const NO_GUILDS: ReadonlySet<string> = new Set();
|
||||
|
||||
export async function resolveExperimentTargeting(
|
||||
user: User,
|
||||
configs: ReadonlyArray<TargetableExperimentConfig>,
|
||||
config: TargetableExperimentConfig,
|
||||
): Promise<ExperimentTargeting> {
|
||||
const needsGuilds = configs.some((config) => config.enabled && config.included_guild_ids.length > 0);
|
||||
const needsGuilds = config.enabled && config.included_guild_ids.length > 0;
|
||||
const memberGuildIds = needsGuilds
|
||||
? new Set((await getUserRepository().getUserGuildIds(user.id)).map((guildId) => guildId.toString()))
|
||||
: NO_GUILDS;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {NoopWorkerService} from '@app/api/test/NoopWorkerService';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
everyoneChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
@@ -65,17 +66,17 @@ describe('ChannelThreadsGate', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
syncChannelThreadsConfig(null, () => ChannelThreadsConfigSchema.parse({}));
|
||||
load({});
|
||||
setCassandraQueryExecutorForTesting(null);
|
||||
});
|
||||
|
||||
it('is off for everyone while the config is missing', () => {
|
||||
syncChannelThreadsConfig(null, () => {
|
||||
throw new Error('a missing config is never parsed');
|
||||
});
|
||||
expect(guildActive(GUILD)).toBe(false);
|
||||
expect(userActive(USER)).toBe(false);
|
||||
expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(false);
|
||||
it('hands a missing config to the parser', () => {
|
||||
load({enabled: false});
|
||||
const compiled = syncChannelThreadsConfig(null, (raw) => everyoneChannelThreadsConfig(raw === null ? 3 : 0));
|
||||
expect(compiled.config.config_version).toBe(3);
|
||||
expect(guildActive(GUILD)).toBe(true);
|
||||
expect(userActive(USER)).toBe(true);
|
||||
expect(viewerActive(SYSTEM_THREAD_VIEWER, GUILD)).toBe(true);
|
||||
});
|
||||
|
||||
it('parses a snapshot once and reuses the compiled value while the string is unchanged', () => {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {acceptInvite, createChannelInvite, createGuild, getChannel} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
@@ -10,18 +9,11 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
|
||||
import {grantPremium} from '@app/api/user/tests/UserTestUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {UserPremiumTypes} from '@fluxer/constants/src/UserConstants';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
type ChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {DEFAULT_CHANNEL_THREADS_CONFIG} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||
import {
|
||||
DEFAULT_PLUTONIUM_PAGE_CONFIG,
|
||||
INERT_PLUTONIUM_PAGE_ASSIGNMENT,
|
||||
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
import {
|
||||
DEFAULT_EXPERIMENT_POLL_INTERVAL_SECONDS,
|
||||
DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
@@ -31,7 +23,7 @@ import {
|
||||
readDomainMigrationAssignment,
|
||||
readPlutoniumPageAssignment,
|
||||
} from '@fluxer/schema/src/domains/experiment/ExperimentSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const NOT_MODIFIED = 304;
|
||||
const ENDPOINT = '/experiments';
|
||||
@@ -47,10 +39,6 @@ describe('GET /experiments', () => {
|
||||
await harness.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await harness.shutdown();
|
||||
});
|
||||
@@ -59,7 +47,7 @@ describe('GET /experiments', () => {
|
||||
await createBuilderWithoutAuth(harness).get(ENDPOINT).expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
it('returns the default delivery cadence and the inert assignment while the feature is disabled', async () => {
|
||||
it('returns the default delivery cadence and the default assignments', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
@@ -69,7 +57,8 @@ describe('GET /experiments', () => {
|
||||
poll_jitter_percent: DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT,
|
||||
assignments: {
|
||||
domain_migration: INERT_DOMAIN_MIGRATION_ASSIGNMENT,
|
||||
plutonium_page: INERT_PLUTONIUM_PAGE_ASSIGNMENT,
|
||||
channel_threads: {active: true, config_version: 0},
|
||||
plutonium_page: {enabled: true},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -83,13 +72,16 @@ describe('GET /experiments', () => {
|
||||
expect(readDomainMigrationAssignment(body).enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('populates the plutonium page assignment key even when the rollout is disabled', async () => {
|
||||
it('serves the plutonium page to everyone and ignores a leftover rollout row', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setConfig(
|
||||
'plutonium_page_config',
|
||||
JSON.stringify({enabled: false, config_version: 3, rollout_basis_points: 0}),
|
||||
);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(Object.hasOwn(body.assignments, 'plutonium_page')).toBe(true);
|
||||
expect(readPlutoniumPageAssignment(body).enabled).toBe(false);
|
||||
expect(readPlutoniumPageAssignment(body)).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('resolves the domain migration caller through the allowlist', async () => {
|
||||
@@ -129,54 +121,7 @@ describe('GET /experiments', () => {
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('resolves the plutonium page caller through the allowlist and the exclusion list', async () => {
|
||||
const targeted = await createTestAccount(harness);
|
||||
const untargeted = await createTestAccount(harness);
|
||||
const excluded = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setPlutoniumPageConfig({
|
||||
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 0,
|
||||
included_user_ids: [targeted.userId, excluded.userId],
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const targetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, targeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(targetedBody.assignments.plutonium_page).toEqual({enabled: true});
|
||||
|
||||
const untargetedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, untargeted.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(untargetedBody.assignments.plutonium_page).toEqual({enabled: false});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('keeps the plutonium page exclusion ahead of a full rollout', async () => {
|
||||
const excluded = await createTestAccount(harness);
|
||||
const other = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().setPlutoniumPageConfig({
|
||||
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
|
||||
enabled: true,
|
||||
rollout_basis_points: 10000,
|
||||
excluded_user_ids: [excluded.userId],
|
||||
});
|
||||
|
||||
const excludedBody = await createBuilder<ExperimentAssignmentsResponse>(harness, excluded.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(excludedBody.assignments.plutonium_page).toEqual({enabled: false});
|
||||
|
||||
const otherBody = await createBuilder<ExperimentAssignmentsResponse>(harness, other.token).get(ENDPOINT).execute();
|
||||
expect(otherBody.assignments.plutonium_page).toEqual({enabled: true});
|
||||
});
|
||||
|
||||
it('enrols members of an included guild in every experiment and leaves everyone else out', async () => {
|
||||
it('enrols members of an included guild in domain migration and leaves everyone else out', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const outsider = await createTestAccount(harness);
|
||||
@@ -184,29 +129,21 @@ describe('GET /experiments', () => {
|
||||
const systemChannel = await getChannel(harness, owner.token, guild.system_channel_id!);
|
||||
const invite = await createChannelInvite(harness, owner.token, systemChannel.id);
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setDomainMigrationConfig({
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
await repository.setPlutoniumPageConfig({
|
||||
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
|
||||
enabled: true,
|
||||
included_guild_ids: [guild.id],
|
||||
});
|
||||
|
||||
const memberBody = await createBuilder<ExperimentAssignmentsResponse>(harness, member.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(memberBody.assignments.domain_migration).toEqual({enabled: true});
|
||||
expect(memberBody.assignments.plutonium_page).toEqual({enabled: true});
|
||||
|
||||
const outsiderBody = await createBuilder<ExperimentAssignmentsResponse>(harness, outsider.token)
|
||||
.get(ENDPOINT)
|
||||
.execute();
|
||||
expect(outsiderBody.assignments.domain_migration).toEqual({enabled: false});
|
||||
expect(outsiderBody.assignments.plutonium_page).toEqual({enabled: false});
|
||||
});
|
||||
|
||||
it('enrols premium users, subscription and lifetime alike, when the switch is on', async () => {
|
||||
@@ -215,17 +152,11 @@ describe('GET /experiments', () => {
|
||||
const free = await createTestAccount(harness);
|
||||
await grantPremium(harness, subscriber.userId, UserPremiumTypes.SUBSCRIPTION);
|
||||
await grantPremium(harness, visionary.userId, UserPremiumTypes.LIFETIME);
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.setDomainMigrationConfig({
|
||||
await getInstanceConfigRepository().setDomainMigrationConfig({
|
||||
...DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
await repository.setPlutoniumPageConfig({
|
||||
...DEFAULT_PLUTONIUM_PAGE_CONFIG,
|
||||
enabled: true,
|
||||
include_premium_users: true,
|
||||
});
|
||||
for (const [account, expected] of [
|
||||
[subscriber, true],
|
||||
[visionary, true],
|
||||
@@ -233,30 +164,24 @@ describe('GET /experiments', () => {
|
||||
] as const) {
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.domain_migration).toEqual({enabled: expected});
|
||||
expect(body.assignments.plutonium_page).toEqual({enabled: expected});
|
||||
}
|
||||
});
|
||||
|
||||
it('stores the guild ids and premium switch an admin sets for each experiment', async () => {
|
||||
it('stores the guild ids and premium switch an admin sets for domain migration', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const guildIds = ['1500000000000000001', '1500000000000000002'];
|
||||
const body = await createBuilder<
|
||||
Record<'domain_migration' | 'plutonium_page', {included_guild_ids: Array<string>; include_premium_users: boolean}>
|
||||
>(harness, admin.token)
|
||||
const body = await createBuilder<{
|
||||
domain_migration: {included_guild_ids: Array<string>; include_premium_users: boolean};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({
|
||||
domain_migration: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
plutonium_page: {included_guild_ids: guildIds, include_premium_users: true},
|
||||
})
|
||||
.body({domain_migration: {included_guild_ids: guildIds, include_premium_users: true}})
|
||||
.execute();
|
||||
for (const section of [body.domain_migration, body.plutonium_page]) {
|
||||
expect(section.included_guild_ids).toEqual(guildIds);
|
||||
expect(section.include_premium_users).toBe(true);
|
||||
}
|
||||
expect(body.domain_migration.included_guild_ids).toEqual(guildIds);
|
||||
expect(body.domain_migration.include_premium_users).toBe(true);
|
||||
});
|
||||
|
||||
it('revalidates with a strong etag and answers 304 when nothing changed', async () => {
|
||||
@@ -411,38 +336,23 @@ describe('GET /experiments', () => {
|
||||
expect(afterUndefined.domain_migration).toMatchObject({config_version: 1, enabled: true});
|
||||
});
|
||||
|
||||
it('bumps the plutonium page config version on every admin update without the client sending one', async () => {
|
||||
it('no longer exposes or accepts plutonium page and channel threads settings in the admin config', async () => {
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
|
||||
const afterFirst = await createBuilder<{plutonium_page: {config_version: number; enabled: boolean}}>(
|
||||
harness,
|
||||
admin.token,
|
||||
)
|
||||
const updated = await createBuilder<Record<string, unknown>>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({plutonium_page: {enabled: true, included_user_ids: [admin.userId]}})
|
||||
.body({plutonium_page: {enabled: false}, channel_threads: {enabled: false}})
|
||||
.execute();
|
||||
expect(afterFirst.plutonium_page).toMatchObject({config_version: 1, enabled: true});
|
||||
|
||||
const afterSecond = await createBuilder<{
|
||||
plutonium_page: {config_version: number; rollout_basis_points: number};
|
||||
}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({plutonium_page: {rollout_basis_points: 2500}})
|
||||
.execute();
|
||||
expect(afterSecond.plutonium_page).toMatchObject({config_version: 2, rollout_basis_points: 2500});
|
||||
|
||||
const afterEmpty = await createBuilder<{plutonium_page: {config_version: number}}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({plutonium_page: {}})
|
||||
.execute();
|
||||
expect(afterEmpty.plutonium_page).toMatchObject({config_version: 2});
|
||||
expect(Object.hasOwn(updated, 'plutonium_page')).toBe(false);
|
||||
expect(Object.hasOwn(updated, 'channel_threads')).toBe(false);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(body.assignments.plutonium_page).toEqual({enabled: true});
|
||||
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 0});
|
||||
});
|
||||
|
||||
it('serves the delivery cadence an admin set through the instance config', async () => {
|
||||
@@ -466,67 +376,20 @@ describe('GET /experiments', () => {
|
||||
expect(body.poll_jitter_percent).toBe(DEFAULT_EXPERIMENT_POLL_JITTER_PERCENT);
|
||||
});
|
||||
|
||||
it('keeps the control body and etag for a user outside the channel threads experiment', async () => {
|
||||
it('assigns channel threads to every user with the stored config version, even from a disabled row', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
const before = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: ['1']}),
|
||||
);
|
||||
const after = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token)
|
||||
.get(ENDPOINT)
|
||||
.executeWithResponse();
|
||||
|
||||
expect(Object.hasOwn(after.json.assignments, 'channel_threads')).toBe(false);
|
||||
expect(readChannelThreadsAssignment(after.json)).toBeNull();
|
||||
expect(after.response.headers.get('etag')).toBe(before.response.headers.get('etag'));
|
||||
});
|
||||
|
||||
it('assigns channel threads to an included user with the config version', async () => {
|
||||
const account = await createTestAccount(harness);
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled: true, included_user_ids: [account.userId]}),
|
||||
await getInstanceConfigRepository().setConfig(
|
||||
'channel_threads_config',
|
||||
JSON.stringify({
|
||||
...DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
enabled: false,
|
||||
config_version: 7,
|
||||
excluded_user_ids: [account.userId],
|
||||
}),
|
||||
);
|
||||
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, account.token).get(ENDPOINT).execute();
|
||||
|
||||
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 1});
|
||||
});
|
||||
|
||||
it('bumps the channel threads version, keeps ever_enabled sticky and publishes every admin update', async () => {
|
||||
const publish = vi.spyOn(ChannelThreadsConfigPublisher.prototype, 'publish').mockResolvedValue(undefined);
|
||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
AdminACLs.AUTHENTICATE,
|
||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||
]);
|
||||
const patch = (body: Record<string, unknown>) =>
|
||||
createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token)
|
||||
.patch('/admin/instance/config')
|
||||
.body({channel_threads: body})
|
||||
.execute();
|
||||
|
||||
const initial = await createBuilder<{channel_threads: ChannelThreadsConfig}>(harness, admin.token)
|
||||
.get('/admin/instance/config')
|
||||
.execute();
|
||||
expect(initial.channel_threads).toMatchObject({enabled: false, config_version: 0, ever_enabled: false});
|
||||
|
||||
const enabled = await patch({enabled: true, included_user_ids: [admin.userId], ever_enabled: false});
|
||||
expect(enabled.channel_threads).toMatchObject({enabled: true, config_version: 1, ever_enabled: true});
|
||||
|
||||
const disabled = await patch({enabled: false, config_version: 99});
|
||||
expect(disabled.channel_threads).toMatchObject({enabled: false, config_version: 2, ever_enabled: true});
|
||||
|
||||
const unchanged = await patch({});
|
||||
expect(unchanged.channel_threads.config_version).toBe(2);
|
||||
|
||||
expect(publish.mock.calls.map(([config]) => [config.config_version, config.enabled])).toEqual([
|
||||
[1, true],
|
||||
[2, false],
|
||||
]);
|
||||
const body = await createBuilder<ExperimentAssignmentsResponse>(harness, admin.token).get(ENDPOINT).execute();
|
||||
expect(readChannelThreadsAssignment(body)).toBeNull();
|
||||
expect(readChannelThreadsAssignment(body)).toEqual({active: true, config_version: 7});
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createGuildID, createUserID, type GuildID} from '@app/api/BrandedTypes';
|
||||
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
|
||||
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
|
||||
import {createGuildID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
isTainted,
|
||||
@@ -16,7 +14,6 @@ import {
|
||||
resolveThreadPermissionMode,
|
||||
shouldMaskThreadPermissionBits,
|
||||
} from '@app/api/guild/services/ThreadPermissionBits';
|
||||
import {setInjectedWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import type {IUserRepository} from '@app/api/user/IUserRepository';
|
||||
import {
|
||||
applyProtectedOverwriteBits,
|
||||
@@ -26,14 +23,7 @@ import {
|
||||
protectedThreadBits,
|
||||
} from '@app/api/utils/featureUtils';
|
||||
import {computePermissionsDiff} from '@app/api/utils/PermissionUtils';
|
||||
import {
|
||||
enabledThreadGuildIds,
|
||||
enqueueThreadPermissionSeeds,
|
||||
newlyEnabledThreadGuildIds,
|
||||
seedThreadOverwriteBits,
|
||||
seedThreadOverwriteValue,
|
||||
} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import type {WorkerTaskName} from '@app/api/worker/WorkerLaneConfig';
|
||||
import {seedThreadOverwriteBits, seedThreadOverwriteValue} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import {ALL_PERMISSIONS, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {GuildMFALevel} from '@fluxer/constants/src/GuildConstants';
|
||||
import {
|
||||
@@ -43,13 +33,9 @@ import {
|
||||
ThreadPermissionFlags,
|
||||
} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {MfaNotEnabledError} from '@fluxer/errors/src/domains/auth/MfaNotEnabledError';
|
||||
import {
|
||||
ChannelThreadsConfigSchema,
|
||||
DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {IWorkerService} from '@pkgs/worker/src/contracts/IWorkerService';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {afterEach, describe, expect, it} from 'vitest';
|
||||
|
||||
const NONE: ReadonlySet<string> = new Set();
|
||||
const CAPABLE: ReadonlySet<string> = new Set(['channel_threads']);
|
||||
@@ -254,61 +240,4 @@ describe('thread permission seeding rules', () => {
|
||||
Permissions.SEND_MESSAGES | DEFAULT_THREAD_PERMISSIONS,
|
||||
);
|
||||
});
|
||||
|
||||
it('targets enabled, non-disabled guilds of an enabled config', () => {
|
||||
const config = {
|
||||
...DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
enabled: true,
|
||||
enabled_guild_ids: ['1', '2', '3'],
|
||||
disabled_guild_ids: ['3'],
|
||||
};
|
||||
expect(enabledThreadGuildIds(config)).toEqual(['1', '2']);
|
||||
expect(enabledThreadGuildIds({...config, enabled: false})).toEqual([]);
|
||||
});
|
||||
|
||||
it('lists only guilds that became active in the landed config', () => {
|
||||
const previous = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
|
||||
expect(newlyEnabledThreadGuildIds(previous, previous)).toEqual([]);
|
||||
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled_guild_ids: ['1', '2', '3', '4']})).toEqual(['4']);
|
||||
expect(newlyEnabledThreadGuildIds({...previous, disabled_guild_ids: ['2']}, previous)).toEqual(['2']);
|
||||
expect(newlyEnabledThreadGuildIds({...previous, enabled: false}, previous)).toEqual(['1', '2', '3']);
|
||||
expect(newlyEnabledThreadGuildIds(previous, {...previous, enabled: false})).toEqual([]);
|
||||
});
|
||||
|
||||
it('enqueues seeds for every targeted guild without a seeded marker, so a retried update still seeds', async () => {
|
||||
const addJob = vi.fn(async () => {});
|
||||
setInjectedWorkerService({addJob} as unknown as IWorkerService<WorkerTaskName>);
|
||||
const markers = new Map<string, GuildThreadStateRow>([
|
||||
[
|
||||
'1',
|
||||
{
|
||||
guild_id: createGuildID(1n),
|
||||
first_active_at: new Date(),
|
||||
perms_seeded_at: new Date(),
|
||||
search_backfilled_at: null,
|
||||
},
|
||||
],
|
||||
[
|
||||
'2',
|
||||
{guild_id: createGuildID(2n), first_active_at: new Date(), perms_seeded_at: null, search_backfilled_at: null},
|
||||
],
|
||||
]);
|
||||
const threads = {
|
||||
getGuildMarker: async (guildId: GuildID) => markers.get(guildId.toString()) ?? null,
|
||||
} as unknown as IThreadRepository;
|
||||
const config = {...DEFAULT_CHANNEL_THREADS_CONFIG, enabled: true, enabled_guild_ids: ['1', '2', '3']};
|
||||
try {
|
||||
await enqueueThreadPermissionSeeds(threads, config);
|
||||
await enqueueThreadPermissionSeeds(threads, config);
|
||||
} finally {
|
||||
setInjectedWorkerService(undefined);
|
||||
}
|
||||
const keys = addJob.mock.calls.map((call) => (call as unknown as [string, unknown, {jobKey: string}])[2].jobKey);
|
||||
expect(keys).toEqual([
|
||||
'seed-thread-permissions-2',
|
||||
'seed-thread-permissions-3',
|
||||
'seed-thread-permissions-2',
|
||||
'seed-thread-permissions-3',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -4,12 +4,9 @@ import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannelID, createGuildID, createRoleID} from '@app/api/BrandedTypes';
|
||||
import {authorizeBot, createTestBotAccount} from '@app/api/bot/tests/BotTestUtils';
|
||||
import {ChannelRepository} from '@app/api/channel/ChannelRepository';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {fetchOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {GuildRoleRepository} from '@app/api/guild/repositories/GuildRoleRepository';
|
||||
import {
|
||||
acceptInvite,
|
||||
@@ -20,7 +17,6 @@ import {
|
||||
getChannel,
|
||||
updateRolePositions,
|
||||
} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {GuildThreadState} from '@app/api/Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {NoopGatewayService} from '@app/api/test/NoopGatewayService';
|
||||
@@ -31,10 +27,7 @@ import {
|
||||
THREAD_PERMISSIONS,
|
||||
ThreadPermissionFlags,
|
||||
} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import type {GuildRoleResponse} from '@fluxer/schema/src/domains/guild/GuildRoleSchemas';
|
||||
@@ -50,17 +43,6 @@ const ACTIVE: Partial<ChannelThreadsConfig> = {
|
||||
|
||||
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
|
||||
|
||||
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
...patch,
|
||||
ever_enabled: current.ever_enabled || patch.enabled === true,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
function bits(value: string | undefined): bigint {
|
||||
return BigInt(value ?? '0');
|
||||
}
|
||||
@@ -74,13 +56,11 @@ describe('thread permission bits across guild modes', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -124,7 +104,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('active guilds seed @everyone, write bits for capable users and restore them for others', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Active');
|
||||
expect(await roleBits(owner.token, guild.id, guild.id)).toBe(DEFAULT_PERMISSIONS | DEFAULT_THREAD_PERMISSIONS);
|
||||
@@ -155,7 +135,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('bots write thread bits and bot invites keep them only for capable active users in active guilds', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Bots');
|
||||
const bot = await createTestBotAccount(harness);
|
||||
@@ -185,7 +165,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
.execute();
|
||||
expect(bits(vcm.permissions)).toBe(0n);
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
const controlOwner = await createTestAccount(harness);
|
||||
const controlGuild = await createGuild(harness, controlOwner.token, 'Bots control');
|
||||
const controlBot = await createTestBotAccount(harness);
|
||||
@@ -197,7 +177,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('retired guilds keep stored thread bits read-only without escalation errors', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Retired');
|
||||
@@ -222,7 +202,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
await acceptInvite(harness, member.token, invite.code);
|
||||
await addMemberRole(harness, owner.token, guild.id, member.userId, mod.id);
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
const updated = await createBuilder<GuildRoleResponse>(harness, member.token)
|
||||
.patch(`/guilds/${guild.id}/roles/${target.id}`)
|
||||
.header(FEATURES, CAPABLE)
|
||||
@@ -268,7 +248,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('active channel creates store thread-aware overwrites and mask them for non-viewers', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Overwrites');
|
||||
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -309,7 +289,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('overwrite removals keep thread bits the actor cannot write, without escalation errors', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const member = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Removals');
|
||||
@@ -352,7 +332,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
expect([after.everyone?.allow, after.everyone?.deny]).toEqual([inThreads, 0n]);
|
||||
expect([after.target?.allow, after.target?.deny]).toEqual([0n, manageThreads]);
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
await createBuilder(harness, member.token)
|
||||
.patch(`/channels/${channel.id}`)
|
||||
.header(FEATURES, CAPABLE)
|
||||
@@ -368,7 +348,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
});
|
||||
|
||||
test('deleting a forum removes its thread-only index row', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Forum delete');
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
@@ -390,7 +370,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
expect(await repository.channelData.countGuildChannels(guildId)).toBe(before);
|
||||
});
|
||||
test('category sync and delete in a retired guild update forums without dispatching them', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Retired category');
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
@@ -407,7 +387,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
const forumId = createChannelID(BigInt(text.id) + 1n);
|
||||
await repository.upsert({...source.toRow(), channel_id: forumId, type: ChannelTypes.GUILD_FORUM, name: 'forum'});
|
||||
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).not.toBeNull();
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
const dispatchSpy = vi.spyOn(NoopGatewayService.prototype, 'dispatchGuild');
|
||||
const updatedIds = () =>
|
||||
dispatchSpy.mock.calls
|
||||
@@ -431,7 +411,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
}
|
||||
});
|
||||
test('channel overwrite edits keep thread bits for clients that cannot write them', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Overwrite edits');
|
||||
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -474,7 +454,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
expect(await storedAllow()).toBeUndefined();
|
||||
});
|
||||
test('lock_permissions moves copy parent thread bits only for writers', async () => {
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Lock sync');
|
||||
const inThreads = ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -545,7 +525,7 @@ describe('thread permission bits across guild modes', () => {
|
||||
const controlRoles = await listRoles(controlOwner.token, controlGuild.id, true);
|
||||
expect(controlRoles.map((role) => bits(role.permissions) & THREAD_PERMISSIONS)).toEqual([0n, 0n]);
|
||||
|
||||
await setConfig(ACTIVE);
|
||||
await setChannelThreadsConfig(ACTIVE);
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await templated(owner.token);
|
||||
const roles = await listRoles(owner.token, guild.id, true);
|
||||
|
||||
@@ -1,42 +1,19 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import type {PreparedQuery} from '@app/api/database/CassandraTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {acceptInvite, createChannelInvite, createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const FEATURES = 'X-Fluxer-Features';
|
||||
const CAPABLE = 'channel_threads';
|
||||
|
||||
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
...patch,
|
||||
ever_enabled: current.ever_enabled || patch.enabled === true,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
function resetGate(): void {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
describe('threads_active on GET /users/@me/guilds', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
@@ -46,12 +23,12 @@ describe('threads_active on GET /users/@me/guilds', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetGate();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetGate();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -93,7 +70,7 @@ describe('threads_active on GET /users/@me/guilds', () => {
|
||||
|
||||
test('flags only the enrolled guild for an enrolled capable viewer', async () => {
|
||||
const {owner, enrolled, plain, ownerBaseline} = await setup();
|
||||
await setConfig(enrol(enrolled, [owner.userId]));
|
||||
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
|
||||
const guilds = await listGuilds(owner.token, true);
|
||||
const byId = new Map(guilds.map((guild) => [guild.id, guild]));
|
||||
const baseline = new Map(ownerBaseline.map((guild) => [guild.id, guild]));
|
||||
@@ -109,17 +86,17 @@ describe('threads_active on GET /users/@me/guilds', () => {
|
||||
for (const guild of guilds) expect(guild).not.toHaveProperty('threads_active');
|
||||
};
|
||||
|
||||
await setConfig(enrol(enrolled, [owner.userId]));
|
||||
await setChannelThreadsConfig(enrol(enrolled, [owner.userId]));
|
||||
expectUnchanged(await listGuilds(member.token, true), memberBaseline);
|
||||
expectUnchanged(await listGuilds(owner.token, false), ownerBaseline);
|
||||
|
||||
await setConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
|
||||
await setChannelThreadsConfig({enabled: true, enabled_guild_ids: [], included_user_ids: [owner.userId]});
|
||||
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
|
||||
|
||||
await setConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
|
||||
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), excluded_user_ids: [owner.userId]});
|
||||
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
|
||||
|
||||
await setConfig({...enrol(enrolled, [owner.userId]), enabled: false});
|
||||
await setChannelThreadsConfig({...enrol(enrolled, [owner.userId]), enabled: false});
|
||||
expectUnchanged(await listGuilds(owner.token, true), ownerBaseline);
|
||||
});
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('KVThreadAutoArchiveQueueService', () => {
|
||||
setCassandraQueryExecutorForTesting(new InMemoryCassandraQueryExecutor());
|
||||
kv = new MockKVProvider();
|
||||
repositories = new ChannelRepository();
|
||||
queue = new KVThreadAutoArchiveQueueService(kv, repositories.threads, repositories.channelData);
|
||||
queue = new KVThreadAutoArchiveQueueService(kv);
|
||||
base = Date.now() - 10 * HOUR;
|
||||
await upsertOne(Channels.upsertAll({...threadRow(PARENT_ID), type: ChannelTypes.GUILD_TEXT, parent_id: null}));
|
||||
});
|
||||
@@ -134,14 +134,4 @@ describe('KVThreadAutoArchiveQueueService', () => {
|
||||
await queue.schedule(transition!.state, null);
|
||||
expect(await kv.zcard(threadArchiveQueueKey(GUILD_ID))).toBe(0);
|
||||
});
|
||||
|
||||
it('rebuilds a guild from its active partition', async () => {
|
||||
const first = await createThread(0, 60);
|
||||
const second = await createThread(1000, 60);
|
||||
await createThread(2000, 60, ChannelFlags.PINNED);
|
||||
await kv.zadd(threadArchiveQueueKey(GUILD_ID), 1, '123');
|
||||
expect(await queue.rebuildGuild(GUILD_ID)).toBe(2);
|
||||
expect(await queue.getDue(GUILD_ID, Date.now(), 200)).toEqual([first.threadId, second.threadId]);
|
||||
expect(await queue.getDue(GUILD_ID, Date.now(), 1)).toEqual([first.threadId]);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {type ChannelID, createChannelID, createGuildID, type GuildID, type MessageID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelDataRepository} from '@app/api/channel/repositories/IChannelDataRepository';
|
||||
import type {IThreadRepository} from '@app/api/channel/repositories/IThreadRepository';
|
||||
import type {ThreadState} from '@app/api/models/ThreadState';
|
||||
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
|
||||
import type {IKVProvider} from '@pkgs/kv_client/src/IKVProvider';
|
||||
@@ -20,11 +18,7 @@ export function threadAutoArchiveDueAt(state: ThreadState, lastMessageId: Messag
|
||||
}
|
||||
|
||||
export class KVThreadAutoArchiveQueueService {
|
||||
constructor(
|
||||
private readonly kvClient: IKVProvider,
|
||||
private readonly threads: IThreadRepository,
|
||||
private readonly channelData: IChannelDataRepository,
|
||||
) {}
|
||||
constructor(private readonly kvClient: IKVProvider) {}
|
||||
|
||||
async schedule(state: ThreadState, lastMessageId: MessageID | null): Promise<void> {
|
||||
if (state.archived || state.isPinned) {
|
||||
@@ -63,23 +57,4 @@ export class KVThreadAutoArchiveQueueService {
|
||||
const members = await this.kvClient.smembers(THREAD_ARCHIVE_GUILDS_KEY);
|
||||
return members.flatMap((member) => (/^\d+$/.test(member) ? [createGuildID(BigInt(member))] : []));
|
||||
}
|
||||
|
||||
async rebuildGuild(guildId: GuildID): Promise<number> {
|
||||
const key = threadArchiveQueueKey(guildId);
|
||||
const states = (await this.threads.listActiveThreads(guildId)).filter((state) => !state.isPinned);
|
||||
const channels = await this.channelData.listChannels(states.map((state) => state.threadId));
|
||||
const lastMessageIds = new Map(channels.map((channel) => [channel.id, channel.lastMessageId]));
|
||||
await this.kvClient.del(key);
|
||||
if (states.length === 0) {
|
||||
await this.kvClient.srem(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString());
|
||||
return 0;
|
||||
}
|
||||
const scoreMembers = states.flatMap((state) => [
|
||||
threadAutoArchiveDueAt(state, lastMessageIds.get(state.threadId) ?? null),
|
||||
state.threadId.toString(),
|
||||
]);
|
||||
await this.kvClient.zadd(key, ...scoreMembers);
|
||||
await this.kvClient.sadd(THREAD_ARCHIVE_GUILDS_KEY, guildId.toString());
|
||||
return states.length;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ChannelThreadsConfig} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {INatsConnectionManager} from '@pkgs/nats/src/INatsConnectionManager';
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
|
||||
export const CHANNEL_THREADS_CONFIG_NATS_SUBJECT = 'config.channel.threads';
|
||||
|
||||
interface ChannelThreadsConfigNatsMessage {
|
||||
type: 'channel_threads_config';
|
||||
config: ChannelThreadsConfig;
|
||||
}
|
||||
|
||||
export class ChannelThreadsConfigPublisher {
|
||||
constructor(private readonly connectionManager: INatsConnectionManager) {}
|
||||
|
||||
async publish(config: ChannelThreadsConfig): Promise<void> {
|
||||
if (this.connectionManager.isClosed()) {
|
||||
await this.connectionManager.connect();
|
||||
}
|
||||
const connection = this.connectionManager.getConnection();
|
||||
const message: ChannelThreadsConfigNatsMessage = {
|
||||
type: 'channel_threads_config',
|
||||
config,
|
||||
};
|
||||
connection.publish(CHANNEL_THREADS_CONFIG_NATS_SUBJECT, textEncoder.encode(JSON.stringify(message)));
|
||||
await connection.flush();
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,10 @@ import {InstanceConfigWriteRaceExecutor} from '@app/api/instance/tests/InstanceC
|
||||
import {startDockerContainer} from '@app/api/test/DockerTestContainer';
|
||||
import {InMemoryCassandraQueryExecutor} from '@app/api/test/InMemoryCassandraQueryExecutor';
|
||||
import {MockKVProvider} from '@app/api/test/mocks/MockKVProvider';
|
||||
import {
|
||||
DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
everyoneChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {
|
||||
DEFAULT_DOMAIN_MIGRATION_CONFIG,
|
||||
type DomainMigrationConfig,
|
||||
@@ -35,6 +39,7 @@ import {
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
const APP_PUBLIC_CONFIG_KEY = 'app_public_config';
|
||||
const INSTANCE_POLICY_CONFIG_KEY = 'instance_policy_config';
|
||||
@@ -301,6 +306,37 @@ describe('InstanceConfigRepository', () => {
|
||||
expect(domains).not.toContain('example.com');
|
||||
});
|
||||
|
||||
it('serves the everyone channel threads config at version zero when the key is absent', async () => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await expect(repository.getChannelThreadsConfig()).resolves.toEqual(everyoneChannelThreadsConfig(0));
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'a disabled row',
|
||||
stored: JSON.stringify({
|
||||
...DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
enabled: false,
|
||||
config_version: 9,
|
||||
disabled_guild_ids: ['1400000000000000001'],
|
||||
excluded_user_ids: ['1400000000000000002'],
|
||||
}),
|
||||
version: 9,
|
||||
},
|
||||
{name: 'a partial rollout row', stored: '{"enabled":true,"config_version":4,"guild_basis_points":100}', version: 4},
|
||||
{name: 'a row with an invalid version', stored: '{"enabled":false,"config_version":-1}', version: 0},
|
||||
{name: 'unparseable text', stored: 'not-json', version: 0},
|
||||
])('serves the everyone channel threads config for $name and keeps the stored version', async ({stored, version}) => {
|
||||
setCassandraQueryExecutorForTesting(new CountingInMemoryCassandraQueryExecutor());
|
||||
const repository = createRepository(new MockKVProvider());
|
||||
|
||||
await repository.setConfig(CHANNEL_THREADS_CONFIG_KEY, stored);
|
||||
|
||||
await expect(repository.getChannelThreadsConfig()).resolves.toEqual(everyoneChannelThreadsConfig(version));
|
||||
});
|
||||
|
||||
it('returns the default domain migration config when the key is absent', async () => {
|
||||
const executor = new CountingInMemoryCassandraQueryExecutor();
|
||||
setCassandraQueryExecutorForTesting(executor);
|
||||
|
||||
@@ -53,8 +53,8 @@ import {
|
||||
} from '@fluxer/schema/src/domains/admin/CaptchaSchemas';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
type CompiledChannelThreadsConfig,
|
||||
everyoneChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {
|
||||
type DomainMigrationConfig,
|
||||
@@ -69,10 +69,6 @@ import {
|
||||
type StoredBillingConfig,
|
||||
StoredBillingConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/InstanceBillingSchemas';
|
||||
import {
|
||||
type PlutoniumPageConfig,
|
||||
PlutoniumPageConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/PlutoniumPageSchemas';
|
||||
import {
|
||||
type LegacyPushServiceDeliveryWire,
|
||||
type PushRelayConfig,
|
||||
@@ -100,7 +96,6 @@ import {z} from 'zod';
|
||||
const GATEWAY_ROLLOUT_CONFIG_KEY = 'gateway_rollout_config';
|
||||
const PUSH_RELAY_CONFIG_KEY = 'push_service_delivery_config';
|
||||
const DOMAIN_MIGRATION_CONFIG_KEY = 'domain_migration_config';
|
||||
const PLUTONIUM_PAGE_CONFIG_KEY = 'plutonium_page_config';
|
||||
const CAPTCHA_CONFIG_KEY = 'captcha_config';
|
||||
const CHANNEL_THREADS_CONFIG_KEY = 'channel_threads_config';
|
||||
const EXPERIMENT_DELIVERY_CONFIG_KEY = 'experiment_delivery_config';
|
||||
@@ -433,7 +428,6 @@ type StoredConfigSection =
|
||||
| 'gateway rollout'
|
||||
| 'push relay'
|
||||
| 'domain migration'
|
||||
| 'plutonium page'
|
||||
| 'captcha'
|
||||
| 'channel threads'
|
||||
| 'experiment delivery'
|
||||
@@ -653,16 +647,15 @@ function parseStoredDomainMigrationConfig(raw: string | null): DomainMigrationCo
|
||||
return parseStoredConfigOrDefault(DomainMigrationConfigSchema, raw, 'domain migration');
|
||||
}
|
||||
|
||||
function parseStoredPlutoniumPageConfig(raw: string | null): PlutoniumPageConfig {
|
||||
return parseStoredConfigOrDefault(PlutoniumPageConfigSchema, raw, 'plutonium page');
|
||||
}
|
||||
|
||||
function parseStoredCaptchaConfig(raw: string | null): CaptchaConfig {
|
||||
return parseStoredConfigOrDefault(CaptchaConfigSchema, raw, 'captcha');
|
||||
}
|
||||
|
||||
const StoredChannelThreadsVersionSchema = z.object({config_version: z.number().int().min(0)});
|
||||
|
||||
function parseStoredChannelThreadsConfig(raw: string | null): ChannelThreadsConfig {
|
||||
return parseStoredConfigOrDefault(ChannelThreadsConfigSchema, raw, 'channel threads');
|
||||
const stored = StoredChannelThreadsVersionSchema.safeParse(readStoredConfigValue(raw, 'channel threads'));
|
||||
return everyoneChannelThreadsConfig(stored.success ? stored.data.config_version : 0);
|
||||
}
|
||||
|
||||
function parseStoredExperimentDeliveryConfig(raw: string | null): ExperimentDeliveryConfig {
|
||||
@@ -1327,7 +1320,6 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
parseStoredPushRelayConfig(snapshot.get(PUSH_RELAY_CONFIG_KEY) ?? null);
|
||||
parseStoredDomainMigrationConfig(snapshot.get(DOMAIN_MIGRATION_CONFIG_KEY) ?? null);
|
||||
parseStoredPlutoniumPageConfig(snapshot.get(PLUTONIUM_PAGE_CONFIG_KEY) ?? null);
|
||||
parseStoredCaptchaConfig(snapshot.get(CAPTCHA_CONFIG_KEY) ?? null);
|
||||
syncChannelThreadsConfig(snapshot.get(CHANNEL_THREADS_CONFIG_KEY) ?? null, parseStoredChannelThreadsConfig);
|
||||
parseStoredExperimentDeliveryConfig(snapshot.get(EXPERIMENT_DELIVERY_CONFIG_KEY) ?? null);
|
||||
@@ -1571,23 +1563,6 @@ export class InstanceConfigRepository {
|
||||
);
|
||||
}
|
||||
|
||||
async getPlutoniumPageConfig(): Promise<PlutoniumPageConfig> {
|
||||
const raw = await this.getConfig(PLUTONIUM_PAGE_CONFIG_KEY);
|
||||
return parseStoredPlutoniumPageConfig(raw);
|
||||
}
|
||||
|
||||
async setPlutoniumPageConfig(config: PlutoniumPageConfig): Promise<void> {
|
||||
await this.updatePlutoniumPageConfig(() => config);
|
||||
}
|
||||
|
||||
updatePlutoniumPageConfig(
|
||||
update: (current: PlutoniumPageConfig) => PlutoniumPageConfig,
|
||||
): Promise<PlutoniumPageConfig> {
|
||||
return this.updateStoredConfig(PLUTONIUM_PAGE_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(PlutoniumPageConfigSchema, update(parseStoredPlutoniumPageConfig(raw)), 'plutonium page'),
|
||||
);
|
||||
}
|
||||
|
||||
async getCaptchaConfig(): Promise<CaptchaConfig> {
|
||||
const raw = await this.getConfig(CAPTCHA_CONFIG_KEY);
|
||||
return parseStoredCaptchaConfig(raw);
|
||||
@@ -1613,16 +1588,6 @@ export class InstanceConfigRepository {
|
||||
return syncChannelThreadsConfig(raw, parseStoredChannelThreadsConfig);
|
||||
}
|
||||
|
||||
async updateChannelThreadsConfig(
|
||||
update: (current: ChannelThreadsConfig) => ChannelThreadsConfig,
|
||||
): Promise<ChannelThreadsConfig> {
|
||||
const landed = await this.updateStoredConfig(CHANNEL_THREADS_CONFIG_KEY, (raw) =>
|
||||
validateStoredConfig(ChannelThreadsConfigSchema, update(parseStoredChannelThreadsConfig(raw)), 'channel threads'),
|
||||
);
|
||||
syncChannelThreadsConfig(JSON.stringify(landed), parseStoredChannelThreadsConfig);
|
||||
return landed;
|
||||
}
|
||||
|
||||
async getExperimentDeliveryConfig(): Promise<ExperimentDeliveryConfig> {
|
||||
const raw = await this.getConfig(EXPERIMENT_DELIVERY_CONFIG_KEY);
|
||||
return parseStoredExperimentDeliveryConfig(raw);
|
||||
|
||||
@@ -47,7 +47,6 @@ import {createStorageService} from '@app/api/infrastructure/StorageServiceFactor
|
||||
import {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {createUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
|
||||
import {VirusScanService} from '@app/api/infrastructure/VirusScanService';
|
||||
import {ChannelThreadsConfigPublisher} from '@app/api/instance/ChannelThreadsConfigPublisher';
|
||||
import {GatewayRolloutConfigPublisher} from '@app/api/instance/GatewayRolloutConfigPublisher';
|
||||
import {InstanceConfigRepository} from '@app/api/instance/InstanceConfigRepository';
|
||||
import {PushRelayConfigPublisher} from '@app/api/instance/PushRelayConfigPublisher';
|
||||
@@ -175,17 +174,6 @@ export const getPushRelayConfigPublisher = singleton(
|
||||
),
|
||||
);
|
||||
|
||||
export const getChannelThreadsConfigPublisher = singleton(
|
||||
() =>
|
||||
new ChannelThreadsConfigPublisher(
|
||||
new NatsConnectionManager({
|
||||
url: Config.nats.coreUrl,
|
||||
token: Config.nats.authToken || undefined,
|
||||
name: 'fluxer-api-channel-threads-config',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
export const getVisionarySlotRepository = singleton(() => new VisionarySlotRepository());
|
||||
export const getCacheService: () => ICacheService = singleton(() => new KVCacheProvider({client: getKVClient()}));
|
||||
export const getRateLimitService = singleton(() => new RateLimitService(getKVClient()));
|
||||
@@ -278,8 +266,7 @@ let threadAutoArchiveQueue: KVThreadAutoArchiveQueueService | null = null;
|
||||
export function getKVThreadAutoArchiveQueue(): KVThreadAutoArchiveQueueService {
|
||||
const kvClient = getKVClient();
|
||||
if (!threadAutoArchiveQueue || threadAutoArchiveQueueClient !== kvClient) {
|
||||
const channels = getChannelRepository();
|
||||
threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient, channels.threads, channels.channelData);
|
||||
threadAutoArchiveQueue = new KVThreadAutoArchiveQueueService(kvClient);
|
||||
threadAutoArchiveQueueClient = kvClient;
|
||||
}
|
||||
return threadAutoArchiveQueue;
|
||||
|
||||
@@ -5,9 +5,9 @@ import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHa
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
type ChannelThreadsConfig,
|
||||
DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
everyoneChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
@@ -38,29 +38,27 @@ describe('RpcService get_channel_threads_config', () => {
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
test('serves the disabled default before any admin write', async () => {
|
||||
test('serves the everyone config at version zero while no row is stored', async () => {
|
||||
expect(await fetchConfig()).toEqual({
|
||||
type: 'get_channel_threads_config',
|
||||
data: {config: DEFAULT_CHANNEL_THREADS_CONFIG},
|
||||
data: {config: everyoneChannelThreadsConfig(0)},
|
||||
});
|
||||
});
|
||||
|
||||
test('serves the stored config with its version and sticky ever_enabled', async () => {
|
||||
const repository = getInstanceConfigRepository();
|
||||
await repository.updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled: true, enabled_guild_ids: ['123']}),
|
||||
);
|
||||
await repository.updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled: false}),
|
||||
test('serves the everyone config at the stored version for a disabled partial row', async () => {
|
||||
await getInstanceConfigRepository().setConfig(
|
||||
'channel_threads_config',
|
||||
JSON.stringify({
|
||||
...DEFAULT_CHANNEL_THREADS_CONFIG,
|
||||
enabled: false,
|
||||
ever_enabled: true,
|
||||
config_version: 12,
|
||||
guild_basis_points: 2500,
|
||||
enabled_guild_ids: ['123'],
|
||||
excluded_user_ids: ['456'],
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await fetchConfig();
|
||||
|
||||
expect(response.data.config).toMatchObject({
|
||||
enabled: false,
|
||||
ever_enabled: true,
|
||||
config_version: 2,
|
||||
enabled_guild_ids: ['123'],
|
||||
});
|
||||
expect((await fetchConfig()).data.config).toEqual(everyoneChannelThreadsConfig(12));
|
||||
});
|
||||
});
|
||||
@@ -2,36 +2,28 @@
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createChannelID, createGuildID} from '@app/api/BrandedTypes';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {executeConditional, fetchOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
getCompiledChannelThreadsConfig,
|
||||
insertGuildThreadMarker,
|
||||
isTainted,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {getWorkerService} from '@app/api/middleware/ServiceRegistry';
|
||||
import {getChannelRepository, getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getChannelRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {GuildThreadState} from '@app/api/Tables';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {
|
||||
applyChannelThreadsConfigUpdate,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const FETCH_MARKER = GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1});
|
||||
|
||||
function resetConfig(): void {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
describe('RpcService guild load thread permission seeding', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
@@ -41,12 +33,12 @@ describe('RpcService guild load thread permission seeding', () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
await harness.reset();
|
||||
resetConfig();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
resetConfig();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -60,10 +52,7 @@ describe('RpcService guild load thread permission seeding', () => {
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const loadGuild = (guildId: string) => loadCollection(guildId, 'guild');
|
||||
const setEnabled = (enabled: boolean) =>
|
||||
getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled, guild_basis_points: 10000}),
|
||||
);
|
||||
const setEnabled = (enabled: boolean) => setChannelThreadsConfig({enabled, guild_basis_points: 10000});
|
||||
|
||||
test('marks and seeds a bucketed guild on its first active load, and leaves control guilds alone', async () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
@@ -75,16 +64,14 @@ describe('RpcService guild load thread permission seeding', () => {
|
||||
expect(await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId})).toBeNull();
|
||||
expect(addJob).not.toHaveBeenCalled();
|
||||
|
||||
const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
applyChannelThreadsConfigUpdate(current, {enabled: true, guild_basis_points: 10000}),
|
||||
);
|
||||
await setEnabled(true);
|
||||
await loadGuild(guild.id);
|
||||
const marker = await fetchOne<GuildThreadStateRow>(FETCH_MARKER, {guild_id: guildId});
|
||||
expect(marker?.first_active_at).toBeInstanceOf(Date);
|
||||
expect(marker?.perms_seeded_at).toBeNull();
|
||||
expect(addJob).toHaveBeenCalledWith(
|
||||
'seedThreadPermissions',
|
||||
{guildId: guild.id, configVersion: landed.config_version},
|
||||
{guildId: guild.id, configVersion: getCompiledChannelThreadsConfig().config.config_version},
|
||||
{jobKey: `seed-thread-permissions-${guild.id}`},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,21 +1,13 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {resetChannelThreadsConfig, setChannelThreadsConfig} from '@app/api/channel/tests/ThreadTestUtils';
|
||||
import {createGuild} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import {markGuildChannelsAsIndexed, sendMessage} from '@app/api/message/tests/MessageTestUtils';
|
||||
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {ChannelTypes, Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {ThreadPermissionFlags} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
ChannelThreadsConfigSchema,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
|
||||
import type {MessageSearchResultsResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
@@ -23,37 +15,21 @@ import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
const FEATURES = 'X-Fluxer-Features';
|
||||
const CAPABLE = 'channel_threads';
|
||||
|
||||
async function setConfig(patch: Partial<ChannelThreadsConfig>): Promise<void> {
|
||||
await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
...patch,
|
||||
ever_enabled: current.ever_enabled || patch.enabled === true,
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
);
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
function resetConfig(): void {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
describe('message search thread permission bits', () => {
|
||||
let harness: ApiTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
resetConfig();
|
||||
resetChannelThreadsConfig();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
resetConfig();
|
||||
resetChannelThreadsConfig();
|
||||
await harness.shutdown();
|
||||
});
|
||||
|
||||
test('search channel overwrites are masked for non-viewers in tainted guilds', async () => {
|
||||
await setConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000});
|
||||
await setChannelThreadsConfig({enabled: true, guild_basis_points: 10000, user_basis_points: 10000});
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Search masking');
|
||||
const allow = Permissions.SEND_MESSAGES | ThreadPermissionFlags.SEND_MESSAGES_IN_THREADS;
|
||||
@@ -89,7 +65,7 @@ describe('message search thread permission bits', () => {
|
||||
expect(await searchAllow(scope, false)).toBe(Permissions.SEND_MESSAGES);
|
||||
}
|
||||
|
||||
await setConfig({enabled: false});
|
||||
await setChannelThreadsConfig({enabled: false});
|
||||
for (const scope of scopes) {
|
||||
expect(await searchAllow(scope, true)).toBe(Permissions.SEND_MESSAGES);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
setCassandraQueryExecutorForTesting,
|
||||
shutdownCassandraQueryExecutorForTesting,
|
||||
} from '@app/api/database/CassandraQueryExecution';
|
||||
import {pinChannelThreadsConfigForTesting} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
|
||||
import {setInjectedUsersServiceClient} from '@app/api/infrastructure/UsersServiceClient';
|
||||
import {initializeLogger} from '@app/api/Logger';
|
||||
@@ -227,6 +228,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
pinChannelThreadsConfigForTesting(null);
|
||||
server.close();
|
||||
await shutdownCassandraQueryExecutorForTesting();
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import {NsfwContentRequiresAgeVerificationError} from '@fluxer/errors/src/domain
|
||||
import type {LimitConfigSnapshot} from '@fluxer/limits/src/LimitTypes';
|
||||
import {ChannelThreadsConfigSchema} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
const {isUnreachableEntityError} = UserContentServiceTestHooks;
|
||||
|
||||
@@ -255,7 +255,7 @@ describe('getRecentMentions', () => {
|
||||
const THREAD = createChannelID(400n);
|
||||
const viewer = {kind: 'user', userId: VIEWER_ID, bot: false, capable: false} as const;
|
||||
|
||||
afterEach(() => {
|
||||
beforeEach(() => {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
});
|
||||
|
||||
@@ -447,7 +447,7 @@ describe('getSavedMessages', () => {
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
beforeEach(() => {
|
||||
syncChannelThreadsConfig(null, (raw) => ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {}));
|
||||
});
|
||||
|
||||
|
||||
@@ -87,7 +87,6 @@ const LANE_CONFIG = {
|
||||
'refreshSearchIndex',
|
||||
'seedThreadPermissions',
|
||||
'archiveInactiveThreads',
|
||||
'rebuildThreadAutoArchiveQueue',
|
||||
'repairThreadIndexes',
|
||||
'deleteChannelThreads',
|
||||
'removeThreadMembershipsForGuildMember',
|
||||
|
||||
@@ -55,7 +55,6 @@ import syncUrlBlocklists from '@app/api/worker/tasks/SyncUrlBlocklists';
|
||||
import {
|
||||
archiveInactiveThreads,
|
||||
deleteChannelThreads,
|
||||
rebuildThreadAutoArchiveQueue,
|
||||
removeThreadMembershipsForGuildMember,
|
||||
repairThreadIndexes,
|
||||
} from '@app/api/worker/tasks/ThreadMaintenanceTasks';
|
||||
@@ -69,7 +68,6 @@ export const workerTasks: Record<WorkerTaskName, WorkerTaskHandler> = {
|
||||
applicationProcessDeletion,
|
||||
archiveInactiveThreads,
|
||||
deleteChannelThreads,
|
||||
rebuildThreadAutoArchiveQueue,
|
||||
removeThreadMembershipsForGuildMember,
|
||||
repairThreadIndexes,
|
||||
syncThreadSearchDocument,
|
||||
|
||||
@@ -13,11 +13,6 @@ import type {Channel} from '@app/api/models/Channel';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {Permissions} from '@fluxer/constants/src/ChannelConstants';
|
||||
import {DEFAULT_THREAD_PERMISSIONS, THREAD_PERMISSIONS} from '@fluxer/constants/src/ThreadPermissionUtils';
|
||||
import {
|
||||
type ChannelThreadsConfig,
|
||||
channelThreadsGuildActive,
|
||||
compileChannelThreadsConfig,
|
||||
} from '@fluxer/schema/src/domains/admin/ChannelThreadsSchemas';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
|
||||
@@ -27,7 +22,6 @@ const PayloadSchema = z.object({
|
||||
});
|
||||
|
||||
const SEEDED_GUILD_CACHE_MAX_ENTRIES = 10_000;
|
||||
const SEED_MARKER_READ_CHUNK = 100;
|
||||
const seededGuildIds = new Set<string>();
|
||||
|
||||
export function seedThreadOverwriteBits(value: bigint): bigint {
|
||||
@@ -39,33 +33,6 @@ export function seedThreadOverwriteValue(value: bigint): bigint {
|
||||
return (value & Permissions.SEND_MESSAGES) !== 0n ? cleared | DEFAULT_THREAD_PERMISSIONS : cleared;
|
||||
}
|
||||
|
||||
export function enabledThreadGuildIds(config: ChannelThreadsConfig): Array<string> {
|
||||
const compiled = compileChannelThreadsConfig(config);
|
||||
return config.enabled_guild_ids.filter((id) => channelThreadsGuildActive(compiled, id));
|
||||
}
|
||||
|
||||
export function newlyEnabledThreadGuildIds(
|
||||
previous: ChannelThreadsConfig,
|
||||
landed: ChannelThreadsConfig,
|
||||
): Array<string> {
|
||||
const before = new Set(enabledThreadGuildIds(previous));
|
||||
return enabledThreadGuildIds(landed).filter((id) => !before.has(id));
|
||||
}
|
||||
|
||||
export async function enqueueThreadPermissionSeeds(
|
||||
threads: IThreadRepository,
|
||||
config: ChannelThreadsConfig,
|
||||
): Promise<void> {
|
||||
const guildIds = enabledThreadGuildIds(config);
|
||||
for (let index = 0; index < guildIds.length; index += SEED_MARKER_READ_CHUNK) {
|
||||
const chunk = guildIds.slice(index, index + SEED_MARKER_READ_CHUNK);
|
||||
const markers = await Promise.all(chunk.map((id) => threads.getGuildMarker(createGuildID(BigInt(id)))));
|
||||
for (const [position, guildId] of chunk.entries()) {
|
||||
if (!markers[position]?.perms_seeded_at) await enqueueThreadPermissionSeed(guildId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function enqueueThreadPermissionSeed(guildId: string): Promise<void> {
|
||||
await getWorkerService().addJob(
|
||||
'seedThreadPermissions',
|
||||
|
||||
@@ -22,14 +22,12 @@ import {
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {deleteChannelMessageSearchDocuments} from '@app/api/search/MessageSearchIndexCleanup';
|
||||
import {deleteThreadSearchDocuments} from '@app/api/search/thread/ThreadSearchService';
|
||||
import {ensureChannelThreadsConfigVersion} from '@app/api/worker/tasks/SeedThreadPermissions';
|
||||
import {getWorkerDependencies} from '@app/api/worker/WorkerContext';
|
||||
import {MAX_THREAD_ARCHIVES_PER_SWEEP_TICK} from '@fluxer/constants/src/ThreadConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {z} from 'zod';
|
||||
|
||||
const SnowflakeString = z.string().regex(/^\d{1,20}$/);
|
||||
const GuildPayload = z.object({guildId: SnowflakeString, configVersion: z.number().int().min(0).optional()});
|
||||
const RepairPayload = z.object({threadIds: z.array(SnowflakeString).min(1).max(1000)});
|
||||
const ParentPayload = z.object({guildId: SnowflakeString, parentId: SnowflakeString});
|
||||
const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeString});
|
||||
@@ -37,8 +35,8 @@ const MemberPayload = z.object({guildId: SnowflakeString, userId: SnowflakeStrin
|
||||
const PARENT_PAGE_SIZE = 100;
|
||||
|
||||
function archiveQueue(): KVThreadAutoArchiveQueueService {
|
||||
const {kvClient, channelRepository} = getWorkerDependencies();
|
||||
return new KVThreadAutoArchiveQueueService(kvClient, channelRepository.threads, channelRepository.channelData);
|
||||
const {kvClient} = getWorkerDependencies();
|
||||
return new KVThreadAutoArchiveQueueService(kvClient);
|
||||
}
|
||||
|
||||
async function archiveDueThreads(
|
||||
@@ -109,15 +107,6 @@ export const archiveInactiveThreads: WorkerTaskHandler = async () => {
|
||||
if (archived > 0) Logger.info({archived}, 'Archived inactive threads');
|
||||
};
|
||||
|
||||
export const rebuildThreadAutoArchiveQueue: WorkerTaskHandler = async (payload) => {
|
||||
const validated = GuildPayload.parse(payload);
|
||||
await ensureChannelThreadsConfigVersion(validated.configVersion);
|
||||
const guildId = createGuildID(BigInt(validated.guildId));
|
||||
if (!guildActive(guildId)) return;
|
||||
const scheduled = await archiveQueue().rebuildGuild(guildId);
|
||||
Logger.info({guildId: guildId.toString(), scheduled}, 'Rebuilt thread auto-archive queue');
|
||||
};
|
||||
|
||||
export const repairThreadIndexes: WorkerTaskHandler = async (payload) => {
|
||||
const {threadIds} = RepairPayload.parse(payload);
|
||||
const {channelRepository} = getWorkerDependencies();
|
||||
|
||||
@@ -349,6 +349,7 @@ describe('thread mention chunks', () => {
|
||||
});
|
||||
|
||||
test('drops a thread chunk that lands after the guild left the experiment', async () => {
|
||||
activate(false);
|
||||
const increments = chunkHarness();
|
||||
await runChunk();
|
||||
expect(increments).toEqual([]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import {fetchOne} from '@app/api/database/CassandraQueryExecution';
|
||||
import type {GuildThreadStateRow} from '@app/api/database/types/ThreadTypes';
|
||||
import {
|
||||
clearChannelThreadsTaintCacheForTesting,
|
||||
syncChannelThreadsConfig,
|
||||
pinChannelThreadsConfigForTesting,
|
||||
} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import {addMemberRole, createGuild, createRole} from '@app/api/guild/tests/GuildTestUtils';
|
||||
import type {IGatewayService} from '@app/api/infrastructure/IGatewayService';
|
||||
@@ -35,12 +35,8 @@ import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSc
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
function parseConfig(raw: string | null): ChannelThreadsConfig {
|
||||
return ChannelThreadsConfigSchema.parse(raw ? JSON.parse(raw) : {});
|
||||
}
|
||||
|
||||
function setConfig(patch: Partial<ChannelThreadsConfig> | null): void {
|
||||
syncChannelThreadsConfig(patch === null ? null : JSON.stringify(patch), parseConfig);
|
||||
pinChannelThreadsConfigForTesting(ChannelThreadsConfigSchema.parse(patch ?? {}));
|
||||
clearChannelThreadsTaintCacheForTesting();
|
||||
}
|
||||
|
||||
@@ -195,22 +191,19 @@ describe('seedThreadPermissions', () => {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, 'Lagging');
|
||||
const guildId = createGuildID(BigInt(guild.id));
|
||||
const landed = await getInstanceConfigRepository().updateChannelThreadsConfig((current) =>
|
||||
ChannelThreadsConfigSchema.parse({
|
||||
enabled: true,
|
||||
ever_enabled: true,
|
||||
enabled_guild_ids: [guild.id],
|
||||
config_version: current.config_version + 1,
|
||||
}),
|
||||
const storedVersion = 5;
|
||||
await getInstanceConfigRepository().setConfig(
|
||||
'channel_threads_config',
|
||||
JSON.stringify(ChannelThreadsConfigSchema.parse({enabled: false, config_version: storedVersion})),
|
||||
);
|
||||
setConfig(null);
|
||||
pinChannelThreadsConfigForTesting(null);
|
||||
|
||||
await expect(
|
||||
seedThreadPermissions({guildId: guild.id, configVersion: landed.config_version + 1}, helpers()),
|
||||
seedThreadPermissions({guildId: guild.id, configVersion: storedVersion + 1}, helpers()),
|
||||
).rejects.toThrow();
|
||||
setConfig(null);
|
||||
pinChannelThreadsConfigForTesting(null);
|
||||
|
||||
await seedThreadPermissions({guildId: guild.id, configVersion: landed.config_version}, helpers());
|
||||
await seedThreadPermissions({guildId: guild.id, configVersion: storedVersion}, helpers());
|
||||
const marker = await fetchOne<GuildThreadStateRow>(
|
||||
GuildThreadState.selectCql({where: GuildThreadState.where.eq('guild_id'), limit: 1}),
|
||||
{guild_id: guildId},
|
||||
|
||||
@@ -23,7 +23,6 @@ import {NoopLogger} from '@app/api/test/mocks/NoopLogger';
|
||||
import {
|
||||
archiveInactiveThreads,
|
||||
deleteChannelThreads,
|
||||
rebuildThreadAutoArchiveQueue,
|
||||
removeThreadMembershipsForGuildMember,
|
||||
} from '@app/api/worker/tasks/ThreadMaintenanceTasks';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '@app/api/worker/WorkerContext';
|
||||
@@ -130,21 +129,6 @@ describe('thread maintenance tasks', () => {
|
||||
expect(queued).not.toContain(thread.id);
|
||||
});
|
||||
|
||||
it('reloads a lagging config before rebuilding the auto-archive queue', async () => {
|
||||
const s = await setup();
|
||||
const thread = await startThread(s.owner.token, s.channelId);
|
||||
const key = threadArchiveQueueKey(createGuildID(BigInt(s.guildId)));
|
||||
await getKVClient().del(key);
|
||||
const configVersion = (await getInstanceConfigRepository().getChannelThreadsConfig()).config_version;
|
||||
resetChannelThreadsConfig();
|
||||
await expect(
|
||||
rebuildThreadAutoArchiveQueue({guildId: s.guildId, configVersion: configVersion + 1}, helpers()),
|
||||
).rejects.toThrow();
|
||||
resetChannelThreadsConfig();
|
||||
await rebuildThreadAutoArchiveQueue({guildId: s.guildId, configVersion}, helpers());
|
||||
expect(await getKVClient().zrangebyscore(key, '-inf', '+inf')).toContain(thread.id);
|
||||
});
|
||||
|
||||
it('deletes the threads of a deleted parent in tainted guilds', async () => {
|
||||
const s = await setup();
|
||||
const thread = await startThread(s.owner.token, s.channelId);
|
||||
|
||||
Reference in new issue
Block a user