mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(api): respect age gating in search and stabilise discovery (#2598)
This commit is contained in:
@@ -401,7 +401,7 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
const language =
|
||||
params.primaryLanguage && isValidDiscoveryLanguage(params.primaryLanguage) ? params.primaryLanguage : undefined;
|
||||
const tag = params.tag && params.tag.trim().length > 0 ? normalizeDiscoveryTag(params.tag) : undefined;
|
||||
const sortBy = params.sortBy === 'member_count' ? 'memberCount' : 'relevance';
|
||||
const sortBy = params.sortBy === 'relevance' ? 'relevance' : 'memberCount';
|
||||
const filters: GuildSearchFilters = {
|
||||
isDiscoverable: true,
|
||||
discoveryCategory: params.categoryId,
|
||||
@@ -444,7 +444,6 @@ export class GuildDiscoveryService extends IGuildDiscoveryService {
|
||||
for (const guild of guilds) {
|
||||
const counts = freshCounts.get(BigInt(guild.id) as GuildID);
|
||||
if (counts) {
|
||||
guild.member_count = counts.memberCount;
|
||||
guild.online_count = counts.onlineCount;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,23 +68,10 @@ export class GuildSearchService {
|
||||
const includeNsfwRequested = searchParams.include_nsfw ?? false;
|
||||
const canUserAccessNsfw =
|
||||
guildIsAgeRestricted || includeNsfwRequested ? await this.getCanUserAccessNsfw(userId) : false;
|
||||
if (guildIsAgeRestricted) {
|
||||
if (!canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
if (!includeNsfwRequested) {
|
||||
const hitsPerPage = searchParams.hits_per_page ?? 25;
|
||||
const page = searchParams.page ?? 1;
|
||||
return {
|
||||
channels: [],
|
||||
messages: [],
|
||||
total: 0,
|
||||
hits_per_page: hitsPerPage,
|
||||
page,
|
||||
};
|
||||
}
|
||||
if (guildIsAgeRestricted && !canUserAccessNsfw) {
|
||||
throw new NsfwContentRequiresAgeVerificationError();
|
||||
}
|
||||
const canIncludeNsfw = includeNsfwRequested && canUserAccessNsfw;
|
||||
const canIncludeNsfw = canUserAccessNsfw && (includeNsfwRequested || guildIsAgeRestricted);
|
||||
const guildNsfw = guildData?.nsfw ?? false;
|
||||
const channels = await this.channelRepository.listChannels(channelIds);
|
||||
const channelMap = new Map<string, Channel>();
|
||||
|
||||
@@ -8,13 +8,21 @@ import type {
|
||||
DiscoveryCategoryResponse,
|
||||
DiscoveryGuildListResponse,
|
||||
} from '@fluxer/schema/src/domains/guild/GuildDiscoverySchemas';
|
||||
import type {WorkerTaskHelpers} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
import {createTestAccount, setUserACLs} from '../../auth/tests/AuthTestUtils';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {createTestBotAccount} from '../../bot/tests/BotTestUtils';
|
||||
import {setInjectedGatewayService} from '../../middleware/ServiceRegistry';
|
||||
import {getGuildRepository} from '../../middleware/ServiceSingletons';
|
||||
import {banUser} from '../../moderation/tests/ModerationTestUtils';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {NoopLogger} from '../../test/mocks/NoopLogger';
|
||||
import {NoopGatewayService} from '../../test/NoopGatewayService';
|
||||
import {HTTP_STATUS, TEST_IDS} from '../../test/TestConstants';
|
||||
import {createBuilder, createBuilderWithoutAuth} from '../../test/TestRequestBuilder';
|
||||
import syncDiscoveryIndex from '../../worker/tasks/SyncDiscoveryIndex';
|
||||
import {clearWorkerDependencies, setWorkerDependenciesForTest} from '../../worker/WorkerContext';
|
||||
import {createGuild, getUserGuilds} from './GuildTestUtils';
|
||||
|
||||
async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, memberCount: number): Promise<void> {
|
||||
@@ -24,6 +32,30 @@ async function setGuildMemberCount(harness: ApiTestHarness, guildId: string, mem
|
||||
.execute();
|
||||
}
|
||||
|
||||
interface LiveGuildCounts {
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
|
||||
const WORKER_HELPERS = {logger: new NoopLogger()} as unknown as WorkerTaskHelpers;
|
||||
|
||||
class LiveCountsGatewayService extends NoopGatewayService {
|
||||
constructor(private readonly liveCounts: Map<string, LiveGuildCounts>) {
|
||||
super();
|
||||
}
|
||||
|
||||
override async getDiscoveryGuildCounts(guildIds: Array<GuildID>): Promise<Map<GuildID, LiveGuildCounts>> {
|
||||
const counts = new Map<GuildID, LiveGuildCounts>();
|
||||
for (const guildId of guildIds) {
|
||||
const live = this.liveCounts.get(guildId.toString());
|
||||
if (live) {
|
||||
counts.set(guildId, live);
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
}
|
||||
|
||||
async function applyAndApprove(
|
||||
harness: ApiTestHarness,
|
||||
ownerToken: string,
|
||||
@@ -44,12 +76,39 @@ async function applyAndApprove(
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function createApprovedDiscoveryGuild(
|
||||
harness: ApiTestHarness,
|
||||
adminToken: string,
|
||||
name: string,
|
||||
memberCount: number,
|
||||
): Promise<string> {
|
||||
const owner = await createTestAccount(harness);
|
||||
const guild = await createGuild(harness, owner.token, name);
|
||||
await setGuildMemberCount(harness, guild.id, memberCount);
|
||||
await applyAndApprove(
|
||||
harness,
|
||||
owner.token,
|
||||
adminToken,
|
||||
guild.id,
|
||||
`${name} welcomes everyone`,
|
||||
DiscoveryCategories.GAMING,
|
||||
);
|
||||
return guild.id;
|
||||
}
|
||||
|
||||
function expectNonIncreasing(counts: Array<number>): void {
|
||||
for (let index = 1; index < counts.length; index++) {
|
||||
expect(counts[index]).toBeLessThanOrEqual(counts[index - 1]);
|
||||
}
|
||||
}
|
||||
|
||||
describe('Discovery Search and Join', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
harness = await createApiTestHarness({search: 'enabled'});
|
||||
});
|
||||
afterEach(async () => {
|
||||
clearWorkerDependencies();
|
||||
await harness?.shutdown();
|
||||
});
|
||||
describe('categories', () => {
|
||||
@@ -257,6 +316,82 @@ describe('Discovery Search and Join', () => {
|
||||
.execute();
|
||||
expect(results.guilds.length).toBeLessThanOrEqual(2);
|
||||
});
|
||||
test('should order results by the member count it reports back', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
setInjectedGatewayService(new LiveCountsGatewayService(liveCounts));
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const memberCount of [50, 40, 30, 20, 10]) {
|
||||
guildIds.push(
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Ordered Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 3});
|
||||
liveCounts.set(guildIds[4], {memberCount: 500, onlineCount: 7});
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual(guildIds);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
expect(results.guilds[0].online_count).toBe(3);
|
||||
expect(results.guilds[4].online_count).toBe(7);
|
||||
});
|
||||
test('should rank by member count when the client omits sort_by', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildsByCount = new Map<number, string>();
|
||||
for (const memberCount of [30, 10, 20]) {
|
||||
guildsByCount.set(
|
||||
memberCount,
|
||||
await createApprovedDiscoveryGuild(harness, admin.token, `Unsorted Guild ${memberCount}`, memberCount),
|
||||
);
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const results = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?limit=48')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(results.guilds.map((guild) => guild.id)).toEqual([
|
||||
guildsByCount.get(30),
|
||||
guildsByCount.get(20),
|
||||
guildsByCount.get(10),
|
||||
]);
|
||||
expectNonIncreasing(results.guilds.map((guild) => guild.member_count));
|
||||
});
|
||||
test('should not repeat guilds across pages when the discovery index is resynced', async () => {
|
||||
const liveCounts = new Map<string, LiveGuildCounts>();
|
||||
const gatewayService = new LiveCountsGatewayService(liveCounts);
|
||||
setInjectedGatewayService(gatewayService);
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'discovery:review']);
|
||||
const guildIds: Array<string> = [];
|
||||
for (const [index, memberCount] of [60, 50, 40, 40, 30, 30].entries()) {
|
||||
guildIds.push(await createApprovedDiscoveryGuild(harness, admin.token, `Paged Guild ${index}`, memberCount));
|
||||
}
|
||||
const searcher = await createTestAccount(harness);
|
||||
const firstPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=0')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(firstPage.guilds.map((guild) => guild.id)).toEqual([guildIds[0], guildIds[1]]);
|
||||
liveCounts.set(guildIds[0], {memberCount: 5, onlineCount: 0});
|
||||
setWorkerDependenciesForTest({guildRepository: getGuildRepository(), gatewayService});
|
||||
await syncDiscoveryIndex({}, WORKER_HELPERS);
|
||||
const secondPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=2')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const thirdPage = await createBuilder<DiscoveryGuildListResponse>(harness, searcher.token)
|
||||
.get('/discovery/guilds?sort_by=member_count&limit=2&offset=4')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const paged = [...firstPage.guilds, ...secondPage.guilds, ...thirdPage.guilds].map((guild) => guild.id);
|
||||
expect(new Set(paged).size).toBe(paged.length);
|
||||
expect([...paged].sort()).toEqual([...guildIds].sort());
|
||||
});
|
||||
test('should require login to search', async () => {
|
||||
await createBuilderWithoutAuth(harness).get('/discovery/guilds').expect(HTTP_STATUS.UNAUTHORIZED).execute();
|
||||
});
|
||||
|
||||
@@ -344,7 +344,7 @@ describe('Message Search Permissions', () => {
|
||||
}
|
||||
expect(result.messages.length).toBe(0);
|
||||
});
|
||||
test('age-restricted guild requires include_nsfw: true', async () => {
|
||||
test('age-restricted guild is searchable by an adult member without include_nsfw', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Restricted Search Guild');
|
||||
const systemChannelId = guild.system_channel_id!;
|
||||
@@ -363,7 +363,7 @@ describe('Message Search Permissions', () => {
|
||||
if (!isSearchResult(excluded)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(excluded.messages.length).toBe(0);
|
||||
expect(excluded.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
|
||||
const included = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
@@ -379,6 +379,30 @@ describe('Message Search Permissions', () => {
|
||||
expect(included.messages.length).toBeGreaterThan(0);
|
||||
expect(included.messages.some((m) => m.channel_id === systemChannelId)).toBe(true);
|
||||
});
|
||||
test('age-restricted guild is searchable in a channel pinned to nsfw_override: false', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const guild = await createGuild(harness, owner.token, 'Age Restricted Override Guild');
|
||||
const channel = await createBuilder<{id: string; nsfw_override?: boolean | null}>(harness, owner.token)
|
||||
.post(`/guilds/${guild.id}/channels`)
|
||||
.body({name: 'override-channel', type: ChannelTypes.GUILD_TEXT, nsfw: false})
|
||||
.execute();
|
||||
expect(channel.nsfw_override).toBe(false);
|
||||
await sendChannelMessage(harness, owner.token, channel.id, 'age restricted override searchable message');
|
||||
await updateGuild(harness, owner.token, guild.id, {nsfw_level: GuildNSFWLevel.AGE_RESTRICTED});
|
||||
await markGuildChannelsAsIndexed(harness, owner.token, guild.id);
|
||||
const result = await createBuilder<MessageSearchResponse>(harness, owner.token)
|
||||
.post('/search/messages')
|
||||
.body({
|
||||
content: 'age restricted override searchable',
|
||||
context_guild_id: guild.id,
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
if (!isSearchResult(result)) {
|
||||
expect.fail('Expected search result but got indexing response');
|
||||
}
|
||||
expect(result.messages.some((m) => m.channel_id === channel.id)).toBe(true);
|
||||
});
|
||||
test('underage user cannot search messages in an age-restricted guild', async () => {
|
||||
const owner = await createTestAccount(harness, {dateOfBirth: '2000-01-01'});
|
||||
const underageMember = await createTestAccount(harness, {dateOfBirth: '2012-01-01'});
|
||||
|
||||
@@ -441,16 +441,25 @@ function collectGuildText(doc: SearchableGuild): Array<string | null> {
|
||||
return [doc.name, doc.vanityUrlCode, doc.discoveryDescription, ...doc.discoveryTags];
|
||||
}
|
||||
|
||||
const sortGuildsByCreatedAt = sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc');
|
||||
const sortGuildsByMemberCount = sortNumericField<SearchableGuild, GuildSearchFilters>('memberCount', 'desc');
|
||||
|
||||
function sortGuilds(left: SearchableGuild, right: SearchableGuild, filters: GuildSearchFilters, query: string): number {
|
||||
const sorter = filters.sortBy === 'memberCount' ? sortGuildsByMemberCount : sortGuildsByCreatedAt;
|
||||
const delta = sorter(left, right, filters, query);
|
||||
if (delta !== 0) return delta;
|
||||
const leftId = BigInt(left.id);
|
||||
const rightId = BigInt(right.id);
|
||||
if (leftId === rightId) return 0;
|
||||
return leftId > rightId ? -1 : 1;
|
||||
}
|
||||
|
||||
class InMemoryGuildSearchService
|
||||
extends InMemorySearchServiceBase<GuildSearchFilters, SearchableGuild>
|
||||
implements IGuildSearchService
|
||||
{
|
||||
constructor() {
|
||||
super(
|
||||
matchesGuildFilters,
|
||||
collectGuildText,
|
||||
sortNumericField<SearchableGuild, GuildSearchFilters>('createdAt', 'asc'),
|
||||
);
|
||||
super(matchesGuildFilters, collectGuildText, sortGuilds);
|
||||
}
|
||||
|
||||
async indexGuild(guild: Guild, discovery?: GuildDiscoveryContext): Promise<void> {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import {DiscoveryApplicationStatus} from '@fluxer/constants/src/DiscoveryConstants';
|
||||
import type {WorkerTaskHandler} from '@pkgs/worker/src/contracts/WorkerTask';
|
||||
import type {GuildID} from '../../BrandedTypes';
|
||||
import {GuildDiscoveryRepository} from '../../guild/repositories/GuildDiscoveryRepository';
|
||||
import {getGuildSearchService} from '../../SearchFactory';
|
||||
import {mapWithConcurrency} from '../../utils/ConcurrencyUtils';
|
||||
@@ -17,7 +16,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
helpers.logger.warn('Search service not available, skipping discovery index sync');
|
||||
return;
|
||||
}
|
||||
const {guildRepository, gatewayService} = getWorkerDependencies();
|
||||
const {guildRepository} = getWorkerDependencies();
|
||||
const discoveryRepository = new GuildDiscoveryRepository();
|
||||
const approvedRows = await discoveryRepository.listByStatus(DiscoveryApplicationStatus.APPROVED);
|
||||
if (approvedRows.length === 0) {
|
||||
@@ -25,21 +24,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
return;
|
||||
}
|
||||
const guildIds = approvedRows.map((row) => row.guild_id);
|
||||
let freshCounts = new Map<
|
||||
GuildID,
|
||||
{
|
||||
memberCount: number;
|
||||
onlineCount: number;
|
||||
}
|
||||
>();
|
||||
try {
|
||||
freshCounts = await gatewayService.getDiscoveryGuildCounts(guildIds);
|
||||
} catch (error) {
|
||||
helpers.logger.warn(
|
||||
{error: error instanceof Error ? error.message : String(error)},
|
||||
'Failed to fetch fresh guild counts from gateway, using database values',
|
||||
);
|
||||
}
|
||||
let synced = 0;
|
||||
for (let i = 0; i < guildIds.length; i += BATCH_SIZE) {
|
||||
const batch = guildIds.slice(i, i + BATCH_SIZE);
|
||||
@@ -54,7 +38,7 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
if (!guild) return null;
|
||||
const discoveryRow = discoveryRows[index];
|
||||
if (!discoveryRow || discoveryRow.status !== DiscoveryApplicationStatus.APPROVED) return null;
|
||||
return {guild, discoveryRow, counts: freshCounts.get(guildId)};
|
||||
return {guild, discoveryRow};
|
||||
})
|
||||
.filter((update): update is NonNullable<typeof update> => update != null);
|
||||
await mapWithConcurrency(updates, UPDATE_CONCURRENCY, (update) =>
|
||||
@@ -63,7 +47,6 @@ const syncDiscoveryIndex: WorkerTaskHandler = async (_payload, helpers) => {
|
||||
categoryId: update.discoveryRow.category_type,
|
||||
primaryLanguage: update.discoveryRow.primary_language ?? null,
|
||||
tags: update.discoveryRow.custom_tags ?? [],
|
||||
memberCount: update.counts?.memberCount,
|
||||
}),
|
||||
);
|
||||
synced += updates.length;
|
||||
|
||||
@@ -99,9 +99,10 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
|
||||
return Math.min(SKELETON_DISCOVERY_MAX_COLUMNS, Math.max(1, columnsThatFit));
|
||||
}, [containerWidth, zoomLevel]);
|
||||
const guilds = Discovery.guilds;
|
||||
const loadedCount = Discovery.loadedCount;
|
||||
const searchActive = Discovery.query.length > 0;
|
||||
const rowCount = columns > 0 ? Math.ceil(guilds.length / columns) : 0;
|
||||
const hasMore = guilds.length < Discovery.total;
|
||||
const hasMore = loadedCount < Discovery.total;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rowCount,
|
||||
getScrollElement: () => scrollerRef.current?.getViewportElement() ?? null,
|
||||
@@ -114,10 +115,10 @@ export const DiscoveryPage = observer(function DiscoveryPage() {
|
||||
return;
|
||||
}
|
||||
void Discovery.search({
|
||||
offset: guilds.length,
|
||||
offset: loadedCount,
|
||||
limit: PAGE_SIZE,
|
||||
});
|
||||
}, [guilds.length, hasMore]);
|
||||
}, [loadedCount, hasMore]);
|
||||
useEffect(() => {
|
||||
const items = virtualizer.getVirtualItems();
|
||||
const lastItem = items[items.length - 1];
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {DiscoveryGuild, DiscoverySearchResponse} from '@app/features/discovery/commands/DiscoveryCommands';
|
||||
import * as DiscoveryCommands from '@app/features/discovery/commands/DiscoveryCommands';
|
||||
import {beforeEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
import Discovery from './Discovery';
|
||||
|
||||
vi.mock('@app/features/discovery/commands/DiscoveryCommands', () => ({
|
||||
searchGuilds: vi.fn(),
|
||||
getCategories: vi.fn(),
|
||||
}));
|
||||
|
||||
function guild(id: string, memberCount: number): DiscoveryGuild {
|
||||
return {
|
||||
id,
|
||||
name: `Guild ${id}`,
|
||||
icon: null,
|
||||
banner: null,
|
||||
description: null,
|
||||
category_type: 0,
|
||||
primary_language: null,
|
||||
custom_tags: [],
|
||||
member_count: memberCount,
|
||||
online_count: 0,
|
||||
features: [],
|
||||
verification_level: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function page(guilds: Array<DiscoveryGuild>, total: number): DiscoverySearchResponse {
|
||||
return {guilds, total, categoryCounts: null};
|
||||
}
|
||||
|
||||
describe('Discovery.search', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(DiscoveryCommands.searchGuilds).mockReset();
|
||||
Discovery.reset();
|
||||
});
|
||||
|
||||
test('does not append a guild that a previous page already returned', async () => {
|
||||
vi.mocked(DiscoveryCommands.searchGuilds)
|
||||
.mockResolvedValueOnce(page([guild('1', 50), guild('2', 40)], 4))
|
||||
.mockResolvedValueOnce(page([guild('2', 40), guild('3', 30)], 4));
|
||||
await Discovery.search({limit: 2, offset: 0});
|
||||
await Discovery.search({limit: 2, offset: Discovery.loadedCount});
|
||||
expect(Discovery.guilds.map((entry) => entry.id)).toEqual(['1', '2', '3']);
|
||||
});
|
||||
|
||||
test('advances the pagination offset by what the server returned, not by what survived deduping', async () => {
|
||||
vi.mocked(DiscoveryCommands.searchGuilds)
|
||||
.mockResolvedValueOnce(page([guild('1', 50), guild('2', 40)], 4))
|
||||
.mockResolvedValueOnce(page([guild('2', 40), guild('3', 30)], 4));
|
||||
await Discovery.search({limit: 2, offset: 0});
|
||||
await Discovery.search({limit: 2, offset: Discovery.loadedCount});
|
||||
expect(Discovery.loadedCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ const DEFAULT_DISCOVERY_PAGE_SIZE = 36;
|
||||
|
||||
class Discovery {
|
||||
guilds: Array<DiscoveryGuild> = [];
|
||||
loadedCount = 0;
|
||||
total = 0;
|
||||
loading = false;
|
||||
error = false;
|
||||
@@ -53,6 +54,7 @@ class Discovery {
|
||||
this.error = false;
|
||||
if (offset === 0 && searchModeChanged) {
|
||||
this.guilds = [];
|
||||
this.loadedCount = 0;
|
||||
this.total = 0;
|
||||
}
|
||||
this.query = query;
|
||||
@@ -78,8 +80,10 @@ class Discovery {
|
||||
if (offset === 0) {
|
||||
this.guilds = result.guilds;
|
||||
} else {
|
||||
this.guilds = [...this.guilds, ...result.guilds];
|
||||
const seen = new Set(this.guilds.map((guild) => guild.id));
|
||||
this.guilds = [...this.guilds, ...result.guilds.filter((guild) => !seen.has(guild.id))];
|
||||
}
|
||||
this.loadedCount = offset + result.guilds.length;
|
||||
this.total = result.total;
|
||||
this.categoryCounts = result.categoryCounts;
|
||||
this.loading = false;
|
||||
@@ -136,6 +140,7 @@ class Discovery {
|
||||
reset(): void {
|
||||
this.activeSearchToken += 1;
|
||||
this.guilds = [];
|
||||
this.loadedCount = 0;
|
||||
this.total = 0;
|
||||
this.categoryCounts = null;
|
||||
this.loading = false;
|
||||
|
||||
@@ -264,7 +264,7 @@ The `current` scope requires one of `context_guild_id` and `context_channel_id`.
|
||||
|
||||
Fluxer resolves a supplied `context_guild_id` before anything else. A guild that cannot be resolved returns 404 `UNKNOWN_GUILD`, and a guild the caller is not a member of returns 403 `MISSING_PERMISSIONS`. A guild context searches that guild, restricted to `channel_ids` when supplied and to every channel of the guild otherwise, and every channel in the resulting set must belong to that guild. A channel the caller cannot both view and read history in is dropped when the set came from the guild, and rejected with 403 `MISSING_PERMISSIONS` when the caller named it in `channel_ids`. An age-gated channel that `include_nsfw` does not admit is removed from the set before the permission check runs, so it never causes that refusal.
|
||||
|
||||
A guild whose NSFW level is age restricted refuses a caller who is not old enough with 403 `NSFW_CONTENT_AGE_RESTRICTED`. An old-enough caller who did not set `include_nsfw` receives an empty result from such a guild. The request still succeeds.
|
||||
A guild whose NSFW level is age restricted refuses a caller who is not old enough with 403 `NSFW_CONTENT_AGE_RESTRICTED`. An old-enough caller searches every channel of such a guild, including its age-gated channels, whether or not `include_nsfw` is set.
|
||||
|
||||
A channel context searches only that channel and ignores `channel_ids`, `cursor`, and `include_nsfw`. When the caller cannot read the channel's history and the guild defines no message history cutoff, the search returns an empty result.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user