mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
83 lines
4.4 KiB
TypeScript
83 lines
4.4 KiB
TypeScript
// @vitest-environment jsdom
|
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
import { PlaybackLease } from '../src/features/media/services/playback-lease.ts';
|
|
import type { MediaApi, Playback } from '../src/features/media/api/media-api.ts';
|
|
import { ApiError } from '../src/api/errors.ts';
|
|
|
|
const leases: PlaybackLease[] = [];
|
|
function setup() {
|
|
vi.useFakeTimers();
|
|
const session = { id: 's1', expiresAt: Date.now() + 6 * 60 * 60_000, streamUrl: '/unchanged', revision: 7 } as Playback;
|
|
const request = vi.fn(async () => ({ id: 's1', expiresAt: Date.now() + 6 * 60 * 60_000 }));
|
|
const failure = vi.fn();
|
|
const lease = new PlaybackLease({ request } as unknown as MediaApi, session, failure);
|
|
leases.push(lease);
|
|
return { session, request, failure, lease };
|
|
}
|
|
afterEach(() => { for (const lease of leases.splice(0)) lease.stop(); vi.useRealTimers(); });
|
|
|
|
describe('playback URL lifetime', () => {
|
|
it('releases a transport that ignores abort and rejects its late expiry update',async()=>{
|
|
const {lease,session,request,failure}=setup();session.expiresAt=Date.now()-1;
|
|
let finish!:(response:{id:string;expiresAt:number})=>void;
|
|
request.mockImplementationOnce(()=>new Promise(resolve=>{finish=resolve;}));
|
|
const pending=expect(lease.ensure()).rejects.toThrow('超时');
|
|
await vi.advanceTimersByTimeAsync(15000);await pending;
|
|
expect(failure).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(30000);expect(request).toHaveBeenCalledTimes(2);
|
|
const renewed=session.expiresAt;finish({id:'s1',expiresAt:renewed+999999});await Promise.resolve();
|
|
expect(session.expiresAt).toBe(renewed);
|
|
});
|
|
it('stop settles pending renewal even when the transport never responds',async()=>{
|
|
const {lease,session,request,failure}=setup();session.expiresAt=Date.now()-1;
|
|
request.mockImplementation(()=>new Promise(()=>{}));
|
|
const pending=lease.ensure();lease.stop();await pending;
|
|
expect(failure).not.toHaveBeenCalled();expect(vi.getTimerCount()).toBe(0);
|
|
});
|
|
it('renews before expiry while retaining stream URL and progress revision', async () => {
|
|
const { session, request, failure } = setup();
|
|
const expiry = session.expiresAt;
|
|
await vi.advanceTimersByTimeAsync(6 * 60 * 60_000 - 2 * 60_000);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(request).toHaveBeenCalledWith('playback/s1/renew', 'POST', {}, expect.any(AbortSignal));
|
|
expect(session.expiresAt).toBeGreaterThan(expiry);
|
|
expect(session.streamUrl).toBe('/unchanged'); expect(session.revision).toBe(7);
|
|
expect(failure).not.toHaveBeenCalled();
|
|
});
|
|
it('coalesces simultaneous resume/progress renewal and ignores late responses after stop', async () => {
|
|
const { lease, session, request } = setup();
|
|
session.expiresAt = Date.now() - 1;
|
|
let finish!: (response: { id: string; expiresAt: number }) => void;
|
|
request.mockImplementation(() => new Promise(resolve => { finish = resolve; }));
|
|
const first = lease.ensure(), second = lease.ensure();
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
lease.stop();
|
|
finish({ id: 's1', expiresAt: Date.now() + 6 * 60 * 60_000 });
|
|
await Promise.all([first, second]);
|
|
expect(session.expiresAt).toBeLessThan(Date.now());
|
|
await vi.advanceTimersByTimeAsync(7 * 60 * 60_000);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
it('backs off transient failure then retries, without a timeupdate request storm', async () => {
|
|
const { lease, session, request, failure } = setup();
|
|
session.expiresAt = Date.now() + 60_000;
|
|
request.mockRejectedValueOnce(new Error('offline'));
|
|
await expect(lease.ensure()).rejects.toThrow('offline');
|
|
await Promise.all(Array.from({ length: 10 }, () => lease.ensure()));
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
expect(failure).toHaveBeenCalledWith(expect.any(String), false);
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
expect(request).toHaveBeenCalledTimes(2);
|
|
expect(session.expiresAt).toBeGreaterThan(Date.now() + 60_000);
|
|
});
|
|
it('stops renewal after permission revocation or takeover', async () => {
|
|
const { lease, session, request, failure } = setup();
|
|
session.expiresAt = Date.now();
|
|
request.mockRejectedValue(new ApiError('conflict', 'taken over', 'PLAYBACK_TAKEN_OVER', 409));
|
|
await expect(lease.ensure()).rejects.toThrow('taken over');
|
|
expect(failure).toHaveBeenCalledWith(expect.any(String), true);
|
|
await vi.advanceTimersByTimeAsync(7 * 60 * 60_000);
|
|
expect(request).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|