mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
- Unconfirmed writes and early edits are saved in localStorage per page load; the tab remembers its page load in sessionStorage, so a reload takes them over, and another tab replays a closed tab's writes once its record is stale. Live tabs renew their record every 30 s. - An edit made on another tab's unsent change, and an entry a re-read keeps from the cache, are sent on a version no entry has, so the write conflicts and is replayed instead of carrying another tab's change past a later removal. - Moving browser data counts the parental-lock step as failed while the lock store cannot be read, so the move stays on offer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
import { TestBed } from '@angular/core/testing';
|
|
import { MatSnackBar } from '@angular/material/snack-bar';
|
|
import { provideRouter } from '@angular/router';
|
|
import { TranslateService } from '@ngx-translate/core';
|
|
import { PlaylistStorageBackend } from '../playlist-storage-backend';
|
|
import { RuntimeCapabilitiesService } from '../runtime-capabilities.service';
|
|
import { SERVER_ACCESS_TOKEN_STORAGE_KEY } from '../server-access-token';
|
|
import { ServerStorageStatusService } from '../server-storage-status.service';
|
|
import { SERVER_STORAGE_CONFIG } from './server-storage.config';
|
|
import { ServerStorageSession } from './server-storage-session';
|
|
|
|
describe('ServerStorageSession', () => {
|
|
const fetchMock = jest.fn();
|
|
|
|
beforeEach(() => {
|
|
// start() arms a 3 s startup race and a 30 s retry interval.
|
|
jest.useFakeTimers();
|
|
(globalThis as { fetch: unknown }).fetch = fetchMock;
|
|
fetchMock.mockResolvedValue({
|
|
status: 200,
|
|
ok: true,
|
|
headers: new Headers(),
|
|
json: async () => ({ entries: {} }),
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
jest.clearAllTimers();
|
|
jest.useRealTimers();
|
|
fetchMock.mockReset();
|
|
localStorage.clear();
|
|
sessionStorage.clear();
|
|
TestBed.resetTestingModule();
|
|
});
|
|
|
|
function setup(auth: 'none' | 'token', startedOffline = false) {
|
|
TestBed.configureTestingModule({
|
|
providers: [
|
|
provideRouter([{ path: 'server-sign-in', children: [] }]),
|
|
{
|
|
provide: RuntimeCapabilitiesService,
|
|
useValue: { supportsSqlite: false },
|
|
},
|
|
{ provide: MatSnackBar, useValue: { open: jest.fn() } },
|
|
{
|
|
provide: TranslateService,
|
|
useValue: { instant: (key: string) => key },
|
|
},
|
|
{
|
|
provide: SERVER_STORAGE_CONFIG,
|
|
useValue: {
|
|
baseUrl: '/api',
|
|
startedOffline,
|
|
instance: {
|
|
service: 'iptvnator',
|
|
apiVersion: 1,
|
|
instanceId: 'i-1',
|
|
storage: 'server',
|
|
auth,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
return {
|
|
session: TestBed.inject(ServerStorageSession),
|
|
backend: TestBed.inject(PlaylistStorageBackend),
|
|
status: TestBed.inject(ServerStorageStatusService),
|
|
};
|
|
}
|
|
|
|
it('switches playlists to the server and hydrates user data', async () => {
|
|
const { session, backend, status } = setup('none');
|
|
|
|
await session.start();
|
|
|
|
expect(backend.kind()).toBe('server');
|
|
expect(status.state()).toBe('online');
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'/api/v1/user-state',
|
|
expect.anything()
|
|
);
|
|
});
|
|
|
|
it('stops at unauthorized when a token is required but missing', async () => {
|
|
const { session, status } = setup('token');
|
|
|
|
await session.start();
|
|
|
|
expect(status.state()).toBe('unauthorized');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('starts with a stored token', async () => {
|
|
localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't');
|
|
const { session, status } = setup('token');
|
|
|
|
await session.start();
|
|
|
|
expect(status.state()).toBe('online');
|
|
});
|
|
|
|
describe('after an offline start', () => {
|
|
it('stays offline until the server answers, then reloads once', async () => {
|
|
const { session, status } = setup('none', true);
|
|
const reload = jest
|
|
.spyOn(
|
|
session as unknown as { reloadPage(): void },
|
|
'reloadPage'
|
|
)
|
|
.mockImplementation(() => undefined);
|
|
fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
|
|
|
|
await session.start();
|
|
expect(status.state()).toBe('offline');
|
|
expect(reload).not.toHaveBeenCalled();
|
|
|
|
fetchMock.mockResolvedValue({
|
|
status: 200,
|
|
ok: true,
|
|
headers: new Headers(),
|
|
json: async () => ({ entries: {} }),
|
|
});
|
|
await session.refresh();
|
|
await session.refresh();
|
|
|
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('sends no write before the reload checks the instance', async () => {
|
|
// A write this tab's last page load could not send.
|
|
sessionStorage.setItem('iptvnator-server:page:i-1', 'last');
|
|
localStorage.setItem(
|
|
'iptvnator-server:unsent-writes:i-1:last',
|
|
JSON.stringify([
|
|
{
|
|
namespace: 'xtream-favorites',
|
|
playlistId: 'p1',
|
|
value: ['x'],
|
|
},
|
|
])
|
|
);
|
|
const { session } = setup('none', true);
|
|
jest.spyOn(
|
|
session as unknown as { reloadPage(): void },
|
|
'reloadPage'
|
|
).mockImplementation(() => undefined);
|
|
|
|
await session.start();
|
|
session.userData.flushWithKeepalive();
|
|
await session.userData.flush();
|
|
|
|
expect(
|
|
fetchMock.mock.calls.map(([url, init]) => [
|
|
url,
|
|
init?.method ?? 'GET',
|
|
])
|
|
).toEqual([['/api/v1/instance', 'GET']]);
|
|
expect(session.userData.hasUnsentWrites()).toBe(true);
|
|
});
|
|
|
|
it('never reloads after a normal start', async () => {
|
|
const { session } = setup('none');
|
|
const reload = jest
|
|
.spyOn(
|
|
session as unknown as { reloadPage(): void },
|
|
'reloadPage'
|
|
)
|
|
.mockImplementation(() => undefined);
|
|
|
|
await session.start();
|
|
await session.refresh();
|
|
|
|
expect(reload).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|