Files
iptvnator/libs/services/src/lib/server-storage/server-storage-session.spec.ts
T
4grayandClaude Opus 5.5 b1b05600d6 fix(server-storage): durable per-page journal, conflicts for edits on other tabs' changes, lock readability
- 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>
2026-10-12 09:49:21 +02:00

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();
});
});
});