feat(app): gate the expression info card behind an experiment (#2773)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-14 20:14:58 +02:00
1 parent 7c9274847f
commit 6339c3b8ad
110 files changed
+12827 -5191

No files matched your search

+16 -5
View File
@@ -15,11 +15,13 @@ import type {GuildMember} from '@app/api/models/GuildMember';
import type {GuildRole} from '@app/api/models/GuildRole';
import type {GuildSticker} from '@app/api/models/GuildSticker';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '@app/api/user/UserCacheHelpers';
import type {
GuildEmojiResponse,
GuildEmojiWithUserResponse,
GuildStickerResponse,
GuildStickerWithUserResponse,
import {
GUILD_EXPRESSION_SOURCE_BADGE_FEATURES,
type GuildEmojiResponse,
type GuildEmojiWithUserResponse,
type GuildExpressionSourceGuildResponse,
type GuildStickerResponse,
type GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildBanResponse, GuildMemberResponse} from '@fluxer/schema/src/domains/guild/GuildMemberSchemas';
import type {GuildPartialResponse, GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
@@ -50,6 +52,15 @@ export function mapGuildToPartialResponse(guild: Guild): GuildPartialResponse {
};
}
export function mapGuildToExpressionSourceGuildResponse(guild: Guild): GuildExpressionSourceGuildResponse {
return {
id: guild.id.toString(),
name: guild.name,
icon: stripGuildIconForFeatures(guild.iconHash, guild.features),
features: GUILD_EXPRESSION_SOURCE_BADGE_FEATURES.filter((feature) => guild.features.has(feature)),
};
}
export function mapGuildToGuildResponse(
guild: Guild,
options?: {
@@ -12,6 +12,7 @@ import {
type StoredGuildAuditLogEntryResponse,
} from '@app/api/guild/GuildAuditLogEntryMapper';
import type {GuildAuditLogService} from '@app/api/guild/GuildAuditLogService';
import {mapGuildToExpressionSourceGuildResponse} from '@app/api/guild/GuildModel';
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
import {GuildChannelService} from '@app/api/guild/services/GuildChannelService';
import {GuildContentService} from '@app/api/guild/services/GuildContentService';
@@ -259,6 +260,7 @@ export class GuildService {
name: emoji.name,
animated: emoji.isAnimated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_EMOJI_ENABLED),
guild: mapGuildToExpressionSourceGuildResponse(guild),
};
}
@@ -272,6 +274,7 @@ export class GuildService {
name: sticker.name,
animated: sticker.animated,
allow_cloning: guild.features.has(GuildFeatures.CLONE_STICKER_ENABLED),
guild: mapGuildToExpressionSourceGuildResponse(guild),
};
}
@@ -0,0 +1,163 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {createTestAccount, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {getPngDataUrl} from '@app/api/emoji/tests/EmojiTestUtils';
import {createGuild, updateGuild} from '@app/api/guild/tests/GuildTestUtils';
import {ensureSessionStarted} from '@app/api/message/tests/MessageTestUtils';
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 {GuildFeatures} from '@fluxer/constants/src/GuildConstants';
import type {
GuildEmojiMetadataResponse,
GuildEmojiWithUserResponse,
GuildStickerMetadataResponse,
GuildStickerWithUserResponse,
} from '@fluxer/schema/src/domains/guild/GuildEmojiSchemas';
import type {GuildResponse} from '@fluxer/schema/src/domains/guild/GuildResponseSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, test} from 'vitest';
const SOURCE_GUILD_KEYS = ['features', 'icon', 'id', 'name'];
interface MetadataSource {
owner: TestAccount;
guild: GuildResponse;
emoji: GuildEmojiWithUserResponse;
sticker: GuildStickerWithUserResponse;
}
async function createSource(harness: ApiTestHarness, name: string): Promise<MetadataSource> {
const owner = await createTestAccount(harness);
await ensureSessionStarted(harness, owner.token);
const guild = await createGuild(harness, owner.token, name);
const emoji = await createBuilder<GuildEmojiWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/emojis`)
.body({name: 'source_emoji', image: getPngDataUrl()})
.execute();
const sticker = await createBuilder<GuildStickerWithUserResponse>(harness, owner.token)
.post(`/guilds/${guild.id}/stickers`)
.body({name: 'source_sticker', description: 'source sticker', tags: [], image: getPngDataUrl()})
.execute();
return {owner, guild, emoji, sticker};
}
async function grantGuildFeatures(harness: ApiTestHarness, guildId: string, features: Array<string>): Promise<void> {
await createBuilder(harness, '').post(`/test/guilds/${guildId}/features`).body({add_features: features}).execute();
}
async function getEmojiMetadata(
harness: ApiTestHarness,
token: string,
emojiId: string,
): Promise<GuildEmojiMetadataResponse> {
return createBuilder<GuildEmojiMetadataResponse>(harness, token)
.get(`/emojis/${emojiId}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
}
async function getStickerMetadata(
harness: ApiTestHarness,
token: string,
stickerId: string,
): Promise<GuildStickerMetadataResponse> {
return createBuilder<GuildStickerMetadataResponse>(harness, token)
.get(`/stickers/${stickerId}/metadata`)
.expect(HTTP_STATUS.OK)
.execute();
}
describe('Guild expression metadata source community', () => {
let harness: ApiTestHarness;
beforeAll(async () => {
harness = await createApiTestHarness();
});
beforeEach(async () => {
await harness.reset();
});
afterAll(async () => {
await harness?.shutdown();
});
test('returns the source community presentation for both kinds to a member and a non-member alike', async () => {
const source = await createSource(harness, 'Metadata Presentation Source');
const withIcon = await updateGuild(harness, source.owner.token, source.guild.id, {icon: getPngDataUrl()});
expect(withIcon.icon).toBeTruthy();
await grantGuildFeatures(harness, source.guild.id, [GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
const outsider = await createTestAccount(harness);
const responses = [
await getEmojiMetadata(harness, source.owner.token, source.emoji.id),
await getEmojiMetadata(harness, outsider.token, source.emoji.id),
await getStickerMetadata(harness, source.owner.token, source.sticker.id),
await getStickerMetadata(harness, outsider.token, source.sticker.id),
];
for (const response of responses) {
expect(response.guild_id).toBe(source.guild.id);
expect(response.guild.id).toBe(source.guild.id);
expect(response.guild.name).toBe('Metadata Presentation Source');
expect(response.guild.icon).toBe(withIcon.icon);
expect(response.guild.features).toContain(GuildFeatures.DISCOVERABLE);
expect(response.guild.features).toContain(GuildFeatures.VERIFIED);
expect(response.allow_cloning).toBe(false);
}
const [memberEmoji, outsiderEmoji, memberSticker, outsiderSticker] = responses;
expect(outsiderEmoji.guild).toEqual(memberEmoji.guild);
expect(outsiderSticker.guild).toEqual(memberSticker.guild);
expect(memberSticker.guild).toEqual(memberEmoji.guild);
});
test('reports no badge features and no icon for a community that has neither', async () => {
const source = await createSource(harness, 'Metadata Plain Source');
const outsider = await createTestAccount(harness);
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
for (const response of [emoji, sticker]) {
expect(response.guild.icon).toBeNull();
expect(response.guild.features).toEqual([]);
}
});
test('exposes only id, name, icon and badge features to a non-member for both kinds', async () => {
const source = await createSource(harness, 'Metadata Envelope Source');
await updateGuild(harness, source.owner.token, source.guild.id, {verification_level: 1});
await grantGuildFeatures(harness, source.guild.id, [
GuildFeatures.DISCOVERABLE,
GuildFeatures.VERIFIED,
GuildFeatures.INVITES_DISABLED,
GuildFeatures.CLONE_EMOJI_ENABLED,
GuildFeatures.CLONE_STICKER_ENABLED,
GuildFeatures.BANNER,
GuildFeatures.INVITE_SPLASH,
]);
const outsider = await createTestAccount(harness);
const emoji = await getEmojiMetadata(harness, outsider.token, source.emoji.id);
const sticker = await getStickerMetadata(harness, outsider.token, source.sticker.id);
for (const response of [emoji, sticker]) {
expect(response.allow_cloning).toBe(true);
expect(Object.keys(response.guild).sort()).toEqual(SOURCE_GUILD_KEYS);
const leaked = response.guild as Record<string, unknown>;
for (const key of [
'banner',
'banner_width',
'banner_height',
'splash',
'splash_width',
'splash_height',
'embed_splash',
'embed_splash_width',
'embed_splash_height',
'splash_card_alignment',
]) {
expect(leaked).not.toHaveProperty(key);
}
expect([...response.guild.features].sort()).toEqual([GuildFeatures.DISCOVERABLE, GuildFeatures.VERIFIED]);
expect(response.guild.features).not.toContain(GuildFeatures.INVITES_DISABLED);
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_EMOJI_ENABLED);
expect(response.guild.features).not.toContain(GuildFeatures.CLONE_STICKER_ENABLED);
expect(response.guild.features).not.toContain(GuildFeatures.BANNER);
}
expect(sticker.guild).toEqual(emoji.guild);
expect(Object.keys(emoji).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
expect(Object.keys(sticker).sort()).toEqual(['allow_cloning', 'animated', 'guild', 'guild_id', 'id', 'name']);
});
});