mirror of https://github.com/msgbyte/tailchat
feat(chat): add slow mode for group text panels
parent
dabeedc94b
commit
d2a292879a
@ -0,0 +1,117 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
getSlowModeStatus,
|
||||
useInterval,
|
||||
useSharedEventHandler,
|
||||
} from 'tailchat-shared';
|
||||
import type { GroupPanelSlowMode, SlowModeStatus } from 'tailchat-shared';
|
||||
|
||||
interface UseSlowModeStatusParams {
|
||||
groupId?: string;
|
||||
converseId: string;
|
||||
slowMode?: GroupPanelSlowMode;
|
||||
}
|
||||
|
||||
function createFallbackStatus(slowMode?: GroupPanelSlowMode): SlowModeStatus {
|
||||
if (!slowMode) {
|
||||
return { enabled: false };
|
||||
}
|
||||
|
||||
return {
|
||||
enabled: true,
|
||||
bypassed: false,
|
||||
...slowMode,
|
||||
remaining: slowMode.maxMessages,
|
||||
};
|
||||
}
|
||||
|
||||
export function useSlowModeStatus({
|
||||
groupId,
|
||||
converseId,
|
||||
slowMode,
|
||||
}: UseSlowModeStatusParams) {
|
||||
const [status, setStatus] = useState<SlowModeStatus>(() =>
|
||||
createFallbackStatus(slowMode)
|
||||
);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!groupId || !slowMode) {
|
||||
setStatus({ enabled: false });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setStatus(await getSlowModeStatus(groupId, converseId));
|
||||
setNow(Date.now());
|
||||
} catch {
|
||||
setStatus((current) =>
|
||||
current.enabled ? current : createFallbackStatus(slowMode)
|
||||
);
|
||||
}
|
||||
}, [converseId, groupId, slowMode?.intervalSeconds, slowMode?.maxMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(createFallbackStatus(slowMode));
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
useSharedEventHandler('sendMessage', (payload) => {
|
||||
if (payload.converseId === converseId) {
|
||||
void refresh();
|
||||
}
|
||||
});
|
||||
|
||||
useSharedEventHandler('slowModeLimited', (payload) => {
|
||||
if (payload.converseId !== converseId || !slowMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({
|
||||
enabled: true,
|
||||
bypassed: false,
|
||||
...slowMode,
|
||||
remaining: 0,
|
||||
resetAt: payload.resetAt,
|
||||
});
|
||||
setNow(Date.now());
|
||||
});
|
||||
|
||||
const resetAt =
|
||||
status.enabled && status.resetAt
|
||||
? new Date(status.resetAt).valueOf()
|
||||
: undefined;
|
||||
const remainingMs = Math.max((resetAt ?? now) - now, 0);
|
||||
const blocked =
|
||||
status.enabled &&
|
||||
!status.bypassed &&
|
||||
status.remaining === 0 &&
|
||||
remainingMs > 0;
|
||||
const hasPendingReset =
|
||||
status.enabled && !status.bypassed && resetAt !== undefined;
|
||||
|
||||
useInterval(() => setNow(Date.now()), hasPendingReset ? 1000 : undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (status.enabled && resetAt !== undefined && now >= resetAt) {
|
||||
if (status.remaining === 0) {
|
||||
setStatus((current) =>
|
||||
current.enabled
|
||||
? {
|
||||
...current,
|
||||
remaining: 1,
|
||||
resetAt: undefined,
|
||||
}
|
||||
: current
|
||||
);
|
||||
}
|
||||
void refresh();
|
||||
}
|
||||
}, [now, refresh, resetAt, status]);
|
||||
|
||||
return {
|
||||
status,
|
||||
blocked,
|
||||
remainingMs,
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import { Select, Switch } from 'antd';
|
||||
import {
|
||||
GROUP_PANEL_SLOW_MODE_INTERVALS,
|
||||
GROUP_PANEL_SLOW_MODE_MAX_MESSAGES,
|
||||
isGroupPanelSlowMode,
|
||||
t,
|
||||
} from 'tailchat-shared';
|
||||
import type { GroupPanelSlowMode } from 'tailchat-shared';
|
||||
import { Icon } from 'tailchat-design';
|
||||
import type { FastifyFormFieldProps } from 'tailchat-design';
|
||||
|
||||
const DEFAULT_SLOW_MODE: GroupPanelSlowMode = {
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
};
|
||||
|
||||
function formatInterval(intervalSeconds: number): string {
|
||||
return t('{{minutes}} 分钟', {
|
||||
minutes: intervalSeconds / 60,
|
||||
});
|
||||
}
|
||||
|
||||
export const SlowModeSettings: React.FC<FastifyFormFieldProps> = React.memo(
|
||||
(props) => {
|
||||
const value = isGroupPanelSlowMode(props.value)
|
||||
? props.value
|
||||
: DEFAULT_SLOW_MODE;
|
||||
const enabled = isGroupPanelSlowMode(props.value);
|
||||
|
||||
const updateValue = (patch: Partial<GroupPanelSlowMode>) => {
|
||||
props.onChange({
|
||||
...value,
|
||||
...patch,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 px-4 py-3 dark:border-gray-600">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<Icon
|
||||
icon="mdi:timer-sand"
|
||||
className="mt-0.5 flex-shrink-0 text-xl text-gray-500 dark:text-gray-300"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900 dark:text-gray-100">
|
||||
{t('限制成员的发送频率')}
|
||||
</div>
|
||||
<div className="mt-0.5 text-xs leading-5 text-gray-500 dark:text-gray-300">
|
||||
{t('每位成员独立计数,系统消息和机器人不受限制')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Switch
|
||||
checked={enabled}
|
||||
aria-label={t('开启慢速模式')}
|
||||
onChange={(checked) =>
|
||||
props.onChange(checked ? DEFAULT_SLOW_MODE : undefined)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{enabled && (
|
||||
<div className="mt-4 border-t border-gray-100 pt-4 dark:border-gray-600">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-gray-700 dark:text-gray-200">
|
||||
<span>{t('每')}</span>
|
||||
<Select<number>
|
||||
value={value.intervalSeconds}
|
||||
style={{ width: 116 }}
|
||||
options={GROUP_PANEL_SLOW_MODE_INTERVALS.map((seconds) => ({
|
||||
label: formatInterval(seconds),
|
||||
value: seconds,
|
||||
}))}
|
||||
onChange={(intervalSeconds) => updateValue({ intervalSeconds })}
|
||||
/>
|
||||
<span>{t('内最多发送')}</span>
|
||||
<Select<number>
|
||||
value={value.maxMessages}
|
||||
style={{ width: 88 }}
|
||||
options={GROUP_PANEL_SLOW_MODE_MAX_MESSAGES.map((count) => ({
|
||||
label: count,
|
||||
value: count,
|
||||
}))}
|
||||
onChange={(maxMessages) => updateValue({ maxMessages })}
|
||||
/>
|
||||
<span>{t('条消息')}</span>
|
||||
</div>
|
||||
<div className="mt-2 text-xs leading-5 text-gray-500 dark:text-gray-300">
|
||||
{t('达到上限后,将从最早一条消息的发送时间开始倒计时')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
SlowModeSettings.displayName = 'SlowModeSettings';
|
||||
@ -1,7 +1,8 @@
|
||||
import type { GroupPanelType } from 'tailchat-shared';
|
||||
import type { GroupPanelSlowMode, GroupPanelType } from 'tailchat-shared';
|
||||
|
||||
export interface GroupPanelValues {
|
||||
name: string;
|
||||
type: string | GroupPanelType.TEXT | GroupPanelType.GROUP;
|
||||
slowMode?: GroupPanelSlowMode;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@ -0,0 +1,216 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
|
||||
const DEFAULT_KEY_PREFIX = 'tailchat:slow-mode:v1';
|
||||
|
||||
const CONSUME_SCRIPT = `
|
||||
local now = tonumber(ARGV[1])
|
||||
if not now then
|
||||
local redisTime = redis.call('TIME')
|
||||
now = redisTime[1] * 1000 + math.floor(redisTime[2] / 1000)
|
||||
end
|
||||
|
||||
local windowMs = tonumber(ARGV[2])
|
||||
local maxMessages = tonumber(ARGV[3])
|
||||
local entryId = ARGV[4]
|
||||
local cutoff = now - windowMs
|
||||
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
|
||||
if count >= maxMessages then
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
local resetAt = tonumber(oldest[2]) + windowMs
|
||||
redis.call('PEXPIRE', KEYS[1], windowMs)
|
||||
return { 0, 0, resetAt, math.max(resetAt - now, 0) }
|
||||
end
|
||||
|
||||
redis.call('ZADD', KEYS[1], now, entryId)
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
local resetAt = tonumber(oldest[2]) + windowMs
|
||||
redis.call('PEXPIRE', KEYS[1], windowMs)
|
||||
|
||||
return { 1, maxMessages - count - 1, resetAt, math.max(resetAt - now, 0) }
|
||||
`;
|
||||
|
||||
const STATUS_SCRIPT = `
|
||||
local now = tonumber(ARGV[1])
|
||||
if not now then
|
||||
local redisTime = redis.call('TIME')
|
||||
now = redisTime[1] * 1000 + math.floor(redisTime[2] / 1000)
|
||||
end
|
||||
|
||||
local windowMs = tonumber(ARGV[2])
|
||||
local maxMessages = tonumber(ARGV[3])
|
||||
local cutoff = now - windowMs
|
||||
|
||||
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', cutoff)
|
||||
local count = redis.call('ZCARD', KEYS[1])
|
||||
|
||||
if count == 0 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
return { maxMessages, 0 }
|
||||
end
|
||||
|
||||
local oldest = redis.call('ZRANGE', KEYS[1], 0, 0, 'WITHSCORES')
|
||||
redis.call('PEXPIRE', KEYS[1], windowMs)
|
||||
|
||||
return { math.max(maxMessages - count, 0), tonumber(oldest[2]) + windowMs }
|
||||
`;
|
||||
|
||||
const RELEASE_SCRIPT = `
|
||||
local removed = redis.call('ZREM', KEYS[1], ARGV[1])
|
||||
if redis.call('ZCARD', KEYS[1]) == 0 then
|
||||
redis.call('DEL', KEYS[1])
|
||||
end
|
||||
return removed
|
||||
`;
|
||||
|
||||
export interface SlowModeConsumeResult {
|
||||
accepted: boolean;
|
||||
remaining: number;
|
||||
resetAt?: Date;
|
||||
retryAfterMs?: number;
|
||||
entryId?: string;
|
||||
}
|
||||
|
||||
export interface SlowModeRedisClient {
|
||||
eval(
|
||||
script: string,
|
||||
numberOfKeys: number,
|
||||
...args: Array<string | number>
|
||||
): Promise<unknown>;
|
||||
scan(
|
||||
cursor: string,
|
||||
...args: Array<string | number>
|
||||
): Promise<[string, string[]]>;
|
||||
del(...keys: string[]): Promise<number>;
|
||||
}
|
||||
|
||||
interface SlowModeParams {
|
||||
converseId: string;
|
||||
userId: string;
|
||||
intervalSeconds: number;
|
||||
maxMessages: number;
|
||||
now?: Date;
|
||||
}
|
||||
|
||||
function encodeKeyPart(value: string): string {
|
||||
return Buffer.from(value).toString('hex');
|
||||
}
|
||||
|
||||
function escapeRedisPattern(value: string): string {
|
||||
return value.replace(/[\\*?\[\]]/g, '\\$&');
|
||||
}
|
||||
|
||||
function parseScriptResult(result: unknown, expectedLength: number): number[] {
|
||||
if (!Array.isArray(result) || result.length !== expectedLength) {
|
||||
throw new Error('Invalid Redis slow mode script result');
|
||||
}
|
||||
|
||||
const values = result.map(Number);
|
||||
if (values.some((value) => !Number.isFinite(value))) {
|
||||
throw new Error('Invalid Redis slow mode script value');
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
export class RedisSlowModeCounter {
|
||||
constructor(
|
||||
private readonly redis: SlowModeRedisClient,
|
||||
private readonly keyPrefix = DEFAULT_KEY_PREFIX
|
||||
) {}
|
||||
|
||||
private getKey(params: SlowModeParams): string {
|
||||
return [
|
||||
this.keyPrefix,
|
||||
encodeKeyPart(params.converseId),
|
||||
encodeKeyPart(params.userId),
|
||||
params.intervalSeconds,
|
||||
params.maxMessages,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
async consume(params: SlowModeParams): Promise<SlowModeConsumeResult> {
|
||||
const entryId = randomBytes(16).toString('hex');
|
||||
const result = await this.redis.eval(
|
||||
CONSUME_SCRIPT,
|
||||
1,
|
||||
this.getKey(params),
|
||||
params.now?.valueOf().toString() ?? '',
|
||||
params.intervalSeconds * 1000,
|
||||
params.maxMessages,
|
||||
entryId
|
||||
);
|
||||
const [accepted, remaining, resetAt, retryAfterMs] = parseScriptResult(
|
||||
result,
|
||||
4
|
||||
);
|
||||
|
||||
return {
|
||||
accepted: accepted === 1,
|
||||
remaining,
|
||||
resetAt: resetAt > 0 ? new Date(resetAt) : undefined,
|
||||
retryAfterMs,
|
||||
entryId: accepted === 1 ? entryId : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async getStatus(
|
||||
params: SlowModeParams
|
||||
): Promise<Omit<SlowModeConsumeResult, 'accepted' | 'entryId'>> {
|
||||
const result = await this.redis.eval(
|
||||
STATUS_SCRIPT,
|
||||
1,
|
||||
this.getKey(params),
|
||||
params.now?.valueOf().toString() ?? '',
|
||||
params.intervalSeconds * 1000,
|
||||
params.maxMessages
|
||||
);
|
||||
const [remaining, resetAt] = parseScriptResult(result, 2);
|
||||
|
||||
return {
|
||||
remaining,
|
||||
resetAt: resetAt > 0 ? new Date(resetAt) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async release(params: SlowModeParams & { entryId: string }): Promise<void> {
|
||||
await this.redis.eval(
|
||||
RELEASE_SCRIPT,
|
||||
1,
|
||||
this.getKey(params),
|
||||
params.entryId
|
||||
);
|
||||
}
|
||||
|
||||
async deleteByConverseIds(converseIds: string[]): Promise<number> {
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const converseId of converseIds) {
|
||||
let cursor = '0';
|
||||
const pattern = `${escapeRedisPattern(this.keyPrefix)}:${encodeKeyPart(
|
||||
converseId
|
||||
)}:*`;
|
||||
|
||||
do {
|
||||
const [nextCursor, keys] = await this.redis.scan(
|
||||
cursor,
|
||||
'MATCH',
|
||||
pattern,
|
||||
'COUNT',
|
||||
100
|
||||
);
|
||||
cursor = nextCursor;
|
||||
|
||||
if (keys.length > 0) {
|
||||
deletedCount += await this.redis.del(...keys);
|
||||
}
|
||||
} while (cursor !== '0');
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
}
|
||||
|
||||
export default RedisSlowModeCounter;
|
||||
@ -0,0 +1,211 @@
|
||||
import RedisClient from 'ioredis';
|
||||
import { Types } from 'mongoose';
|
||||
import RedisSlowModeCounter from '../../../services/core/chat/slowModeCounter';
|
||||
|
||||
describe('RedisSlowModeCounter', () => {
|
||||
const converseIds: string[] = [];
|
||||
let redis: RedisClient.Redis;
|
||||
let counter: RedisSlowModeCounter;
|
||||
|
||||
const createIds = () => {
|
||||
const converseId = String(new Types.ObjectId());
|
||||
converseIds.push(converseId);
|
||||
return {
|
||||
converseId,
|
||||
userId: String(new Types.ObjectId()),
|
||||
};
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!process.env.REDIS_URL) {
|
||||
throw new Error('REDIS_URL is required to test RedisSlowModeCounter');
|
||||
}
|
||||
|
||||
redis = new RedisClient(process.env.REDIS_URL);
|
||||
await redis.ping();
|
||||
counter = new RedisSlowModeCounter(redis);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await redis.quit();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await counter.deleteByConverseIds(converseIds);
|
||||
converseIds.length = 0;
|
||||
});
|
||||
|
||||
test('limits messages in a rolling window and opens the oldest slot', async () => {
|
||||
const ids = createIds();
|
||||
const start = new Date('2026-08-20T00:00:00.000Z');
|
||||
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const result = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 5,
|
||||
now: new Date(start.valueOf() + index * 1000),
|
||||
});
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.remaining).toBe(4 - index);
|
||||
}
|
||||
|
||||
const limited = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 5,
|
||||
now: new Date(start.valueOf() + 5000),
|
||||
});
|
||||
expect(limited).toMatchObject({
|
||||
accepted: false,
|
||||
remaining: 0,
|
||||
resetAt: new Date(start.valueOf() + 60000),
|
||||
});
|
||||
|
||||
const released = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 5,
|
||||
now: new Date(start.valueOf() + 60001),
|
||||
});
|
||||
expect(released.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test('releases a reserved slot when message persistence fails', async () => {
|
||||
const ids = createIds();
|
||||
const now = new Date('2026-08-20T00:00:00.000Z');
|
||||
const reserved = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(reserved.accepted).toBe(true);
|
||||
expect(reserved.entryId).toBeDefined();
|
||||
|
||||
await counter.release({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
entryId: reserved.entryId!,
|
||||
});
|
||||
|
||||
const retried = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
});
|
||||
expect(retried.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test('keeps quotas isolated by user and channel', async () => {
|
||||
const first = createIds();
|
||||
const secondUser = {
|
||||
...first,
|
||||
userId: String(new Types.ObjectId()),
|
||||
};
|
||||
const secondChannel = createIds();
|
||||
const now = new Date('2026-08-20T00:00:00.000Z');
|
||||
|
||||
await counter.consume({
|
||||
...first,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
});
|
||||
|
||||
const [sameQuota, otherUser, otherChannel] = await Promise.all([
|
||||
counter.consume({
|
||||
...first,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
}),
|
||||
counter.consume({
|
||||
...secondUser,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
}),
|
||||
counter.consume({
|
||||
...secondChannel,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(sameQuota.accepted).toBe(false);
|
||||
expect(otherUser.accepted).toBe(true);
|
||||
expect(otherChannel.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test('allows exactly the configured count under concurrent sends', async () => {
|
||||
const ids = createIds();
|
||||
const now = new Date('2026-08-20T00:00:00.000Z');
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 20 }, () =>
|
||||
counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 5,
|
||||
now,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
expect(results.filter((result) => result.accepted)).toHaveLength(5);
|
||||
expect(results.filter((result) => !result.accepted)).toHaveLength(15);
|
||||
});
|
||||
|
||||
test('resets the quota when the policy changes', async () => {
|
||||
const ids = createIds();
|
||||
const now = new Date('2026-08-20T00:00:00.000Z');
|
||||
|
||||
await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
});
|
||||
const result = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 5,
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
accepted: true,
|
||||
remaining: 4,
|
||||
});
|
||||
|
||||
expect(await counter.deleteByConverseIds([ids.converseId])).toBe(2);
|
||||
const reset = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
now,
|
||||
});
|
||||
expect(reset.accepted).toBe(true);
|
||||
});
|
||||
|
||||
test('uses Redis server time when application time is omitted', async () => {
|
||||
const ids = createIds();
|
||||
const before = Date.now();
|
||||
const result = await counter.consume({
|
||||
...ids,
|
||||
intervalSeconds: 60,
|
||||
maxMessages: 1,
|
||||
});
|
||||
const after = Date.now();
|
||||
|
||||
expect(result.accepted).toBe(true);
|
||||
expect(result.retryAfterMs).toBeGreaterThan(59000);
|
||||
expect(result.retryAfterMs).toBeLessThanOrEqual(60000);
|
||||
expect(result.resetAt!.valueOf()).toBeGreaterThanOrEqual(before + 60000);
|
||||
expect(result.resetAt!.valueOf()).toBeLessThanOrEqual(after + 60000);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue