mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(settings): honor disabled Prowlarr seed limits
Keep disabled Prowlarr limits from being reapplied through tracker metadata, cached releases, or stale source rows after a failed synchronization. Cover policy changes, synchronization, both torrent transports, client defaults, and the PostgreSQL join with regression tests. Closes #1625
This commit is contained in:
@@ -100,6 +100,23 @@ describe('QbittorrentAdapter', () => {
|
||||
expect(form.get('category')).toBe('bookorbit');
|
||||
});
|
||||
|
||||
it.each(['magnet', 'torrent'] as const)('leaves the client seed defaults untouched when adding a %s without goals', async (transport) => {
|
||||
const { calls } = mockFetch();
|
||||
await adapter.add(
|
||||
{
|
||||
clientKey: INFO_HASH,
|
||||
...(transport === 'magnet' ? { magnet: `magnet:?xt=urn:btih:${INFO_HASH}` } : { torrentFile: Buffer.from('d4:infod4:name4:duneee') }),
|
||||
},
|
||||
config(),
|
||||
);
|
||||
|
||||
const form = calls.find((call) => call.url.includes('/torrents/add'))?.init.body as FormData;
|
||||
expect(form.get('category')).toBe('bookorbit');
|
||||
expect(form.has('ratioLimit')).toBe(false);
|
||||
expect(form.has('seedingTimeLimit')).toBe(false);
|
||||
expect(calls.some((call) => call.url.includes('setShareLimits'))).toBe(false);
|
||||
});
|
||||
|
||||
it('passes seed goals through so the client, not BookOrbit, enforces them', async () => {
|
||||
const { calls } = mockFetch();
|
||||
|
||||
|
||||
@@ -760,6 +760,47 @@ describe('RequestFulfillmentService.grab from a picked release', () => {
|
||||
expect(payload).toHaveProperty('seedTimeMinutes', 90);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ transport: 'torrent', inspected: false },
|
||||
{ transport: 'torrent', inspected: true },
|
||||
{ transport: 'magnet', inspected: false },
|
||||
{ transport: 'magnet', inspected: true },
|
||||
])('omits both tracker limits for a disabled source ($transport, inspected=$inspected)', async ({ transport, inspected }) => {
|
||||
const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes());
|
||||
const resolveSeedPolicy = vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
const { service, adapter } = makeService({
|
||||
releases: {
|
||||
find: vi.fn().mockReturnValue({ ...RELEASE, seedRatioGoal: 1, seedTimeMinutes: 4320, ...(transport === 'magnet' ? { magnet: MAGNET } : {}) }),
|
||||
},
|
||||
indexers: { resolveSeedPolicy },
|
||||
indexerAdapter: { fetchTorrentFile },
|
||||
});
|
||||
|
||||
if (inspected) await service.inspectRelease(7, { indexerId: 9, releaseGuid: 'r-1' });
|
||||
resolveSeedPolicy.mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
await service.grab(7, { indexerId: 9, releaseGuid: 'r-1' }, user());
|
||||
|
||||
expect(resolveSeedPolicy).toHaveBeenCalledExactlyOnceWith(9);
|
||||
expect(adapter.add).toHaveBeenCalledTimes(1);
|
||||
expect(adapter.add.mock.calls[0]?.[0]).not.toHaveProperty('seedRatioGoal');
|
||||
expect(adapter.add.mock.calls[0]?.[0]).not.toHaveProperty('seedTimeMinutes');
|
||||
expect(fetchTorrentFile).toHaveBeenCalledTimes(transport === 'torrent' ? 1 : 0);
|
||||
});
|
||||
|
||||
it('uses current policy after an inspection cache hit without fetching bytes again', async () => {
|
||||
const fetchTorrentFile = vi.fn().mockResolvedValue(torrentBytes());
|
||||
const resolveSeedPolicy = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -54,6 +54,8 @@ function makeService(
|
||||
findAssignedColors: vi.fn().mockResolvedValue([null]),
|
||||
findById: vi.fn().mockResolvedValue(indexerRow()),
|
||||
findSeedPolicyById: vi.fn().mockResolvedValue({
|
||||
managerId: null,
|
||||
inheritSeedLimits: null,
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
applyTrackerSeedGoals: true,
|
||||
@@ -289,6 +291,102 @@ describe('IndexerConfigService', () => {
|
||||
expect(credentials.decrypt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: 'disabled manager with stale inherited values',
|
||||
managerId: 3,
|
||||
inheritSeedLimits: false,
|
||||
applyTrackerSeedGoals: true,
|
||||
expectedTracker: false,
|
||||
expectedRatio: null,
|
||||
expectedTime: null,
|
||||
},
|
||||
{
|
||||
name: 'enabled manager with a stale disabled source',
|
||||
managerId: 3,
|
||||
inheritSeedLimits: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
expectedTracker: true,
|
||||
expectedRatio: 1,
|
||||
expectedTime: 4320,
|
||||
},
|
||||
{
|
||||
name: 'missing manager',
|
||||
managerId: 3,
|
||||
inheritSeedLimits: null,
|
||||
applyTrackerSeedGoals: true,
|
||||
expectedTracker: false,
|
||||
expectedRatio: null,
|
||||
expectedTime: null,
|
||||
},
|
||||
{
|
||||
name: 'standalone source with tracker fallback',
|
||||
managerId: null,
|
||||
inheritSeedLimits: null,
|
||||
applyTrackerSeedGoals: true,
|
||||
expectedTracker: true,
|
||||
expectedRatio: 1,
|
||||
expectedTime: 4320,
|
||||
},
|
||||
{
|
||||
name: 'standalone source with manual overrides only',
|
||||
managerId: null,
|
||||
inheritSeedLimits: null,
|
||||
applyTrackerSeedGoals: false,
|
||||
expectedTracker: false,
|
||||
expectedRatio: 1,
|
||||
expectedTime: 4320,
|
||||
},
|
||||
])(
|
||||
'resolves seed policy for a $name',
|
||||
async ({ managerId, inheritSeedLimits, applyTrackerSeedGoals, expectedTracker, expectedRatio, expectedTime }) => {
|
||||
const { service, credentials, managers } = makeService({
|
||||
repo: {
|
||||
findSeedPolicyById: vi.fn().mockResolvedValue({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
managerId,
|
||||
inheritSeedLimits,
|
||||
applyTrackerSeedGoals,
|
||||
seedRatioGoal: 1,
|
||||
seedTimeMinutes: 4320,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.resolveSeedPolicy(9)).resolves.toEqual({
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: expectedTracker,
|
||||
seedRatioGoal: expectedRatio,
|
||||
seedTimeMinutes: expectedTime,
|
||||
});
|
||||
expect(credentials.decrypt).not.toHaveBeenCalled();
|
||||
expect(managers.findById).not.toHaveBeenCalled();
|
||||
expect(managers.findRowById).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('uses the current manager toggle on each grab without waiting for a source synchronization', async () => {
|
||||
const row = {
|
||||
id: 9,
|
||||
adapterType: 'torznab',
|
||||
managerId: 3,
|
||||
inheritSeedLimits: true,
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: 1,
|
||||
seedTimeMinutes: 4320,
|
||||
};
|
||||
const { service } = makeService({ repo: { findSeedPolicyById: vi.fn().mockImplementation(() => Promise.resolve({ ...row })) } });
|
||||
|
||||
await expect(service.resolveSeedPolicy(9)).resolves.toMatchObject({ applyTrackerSeedGoals: true, seedRatioGoal: 1, seedTimeMinutes: 4320 });
|
||||
row.inheritSeedLimits = false;
|
||||
await expect(service.resolveSeedPolicy(9)).resolves.toMatchObject({ applyTrackerSeedGoals: false, seedRatioGoal: null, seedTimeMinutes: null });
|
||||
row.inheritSeedLimits = true;
|
||||
await expect(service.resolveSeedPolicy(9)).resolves.toMatchObject({ applyTrackerSeedGoals: true, seedRatioGoal: 1, seedTimeMinutes: 4320 });
|
||||
});
|
||||
|
||||
it('assigns a random unused color when a new indexer does not name one', async () => {
|
||||
const existing = INDEXER_COLORS.filter((color) => color !== 'teal');
|
||||
const { service, repo } = makeService({ repo: { findAssignedColors: vi.fn().mockResolvedValue(existing) } });
|
||||
|
||||
@@ -252,10 +252,16 @@ export class IndexerConfigService {
|
||||
const row = await this.repo.findSeedPolicyById(id);
|
||||
if (!row) throw new NotFoundException('Indexer not found');
|
||||
this.registry.require(row.adapterType);
|
||||
// Read the manager toggle at grab time even if synchronizing its source rows failed.
|
||||
const managed = row.managerId !== null;
|
||||
const inheritSeedLimits = !managed || row.inheritSeedLimits === true;
|
||||
return {
|
||||
...row,
|
||||
id: row.id,
|
||||
adapterType: row.adapterType as IndexerAdapterType,
|
||||
seedsBack: this.registry.seedsBack(row.adapterType),
|
||||
applyTrackerSeedGoals: managed ? inheritSeedLimits : row.applyTrackerSeedGoals,
|
||||
seedRatioGoal: inheritSeedLimits ? row.seedRatioGoal : null,
|
||||
seedTimeMinutes: inheritSeedLimits ? row.seedTimeMinutes : null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -73,4 +73,61 @@ describe('IndexerManagerRepository', () => {
|
||||
expect(updates.filter((update) => 'color' in update).map((update) => update.color)).toEqual(['orange', 'blue']);
|
||||
expect(inserts).toEqual([expect.objectContaining({ managerExternalId: '3', color: 'indigo' })]);
|
||||
});
|
||||
it.each([
|
||||
{ protocol: 'torrent' as const, inheritSeedLimits: false, expectedTracker: false, expectedRatio: null, expectedTime: null },
|
||||
{ protocol: 'torrent' as const, inheritSeedLimits: true, expectedTracker: true, expectedRatio: 1, expectedTime: 4320 },
|
||||
{ protocol: 'usenet' as const, inheritSeedLimits: false, expectedTracker: true, expectedRatio: null, expectedTime: null },
|
||||
{ protocol: 'usenet' as const, inheritSeedLimits: true, expectedTracker: true, expectedRatio: null, expectedTime: null },
|
||||
])(
|
||||
'synchronizes new and existing $protocol sources with inheritance=$inheritSeedLimits',
|
||||
async ({ protocol, inheritSeedLimits, expectedTracker, expectedRatio, expectedTime }) => {
|
||||
const existing = [{ ...source(1, 'blue'), applyTrackerSeedGoals: !expectedTracker, seedRatioGoal: 7, seedTimeMinutes: 120 }];
|
||||
const sourceWrites: Array<Partial<RequestIndexerRow>> = [];
|
||||
const tx = {
|
||||
select: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce({ from: () => ({ where: () => Promise.resolve(existing) }) })
|
||||
.mockReturnValueOnce({ from: () => Promise.resolve(existing) }),
|
||||
update: vi.fn(() => ({
|
||||
set: (data: Partial<RequestIndexerRow>) => {
|
||||
if ('adapterType' in data) sourceWrites.push(data);
|
||||
return { where: () => Promise.resolve() };
|
||||
},
|
||||
})),
|
||||
insert: vi.fn(() => ({
|
||||
values: (data: Partial<RequestIndexerRow>) => {
|
||||
sourceWrites.push(data);
|
||||
return Promise.resolve();
|
||||
},
|
||||
})),
|
||||
};
|
||||
const db = { transaction: (run: (value: typeof tx) => unknown) => run(tx) };
|
||||
const repository = new IndexerManagerRepository(db as never);
|
||||
await expect(
|
||||
repository.syncProwlarrSources(
|
||||
{
|
||||
id: 3,
|
||||
enabled: true,
|
||||
baseUrl: 'http://127.0.0.1:9696',
|
||||
allowPrivateAddress: true,
|
||||
inheritSeedLimits,
|
||||
syncNewIndexers: true,
|
||||
networkProfile: null,
|
||||
} as RequestIndexerManagerRow,
|
||||
[1, 2].map((id) => ({ ...discovered(id), protocol, seedRatioGoal: 1, seedTimeMinutes: 4320 })),
|
||||
categories,
|
||||
),
|
||||
).resolves.toEqual({ created: 1, updated: 1, unavailable: 0 });
|
||||
|
||||
expect(sourceWrites).toHaveLength(2);
|
||||
for (const write of sourceWrites) {
|
||||
expect(write).toMatchObject({
|
||||
adapterType: protocol === 'torrent' ? 'torznab' : 'newznab',
|
||||
applyTrackerSeedGoals: expectedTracker,
|
||||
seedRatioGoal: expectedRatio,
|
||||
seedTimeMinutes: expectedTime,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -159,6 +159,7 @@ export class IndexerManagerRepository {
|
||||
allowPrivateAddress: manager.allowPrivateAddress,
|
||||
networkProfile: manager.networkProfile,
|
||||
categories: categories[source.protocol],
|
||||
applyTrackerSeedGoals: source.protocol !== 'torrent' || manager.inheritSeedLimits,
|
||||
seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null,
|
||||
seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null,
|
||||
})
|
||||
@@ -180,7 +181,7 @@ export class IndexerManagerRepository {
|
||||
baseUrl,
|
||||
credentialsEnc: null,
|
||||
allowPrivateAddress: manager.allowPrivateAddress,
|
||||
applyTrackerSeedGoals: true,
|
||||
applyTrackerSeedGoals: source.protocol !== 'torrent' || manager.inheritSeedLimits,
|
||||
seedRatioGoal: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedRatioGoal : null,
|
||||
seedTimeMinutes: manager.inheritSeedLimits && source.protocol === 'torrent' ? source.seedTimeMinutes : null,
|
||||
categories: categories[source.protocol],
|
||||
|
||||
@@ -163,4 +163,14 @@ describe('IndexerManagerService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a disabled seed-limit toggle saved when its source synchronization fails', async () => {
|
||||
const { service, repo, prowlarr } = makeService();
|
||||
prowlarr.indexers.mockRejectedValueOnce(new Error('Prowlarr unavailable'));
|
||||
|
||||
await expect(service.update(3, { inheritSeedLimits: false })).resolves.toMatchObject({ inheritSeedLimits: false });
|
||||
expect(repo.update).toHaveBeenCalledWith(3, { inheritSeedLimits: false });
|
||||
expect(repo.recordSyncFailure).toHaveBeenCalledWith(3, 'Prowlarr unavailable');
|
||||
expect(repo.syncProwlarrSources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,6 +23,18 @@ describe('IndexerRepository', () => {
|
||||
expect(queries[0].params).toEqual([3, 8]);
|
||||
});
|
||||
|
||||
it('reads the source and current manager seed policy in one bounded query without credentials', async () => {
|
||||
const { db, queries } = createCapturingDb();
|
||||
await new IndexerRepository(db).findSeedPolicyById(9);
|
||||
|
||||
expect(queries).toHaveLength(1);
|
||||
expect(queries[0].sql).toContain('left join "request_indexer_managers" on "request_indexers"."manager_id" = "request_indexer_managers"."id"');
|
||||
expect(queries[0].sql).toContain('"request_indexer_managers"."inherit_seed_limits"');
|
||||
expect(queries[0].sql).toMatch(/where "request_indexers"\."id" = \$1 limit \$2/);
|
||||
expect(queries[0].sql).not.toContain('credentials_enc');
|
||||
expect(queries[0].params).toEqual([9, 1]);
|
||||
});
|
||||
|
||||
it('deletes every indexer with the requested adapter type in one query', async () => {
|
||||
const { db, queries } = createCapturingDb();
|
||||
const repository = new IndexerRepository(db);
|
||||
|
||||
@@ -6,13 +6,15 @@ import type { BookRequestSourceStatus, IndexerColor } from '@bookorbit/types';
|
||||
|
||||
import { DB } from '../../../db';
|
||||
import * as schema from '../../../db/schema';
|
||||
import { requestIndexers, type NewRequestIndexerRow, type RequestIndexerRow } from '../../../db/schema';
|
||||
import { requestIndexerManagers, requestIndexers, type NewRequestIndexerRow, type RequestIndexerRow } from '../../../db/schema';
|
||||
|
||||
type Db = NodePgDatabase<typeof schema>;
|
||||
|
||||
export interface IndexerSeedPolicyRow {
|
||||
id: number;
|
||||
adapterType: string;
|
||||
managerId: number | null;
|
||||
inheritSeedLimits: boolean | null;
|
||||
applyTrackerSeedGoals: boolean;
|
||||
seedRatioGoal: number | null;
|
||||
seedTimeMinutes: number | null;
|
||||
@@ -46,11 +48,14 @@ export class IndexerRepository {
|
||||
.select({
|
||||
id: requestIndexers.id,
|
||||
adapterType: requestIndexers.adapterType,
|
||||
managerId: requestIndexers.managerId,
|
||||
inheritSeedLimits: requestIndexerManagers.inheritSeedLimits,
|
||||
applyTrackerSeedGoals: requestIndexers.applyTrackerSeedGoals,
|
||||
seedRatioGoal: requestIndexers.seedRatioGoal,
|
||||
seedTimeMinutes: requestIndexers.seedTimeMinutes,
|
||||
})
|
||||
.from(requestIndexers)
|
||||
.leftJoin(requestIndexerManagers, eq(requestIndexers.managerId, requestIndexerManagers.id))
|
||||
.where(eq(requestIndexers.id, id))
|
||||
.limit(1);
|
||||
return row;
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
} from '@bookorbit/types';
|
||||
|
||||
import * as schema from '../src/db/schema';
|
||||
import { IndexerConfigService } from '../src/modules/book-request/indexers/indexer-config.service';
|
||||
import {
|
||||
authHeader,
|
||||
closeAuthorizationMatrixE2EContext,
|
||||
@@ -1085,4 +1086,99 @@ describe('Book requests (e2e)', () => {
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
describe('managed source seed policy', () => {
|
||||
it(
|
||||
'honors the current manager toggle over unsynchronized legacy source rows',
|
||||
async () => {
|
||||
const [manager] = await ctx.db
|
||||
.insert(schema.requestIndexerManagers)
|
||||
.values({
|
||||
name: `seed-policy-manager-${randomUUID()}`,
|
||||
type: 'prowlarr',
|
||||
baseUrl: 'http://127.0.0.1:9696',
|
||||
credentialsEnc: 'unused-fixture-credential',
|
||||
inheritSeedLimits: true,
|
||||
})
|
||||
.returning();
|
||||
try {
|
||||
const [source] = await ctx.db
|
||||
.insert(schema.requestIndexers)
|
||||
.values({
|
||||
name: `seed-policy-source-${randomUUID()}`,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://127.0.0.1:9696/1',
|
||||
managerId: manager.id,
|
||||
managerExternalId: '1',
|
||||
managerMetadata: { displayName: 'Seed policy fixture', implementation: 'Torznab', protocol: 'torrent', priority: 25 },
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: 1,
|
||||
seedTimeMinutes: 4320,
|
||||
})
|
||||
.returning();
|
||||
const service = ctx.app.get(IndexerConfigService);
|
||||
await expect(service.resolveSeedPolicy(source.id)).resolves.toMatchObject({
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: 1,
|
||||
seedTimeMinutes: 4320,
|
||||
});
|
||||
|
||||
await ctx.db
|
||||
.update(schema.requestIndexerManagers)
|
||||
.set({ inheritSeedLimits: false })
|
||||
.where(eq(schema.requestIndexerManagers.id, manager.id));
|
||||
await expect(service.resolveSeedPolicy(source.id)).resolves.toEqual({
|
||||
id: source.id,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: null,
|
||||
seedTimeMinutes: null,
|
||||
});
|
||||
const [unchanged] = await ctx.db.select().from(schema.requestIndexers).where(eq(schema.requestIndexers.id, source.id));
|
||||
expect(unchanged).toMatchObject({ applyTrackerSeedGoals: true, seedRatioGoal: 1, seedTimeMinutes: 4320 });
|
||||
|
||||
await ctx.db.update(schema.requestIndexers).set({ applyTrackerSeedGoals: false }).where(eq(schema.requestIndexers.id, source.id));
|
||||
await ctx.db.update(schema.requestIndexerManagers).set({ inheritSeedLimits: true }).where(eq(schema.requestIndexerManagers.id, manager.id));
|
||||
await expect(service.resolveSeedPolicy(source.id)).resolves.toMatchObject({
|
||||
applyTrackerSeedGoals: true,
|
||||
seedRatioGoal: 1,
|
||||
seedTimeMinutes: 4320,
|
||||
});
|
||||
} finally {
|
||||
await ctx.db.delete(schema.requestIndexerManagers).where(eq(schema.requestIndexerManagers.id, manager.id));
|
||||
}
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
it(
|
||||
'preserves standalone manual goals when the manager join has no row',
|
||||
async () => {
|
||||
const [source] = await ctx.db
|
||||
.insert(schema.requestIndexers)
|
||||
.values({
|
||||
name: `standalone-seed-policy-${randomUUID()}`,
|
||||
adapterType: 'torznab',
|
||||
baseUrl: 'http://127.0.0.1:9117',
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 2,
|
||||
seedTimeMinutes: 90,
|
||||
})
|
||||
.returning();
|
||||
try {
|
||||
await expect(ctx.app.get(IndexerConfigService).resolveSeedPolicy(source.id)).resolves.toEqual({
|
||||
id: source.id,
|
||||
adapterType: 'torznab',
|
||||
seedsBack: true,
|
||||
applyTrackerSeedGoals: false,
|
||||
seedRatioGoal: 2,
|
||||
seedTimeMinutes: 90,
|
||||
});
|
||||
} finally {
|
||||
await ctx.db.delete(schema.requestIndexers).where(eq(schema.requestIndexers.id, source.id));
|
||||
}
|
||||
},
|
||||
SCENARIO_TIMEOUT_MS,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user