mirror of https://github.com/OpenVidu/openvidu.git
163 lines
5.3 KiB
TypeScript
163 lines
5.3 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
|
|
import { decodeCursor, encodeCursor } from '../../src/api/cursor.js';
|
|
import {
|
|
assertRankedRange,
|
|
type Ranked,
|
|
rankedPipeline,
|
|
type RankedPageRequest,
|
|
rankedResult
|
|
} from '../../src/api/ranked.js';
|
|
|
|
const AS_OF = new Date('2026-10-13T12:00:00.000Z');
|
|
const MINUTE = 60_000;
|
|
|
|
interface Room {
|
|
_id: string;
|
|
createdAt: Date;
|
|
durationMs?: number;
|
|
}
|
|
|
|
/** Duration rank of a room at `asOf`: the stored duration, or the time since creation while it runs. */
|
|
function durationAt(room: Room, asOf: Date): number {
|
|
return room.durationMs ?? asOf.getTime() - room.createdAt.getTime();
|
|
}
|
|
|
|
/** Runs the pipeline of {@link rankedPipeline} in memory, as MongoDB would. */
|
|
function run(rooms: readonly Room[], request: RankedPageRequest, asOf: Date): Ranked<Room>[] {
|
|
const pipeline = rankedPipeline(request, asOf);
|
|
const sort = pipeline.find((stage) => '$sort' in stage)?.$sort as { rank: 1 | -1 };
|
|
const limit = pipeline.find((stage) => '$limit' in stage)?.$limit as number;
|
|
const boundary = request.cursor;
|
|
const ranked = rooms.map((room) => ({ ...room, rank: durationAt(room, asOf) }));
|
|
const beyond = (item: Room & { rank: number }) => {
|
|
if (boundary === undefined) {
|
|
return true;
|
|
}
|
|
|
|
const value = boundary.value as number;
|
|
const after = item.rank === value ? item._id.localeCompare(boundary.id) : item.rank - value;
|
|
|
|
return sort.rank === 1 ? after > 0 : after < 0;
|
|
};
|
|
|
|
return ranked
|
|
.filter(beyond)
|
|
.sort((a, b) => (a.rank === b.rank ? a._id.localeCompare(b._id) : a.rank - b.rank) * sort.rank)
|
|
.slice(0, limit);
|
|
}
|
|
|
|
function request(cursor: string | null, now: Date, limit = 2): RankedPageRequest {
|
|
const decoded = cursor === null ? undefined : decodeCursor(cursor, 'duration', 'desc');
|
|
|
|
if (typeof decoded === 'string') {
|
|
throw new Error(decoded);
|
|
}
|
|
|
|
return {
|
|
match: {},
|
|
rank: () => ({}),
|
|
projection: {},
|
|
sort: 'duration',
|
|
order: 'desc',
|
|
cursor: decoded,
|
|
limit,
|
|
now
|
|
};
|
|
}
|
|
|
|
function page(rooms: readonly Room[], cursor: string | null, now: Date) {
|
|
const req = request(cursor, now);
|
|
const asOf = req.cursor?.asOf ?? now;
|
|
|
|
return rankedResult(run(rooms, req, asOf), req, asOf);
|
|
}
|
|
|
|
describe('ranked cursors', () => {
|
|
it('round-trips asOf and keeps the five-field cursors of the index-backed sorts', () => {
|
|
const ranked = encodeCursor({
|
|
value: 42,
|
|
id: 'RM_a',
|
|
sort: 'duration',
|
|
order: 'desc',
|
|
direction: 'next',
|
|
asOf: AS_OF
|
|
});
|
|
const plain = encodeCursor({ value: AS_OF, id: 'RM_a', sort: 'createdAt', order: 'desc', direction: 'next' });
|
|
|
|
expect(decodeCursor(ranked, 'duration', 'desc')).toEqual({
|
|
value: 42,
|
|
id: 'RM_a',
|
|
sort: 'duration',
|
|
order: 'desc',
|
|
direction: 'next',
|
|
asOf: AS_OF
|
|
});
|
|
expect(decodeCursor(plain, 'createdAt', 'desc')).not.toHaveProperty('asOf');
|
|
});
|
|
|
|
it('rejects an invalid asOf', () => {
|
|
const broken = Buffer.from(JSON.stringify([1, 'RM_a', 'duration', 'desc', 'next', 'yesterday'])).toString(
|
|
'base64url'
|
|
);
|
|
|
|
expect(decodeCursor(broken, 'duration', 'desc')).toBe('is not a valid cursor');
|
|
});
|
|
});
|
|
|
|
describe('ranked pages', () => {
|
|
const rooms: Room[] = [
|
|
{ _id: 'RM_a', createdAt: new Date(AS_OF.getTime() - 60 * MINUTE), durationMs: 40 * MINUTE },
|
|
{ _id: 'RM_b', createdAt: new Date(AS_OF.getTime() - 50 * MINUTE), durationMs: 10 * MINUTE },
|
|
{ _id: 'RM_c', createdAt: new Date(AS_OF.getTime() - 25 * MINUTE) },
|
|
{ _id: 'RM_d', createdAt: new Date(AS_OF.getTime() - 90 * MINUTE), durationMs: 10 * MINUTE }
|
|
];
|
|
|
|
it('orders by rank with _id breaking ties in the same direction', () => {
|
|
const first = page(rooms, null, AS_OF);
|
|
const second = page(rooms, first.nextCursor, AS_OF);
|
|
|
|
expect(first.items.map((room) => room._id)).toEqual(['RM_a', 'RM_c']);
|
|
expect(second.items.map((room) => room._id)).toEqual(['RM_d', 'RM_b']);
|
|
expect(second.nextCursor).toBeNull();
|
|
expect(page(rooms, second.prevCursor, AS_OF).items.map((room) => room._id)).toEqual(['RM_a', 'RM_c']);
|
|
});
|
|
|
|
it('keeps a running room at its rank of the first page', () => {
|
|
const first = page(rooms, null, AS_OF);
|
|
// Twenty minutes later the running room has run 45 minutes, more than RM_a, but the cursor keeps its asOf.
|
|
const second = page(rooms, first.nextCursor, new Date(AS_OF.getTime() + 20 * MINUTE));
|
|
|
|
expect([...first.items, ...second.items].map((room) => room._id)).toEqual(['RM_a', 'RM_c', 'RM_d', 'RM_b']);
|
|
expect(decodeCursor(first.nextCursor ?? '', 'duration', 'desc')).toMatchObject({ asOf: AS_OF });
|
|
});
|
|
|
|
it('scans the range first and limits the sorted documents to one page and one more', () => {
|
|
const pipeline = rankedPipeline(
|
|
{
|
|
...request(null, AS_OF, 25),
|
|
match: { createdAt: { $gte: AS_OF } },
|
|
rank: (asOf) => ({ $literal: asOf })
|
|
},
|
|
AS_OF
|
|
);
|
|
|
|
expect(pipeline[0]).toEqual({ $match: { createdAt: { $gte: AS_OF } } });
|
|
expect(pipeline.at(-2)).toEqual({ $sort: { rank: -1, _id: -1 } });
|
|
expect(pipeline.at(-1)).toEqual({ $limit: 26 });
|
|
});
|
|
});
|
|
|
|
describe('ranked range limit', () => {
|
|
it('refuses a range wider than the limit with SORT_RANGE_TOO_WIDE', () => {
|
|
const to = AS_OF;
|
|
|
|
expect(() => {
|
|
assertRankedRange({ from: new Date(to.getTime() - 8 * 86_400_000), to }, 7 * 86_400_000, 'duration');
|
|
}).toThrow(expect.objectContaining({ code: 'SORT_RANGE_TOO_WIDE' }));
|
|
expect(() => {
|
|
assertRankedRange({ from: new Date(to.getTime() - 7 * 86_400_000), to }, 7 * 86_400_000, 'duration');
|
|
}).not.toThrow();
|
|
});
|
|
});
|