Disable unavailable subreddits after Reddit 404
This commit is contained in:
1 parent
664aa5c098
commit
dcf57273e2
13 files changed
+153
-12
No files matched your search
@@ -9,6 +9,7 @@ import {
|
|||||||
|
|
||||||
export interface SubredditDraft {
|
export interface SubredditDraft {
|
||||||
name: string;
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
type: 'image' | 'chat';
|
type: 'image' | 'chat';
|
||||||
weight: string;
|
weight: string;
|
||||||
tone: z.infer<typeof ToneKeySchema>;
|
tone: z.infer<typeof ToneKeySchema>;
|
||||||
@@ -67,6 +68,7 @@ export function createSettingsDraft(config: ConfigResponse): SettingsDraft {
|
|||||||
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
recentWindowMinutes: String(config.reddit.recentWindowMinutes),
|
||||||
subreddits: config.subreddits.map((subreddit) => ({
|
subreddits: config.subreddits.map((subreddit) => ({
|
||||||
name: subreddit.name,
|
name: subreddit.name,
|
||||||
|
enabled: subreddit.enabled,
|
||||||
type: subreddit.type,
|
type: subreddit.type,
|
||||||
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
|
weight: subreddit.weight === undefined ? '' : String(subreddit.weight),
|
||||||
tone: subreddit.tone,
|
tone: subreddit.tone,
|
||||||
@@ -112,6 +114,7 @@ export function validateSettingsDraft(draft: SettingsDraft): SettingsValidationR
|
|||||||
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
recentWindowMinutes: Number(draft.recentWindowMinutes),
|
||||||
subreddits: draft.subreddits.map((subreddit) => ({
|
subreddits: draft.subreddits.map((subreddit) => ({
|
||||||
name: subreddit.name.trim(),
|
name: subreddit.name.trim(),
|
||||||
|
enabled: subreddit.enabled,
|
||||||
type: subreddit.type,
|
type: subreddit.type,
|
||||||
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
|
weight: subreddit.weight.trim() === '' ? undefined : Number(subreddit.weight),
|
||||||
tone: subreddit.tone,
|
tone: subreddit.tone,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
...draft,
|
...draft,
|
||||||
subreddits: [
|
subreddits: [
|
||||||
...draft.subreddits,
|
...draft.subreddits,
|
||||||
{ name: '', type: 'image', weight: '1', tone: 'default', scripted: '' },
|
{ name: '', enabled: true, type: 'image', weight: '1', tone: 'default', scripted: '' },
|
||||||
],
|
],
|
||||||
})}
|
})}
|
||||||
type="button"
|
type="button"
|
||||||
@@ -157,6 +157,15 @@ export function BehaviourSection({ config, draft, errors, onChange }: SectionPro
|
|||||||
<option value="image">Image</option><option value="chat">Chat</option>
|
<option value="image">Image</option><option value="chat">Chat</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="toggle-field">
|
||||||
|
<input
|
||||||
|
aria-label={`Subreddit ${index + 1} enabled`}
|
||||||
|
checked={subreddit.enabled}
|
||||||
|
onChange={(event) => onChange(updateSubreddit(draft, index, { ...subreddit, enabled: event.target.checked }))}
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
<span><strong>Enabled</strong><small>Disabled subreddits are not polled.</small></span>
|
||||||
|
</label>
|
||||||
<label className="settings-field">
|
<label className="settings-field">
|
||||||
<span>Weight</span>
|
<span>Weight</span>
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const RuntimeConfigSchema = z.strictObject({
|
|||||||
});
|
});
|
||||||
export const SubredditConfigSchema = z.strictObject({
|
export const SubredditConfigSchema = z.strictObject({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
type: z.union([z.literal('image'), z.literal('chat')]),
|
type: z.union([z.literal('image'), z.literal('chat')]),
|
||||||
weight: z.number().optional(),
|
weight: z.number().optional(),
|
||||||
tone: ToneKeySchema,
|
tone: ToneKeySchema,
|
||||||
|
|||||||
+5
-1
@@ -49,6 +49,7 @@ import { RedditAccountRuntimeManager } from './services/reddit-account-runtime.j
|
|||||||
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
|
import { createConcreteRedditAccountRuntimeFactory } from './services/reddit-account-runtime-builder.js';
|
||||||
import { importLegacyAccountContent } from './database/legacy-content-import.js';
|
import { importLegacyAccountContent } from './database/legacy-content-import.js';
|
||||||
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
import { RecipientDeduplicator } from './services/recipient-deduplicator.js';
|
||||||
|
import { createConfigBackedSubredditPollingControl } from './services/subreddit-polling-control.js';
|
||||||
|
|
||||||
export interface AppContainer {
|
export interface AppContainer {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -168,7 +169,10 @@ export async function bootstrap(
|
|||||||
|
|
||||||
// AI + services
|
// AI + services
|
||||||
const aiClient = new OpenRouterClient(config, logger);
|
const aiClient = new OpenRouterClient(config, logger);
|
||||||
const monitorService = new MonitorService(redditApi, postsRepo, recipientDeduplicator, config, logger);
|
const monitorService = new MonitorService(
|
||||||
|
redditApi, postsRepo, recipientDeduplicator, config, logger, 1000,
|
||||||
|
createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||||
|
);
|
||||||
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
|
const processorService = new ProcessorService(redditApi, aiClient, config, logger);
|
||||||
const strategySelector = new StrategySelector(config, messagesRepo, logger);
|
const strategySelector = new StrategySelector(config, messagesRepo, logger);
|
||||||
processorService.setStrategySelector(strategySelector);
|
processorService.setStrategySelector(strategySelector);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dotenv.config();
|
|||||||
|
|
||||||
export const SubredditConfigSchema = z.object({
|
export const SubredditConfigSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
type: z.enum(['image', 'chat']).default('image'),
|
type: z.enum(['image', 'chat']).default('image'),
|
||||||
weight: z.number().positive().optional(),
|
weight: z.number().positive().optional(),
|
||||||
tone: z.enum(TONE_KEYS).default('default'),
|
tone: z.enum(TONE_KEYS).default('default'),
|
||||||
|
|||||||
+9
-2
@@ -24,6 +24,14 @@ export class RedditAccessDeniedError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Reddit confirms the community listing is gone and must no longer be polled. */
|
||||||
|
export class RedditSubredditUnavailableError extends Error {
|
||||||
|
constructor(public readonly subreddit: string) {
|
||||||
|
super(`Reddit reports that r/${subreddit} is unavailable (HTTP 404)`);
|
||||||
|
this.name = 'RedditSubredditUnavailableError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getRateLimitDelayMs(headers: unknown): number {
|
function getRateLimitDelayMs(headers: unknown): number {
|
||||||
if (!headers || typeof headers !== 'object') return 60_000;
|
if (!headers || typeof headers !== 'object') return 60_000;
|
||||||
|
|
||||||
@@ -198,8 +206,7 @@ export class RedditApiClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (axios.isAxiosError(error)) {
|
if (axios.isAxiosError(error)) {
|
||||||
if (error.response?.status === 404) {
|
if (error.response?.status === 404) {
|
||||||
this.logger.warn(`Subreddit r/${subreddit} not found`);
|
throw new RedditSubredditUnavailableError(subreddit);
|
||||||
return [];
|
|
||||||
}
|
}
|
||||||
if (error.response?.status === 403) {
|
if (error.response?.status === 403) {
|
||||||
this.consecutiveListing403s += 1;
|
this.consecutiveListing403s += 1;
|
||||||
|
|||||||
+25
-3
@@ -1,4 +1,9 @@
|
|||||||
import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../reddit/api.js';
|
import {
|
||||||
|
RedditRateLimitError,
|
||||||
|
RedditSubredditUnavailableError,
|
||||||
|
type RedditApiClient,
|
||||||
|
type RedditPost,
|
||||||
|
} from '../reddit/api.js';
|
||||||
import type { PostsRepository } from '../database/repositories.js';
|
import type { PostsRepository } from '../database/repositories.js';
|
||||||
import type { Config } from '../config/index.js';
|
import type { Config } from '../config/index.js';
|
||||||
import type { Logger } from '../utils/logger.js';
|
import type { Logger } from '../utils/logger.js';
|
||||||
@@ -17,6 +22,12 @@ interface ListingScreeningSummary {
|
|||||||
deferredReason: 'selected-recent-candidate' | 'older-than-fallback' | null;
|
deferredReason: 'selected-recent-candidate' | 'older-than-fallback' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Coordinates permanent polling disablement across account runtimes. */
|
||||||
|
export interface SubredditPollingControl {
|
||||||
|
disableUnavailableSubreddit(subreddit: string): void;
|
||||||
|
isSubredditEnabled(subreddit: string): boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class MonitorService {
|
export class MonitorService {
|
||||||
// Flips on each call so that when both pools have candidates the bot
|
// Flips on each call so that when both pools have candidates the bot
|
||||||
// alternates between image and chat instead of always favouring whichever
|
// alternates between image and chat instead of always favouring whichever
|
||||||
@@ -30,6 +41,7 @@ export class MonitorService {
|
|||||||
private config: Config,
|
private config: Config,
|
||||||
private logger: Logger,
|
private logger: Logger,
|
||||||
private delayBetweenFetchesMs: number = 1000,
|
private delayBetweenFetchesMs: number = 1000,
|
||||||
|
private readonly pollingControl?: SubredditPollingControl,
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
async getUnprocessedPosts(): Promise<RedditPost[]> {
|
async getUnprocessedPosts(): Promise<RedditPost[]> {
|
||||||
@@ -151,6 +163,11 @@ export class MonitorService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RedditRateLimitError) throw error;
|
if (error instanceof RedditRateLimitError) throw error;
|
||||||
|
if (error instanceof RedditSubredditUnavailableError) {
|
||||||
|
this.pollingControl?.disableUnavailableSubreddit(subreddit.name);
|
||||||
|
this.logger.warn(`Disabled r/${subreddit.name}: Reddit returned HTTP 404`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
this.logger.error(`Failed to fetch posts from r/${subreddit.name}: ${errorMessage}`);
|
this.logger.error(`Failed to fetch posts from r/${subreddit.name}: ${errorMessage}`);
|
||||||
@@ -182,10 +199,10 @@ export class MonitorService {
|
|||||||
*/
|
*/
|
||||||
private buildCheckOrder(): Config['reddit']['subreddits'] {
|
private buildCheckOrder(): Config['reddit']['subreddits'] {
|
||||||
const images = this.weightedShuffle(
|
const images = this.weightedShuffle(
|
||||||
this.config.reddit.subreddits.filter((s) => s.type === 'image'),
|
this.config.reddit.subreddits.filter((s) => s.type === 'image' && this.isSubredditEnabled(s.name)),
|
||||||
);
|
);
|
||||||
const chats = this.weightedShuffle(
|
const chats = this.weightedShuffle(
|
||||||
this.config.reddit.subreddits.filter((s) => s.type === 'chat'),
|
this.config.reddit.subreddits.filter((s) => s.type === 'chat' && this.isSubredditEnabled(s.name)),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [a, b] = this.chatGoesFirst ? [chats, images] : [images, chats];
|
const [a, b] = this.chatGoesFirst ? [chats, images] : [images, chats];
|
||||||
@@ -200,6 +217,11 @@ export class MonitorService {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSubredditEnabled(subreddit: string): boolean {
|
||||||
|
const local = this.config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase());
|
||||||
|
return local?.enabled !== false && (this.pollingControl?.isSubredditEnabled(subreddit) ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
// Weighted random ordering (Efraimidis–Spirakis): key = random^(1/weight),
|
// Weighted random ordering (Efraimidis–Spirakis): key = random^(1/weight),
|
||||||
// sorted descending. Heavier-weighted subs are more likely to land earlier
|
// sorted descending. Heavier-weighted subs are more likely to land earlier
|
||||||
// each cycle, while every sub still has a non-zero chance of any position.
|
// each cycle, while every sub still has a non-zero chance of any position.
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ import {
|
|||||||
type VerifiedAccountSession,
|
type VerifiedAccountSession,
|
||||||
} from './reddit-account-runtime.js';
|
} from './reddit-account-runtime.js';
|
||||||
import { RecipientDeduplicator } from './recipient-deduplicator.js';
|
import { RecipientDeduplicator } from './recipient-deduplicator.js';
|
||||||
|
import { ConfigStore } from '../config/persistence.js';
|
||||||
|
import { createConfigBackedSubredditPollingControl } from './subreddit-polling-control.js';
|
||||||
|
|
||||||
export interface ConcreteRuntimeFactoryOptions {
|
export interface ConcreteRuntimeFactoryOptions {
|
||||||
config: Config;
|
config: Config;
|
||||||
@@ -116,6 +118,7 @@ async function buildRuntime(
|
|||||||
const aiClient = new OpenRouterClient(accountConfig, logger);
|
const aiClient = new OpenRouterClient(accountConfig, logger);
|
||||||
const monitorService = new MonitorService(
|
const monitorService = new MonitorService(
|
||||||
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
|
redditApi, postsRepo, recipientDeduplicator, accountConfig, logger,
|
||||||
|
1000, createConfigBackedSubredditPollingControl(config, new ConfigStore(db), logger),
|
||||||
);
|
);
|
||||||
const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger);
|
const processorService = new ProcessorService(redditApi, aiClient, accountConfig, logger);
|
||||||
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
|
processorService.setStrategySelector(new StrategySelector(accountConfig, messagesRepo, logger));
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import type { Config } from '../config/index.js';
|
||||||
|
import type { ConfigPersistence } from '../config/index.js';
|
||||||
|
import type { Logger } from '../utils/logger.js';
|
||||||
|
import type { SubredditPollingControl } from './monitor.js';
|
||||||
|
|
||||||
|
/** Persists a terminal Reddit listing failure so every account stops polling it. */
|
||||||
|
export function createConfigBackedSubredditPollingControl(
|
||||||
|
config: Config,
|
||||||
|
configStore: ConfigPersistence,
|
||||||
|
logger: Logger,
|
||||||
|
): SubredditPollingControl {
|
||||||
|
return {
|
||||||
|
disableUnavailableSubreddit(subreddit: string): void {
|
||||||
|
const entry = config.reddit.subreddits.find((candidate) => candidate.name.toLowerCase() === subreddit.toLowerCase());
|
||||||
|
if (!entry || entry.enabled === false) return;
|
||||||
|
|
||||||
|
entry.enabled = false;
|
||||||
|
configStore.save(config);
|
||||||
|
logger.warn(`Persisted disabled status for unavailable subreddit r/${entry.name}`);
|
||||||
|
},
|
||||||
|
isSubredditEnabled(subreddit: string): boolean {
|
||||||
|
return config.reddit.subreddits.find((entry) => entry.name.toLowerCase() === subreddit.toLowerCase())?.enabled !== false;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -49,6 +49,12 @@ describe('ConfigSchema', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('enables subreddits by default', () => {
|
||||||
|
const result = ConfigSchema.safeParse(minimalConfig({ reddit: { subreddits: ['test'] } }));
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) expect(result.data.reddit.subreddits[0].enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('accepts valid tone values', () => {
|
it('accepts valid tone values', () => {
|
||||||
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
|
for (const tone of ['default', 'dominant', 'submissive', 'playful', 'direct', 'gentle', 'kink-friendly', 'teasing']) {
|
||||||
const result = ConfigSchema.safeParse({
|
const result = ConfigSchema.safeParse({
|
||||||
@@ -344,7 +350,7 @@ openrouter:
|
|||||||
`);
|
`);
|
||||||
|
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', type: 'image', tone: 'default' }]);
|
expect(config.reddit.subreddits).toEqual([{ name: 'test-sub', enabled: true, type: 'image', tone: 'default' }]);
|
||||||
expect(config.reddit.pollIntervalMs).toBe(5000);
|
expect(config.reddit.pollIntervalMs).toBe(5000);
|
||||||
expect(config.openrouter.apiKey).toBe('yaml-key');
|
expect(config.openrouter.apiKey).toBe('yaml-key');
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-1
@@ -1,6 +1,11 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
import { MonitorService } from '../src/services/monitor.js';
|
import { MonitorService } from '../src/services/monitor.js';
|
||||||
import { RedditRateLimitError, type RedditApiClient, type RedditPost } from '../src/reddit/api.js';
|
import {
|
||||||
|
RedditRateLimitError,
|
||||||
|
RedditSubredditUnavailableError,
|
||||||
|
type RedditApiClient,
|
||||||
|
type RedditPost,
|
||||||
|
} from '../src/reddit/api.js';
|
||||||
import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js';
|
import type { PostsRepository, MessagesRepository } from '../src/database/repositories.js';
|
||||||
import { createMockLogger } from '../tests/mockLogger.js';
|
import { createMockLogger } from '../tests/mockLogger.js';
|
||||||
import { getTestConfig } from '../tests/testUtils.js';
|
import { getTestConfig } from '../tests/testUtils.js';
|
||||||
@@ -87,6 +92,25 @@ describe('MonitorService', () => {
|
|||||||
expect(result.length).toBe(0);
|
expect(result.length).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('disables a subreddit when Reddit returns a terminal listing 404', async () => {
|
||||||
|
const redditApi = createMockRedditApi();
|
||||||
|
vi.mocked(redditApi.getNewPosts).mockRejectedValueOnce(new RedditSubredditUnavailableError('banned-sub'));
|
||||||
|
const pollingControl = {
|
||||||
|
disableUnavailableSubreddit: vi.fn(),
|
||||||
|
isSubredditEnabled: vi.fn(() => true),
|
||||||
|
};
|
||||||
|
const config = getTestConfig({ reddit: { subreddits: [{ name: 'banned-sub', type: 'image' }] } });
|
||||||
|
monitor = new MonitorService(
|
||||||
|
redditApi, createMockPostsRepo(), createMockMessagesRepo(), config, logger, 0, pollingControl,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(monitor.getUnprocessedPosts()).resolves.toEqual([]);
|
||||||
|
expect(pollingControl.disableUnavailableSubreddit).toHaveBeenCalledWith('banned-sub');
|
||||||
|
expect(logger.logs).toContainEqual(expect.objectContaining({
|
||||||
|
level: 'warn', message: expect.stringContaining('Disabled r/banned-sub: Reddit returned HTTP 404'),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
it('uses shared post history so another account cannot rediscover a post', async () => {
|
it('uses shared post history so another account cannot rediscover a post', async () => {
|
||||||
const post: RedditPost = {
|
const post: RedditPost = {
|
||||||
id: 't3_shared', subreddit: 'sub1', author: 'user1', title: 'Post',
|
id: 't3_shared', subreddit: 'sub1', author: 'user1', title: 'Post',
|
||||||
|
|||||||
@@ -480,14 +480,29 @@ describe('RedditApiClient', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('getNewPosts', () => {
|
describe('getNewPosts', () => {
|
||||||
it('returns empty array for 404 subreddit', async () => {
|
it('throws a permanent-disable signal for a plain 404 subreddit', async () => {
|
||||||
const error = { isAxiosError: true, response: { status: 404 } };
|
const error = { isAxiosError: true, response: { status: 404 } };
|
||||||
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
||||||
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
vi.mocked(axios.create).mockReturnValue(mockAxios as any);
|
||||||
|
|
||||||
const client2 = new RedditApiClient(getTestConfig(), logger);
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||||
const result = await client2.getNewPosts('nonexistent');
|
await expect(client2.getNewPosts('nonexistent')).rejects.toMatchObject({
|
||||||
expect(result).toEqual([]);
|
name: 'RedditSubredditUnavailableError', subreddit: 'nonexistent',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws the same permanent-disable signal for Reddit banned responses', async () => {
|
||||||
|
const error = {
|
||||||
|
isAxiosError: true,
|
||||||
|
response: { status: 404, data: { reason: 'banned', message: 'Not Found', error: 404 } },
|
||||||
|
};
|
||||||
|
const mockAxios = { get: vi.fn().mockRejectedValue(error) };
|
||||||
|
vi.mocked(axios.create).mockReturnValue(mockAxios as unknown as AxiosInstance);
|
||||||
|
|
||||||
|
const client2 = new RedditApiClient(getTestConfig(), logger);
|
||||||
|
await expect(client2.getNewPosts('banned-sub')).rejects.toMatchObject({
|
||||||
|
name: 'RedditSubredditUnavailableError', subreddit: 'banned-sub',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array and explains an initial 403 access denial', async () => {
|
it('returns empty array and explains an initial 403 access denial', async () => {
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { createConfigBackedSubredditPollingControl } from '../src/services/subreddit-polling-control.js';
|
||||||
|
import { getTestConfig } from './testUtils.js';
|
||||||
|
import { createMockLogger } from './mockLogger.js';
|
||||||
|
|
||||||
|
describe('config-backed subreddit polling control', () => {
|
||||||
|
it('disables and persists only the matched subreddit', () => {
|
||||||
|
const config = getTestConfig({ reddit: { subreddits: [{ name: 'gone', type: 'image' }, { name: 'active', type: 'chat' }] } });
|
||||||
|
const store = { load: vi.fn(), save: vi.fn() };
|
||||||
|
const control = createConfigBackedSubredditPollingControl(config, store, createMockLogger());
|
||||||
|
|
||||||
|
control.disableUnavailableSubreddit('GONE');
|
||||||
|
|
||||||
|
expect(config.reddit.subreddits).toEqual([
|
||||||
|
expect.objectContaining({ name: 'gone', enabled: false }),
|
||||||
|
expect.objectContaining({ name: 'active' }),
|
||||||
|
]);
|
||||||
|
expect(store.save).toHaveBeenCalledWith(config);
|
||||||
|
expect(control.isSubredditEnabled('gone')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user