snapshot
This commit is contained in:
parent
3ad2766f82
commit
f74ee43cb4
196 changed files with 18949 additions and 32173 deletions
|
|
@ -1,53 +1,35 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Unique } from '../store/unique';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
import { TreeDto } from '../models';
|
||||
|
||||
const API_URI = 'https://store.schmelczer.dev/api/store/';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ApiService {
|
||||
constructor(private http: HttpClient) {}
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private static getAuthorizationHeader(id: string): HttpHeaders {
|
||||
return new HttpHeaders().set('Authorization', `life-towers-v3 ${id}`);
|
||||
health(): Promise<{ status: string }> {
|
||||
return firstValueFrom(this.http.get<{ status: string }>('/api/v1/health'));
|
||||
}
|
||||
|
||||
async track(id: string): Promise<void> {
|
||||
await this.http.post(`${API_URI}me`, {}, { headers: ApiService.getAuthorizationHeader(id) }).toPromise();
|
||||
register(token: string): Promise<{ user_id: string }> {
|
||||
return firstValueFrom(
|
||||
this.http.post<{ user_id: string }>('/api/v1/register', { token }),
|
||||
);
|
||||
}
|
||||
|
||||
async register(id: string): Promise<void> {
|
||||
await this.http.post(API_URI, { token: id }).toPromise();
|
||||
getData(token: string): Promise<TreeDto> {
|
||||
return firstValueFrom(
|
||||
this.http.get<TreeDto>('/api/v1/data', { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
async getObject(userId: string, objectId: string): Promise<Unique> {
|
||||
return await this.http
|
||||
.get<Unique>(`${API_URI}me/${objectId}`, { headers: ApiService.getAuthorizationHeader(userId) })
|
||||
.toPromise();
|
||||
putData(token: string, tree: TreeDto): Promise<void> {
|
||||
return firstValueFrom(
|
||||
this.http.put<void>('/api/v1/data', tree, { headers: this.authHeaders(token) }),
|
||||
);
|
||||
}
|
||||
|
||||
async postObject(userId: string, objectId: string, serializedObject: string): Promise<void> {
|
||||
await this.http
|
||||
.post(
|
||||
`${API_URI}me/${objectId}`,
|
||||
{ data: serializedObject },
|
||||
{ headers: ApiService.getAuthorizationHeader(userId) }
|
||||
)
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
async getRootId(userId: string): Promise<string> {
|
||||
return await this.http
|
||||
// @ts-ignore
|
||||
.get<string>(`${API_URI}me/root`, { headers: ApiService.getAuthorizationHeader(userId), responseType: 'text' })
|
||||
.toPromise();
|
||||
}
|
||||
|
||||
async setRootId(userId: string, rootId: string): Promise<void> {
|
||||
await this.http
|
||||
.put(`${API_URI}me/root`, { root_id: rootId }, { headers: ApiService.getAuthorizationHeader(userId) })
|
||||
.toPromise();
|
||||
private authHeaders(token: string): HttpHeaders {
|
||||
return new HttpHeaders({ Authorization: `Bearer ${token}` });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
28
frontend/src/app/services/api.service.vitest.ts
Normal file
28
frontend/src/app/services/api.service.vitest.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// Mock environment
|
||||
vi.mock('../../environments/environment', () => ({
|
||||
environment: { apiBase: 'http://test-api', production: false },
|
||||
}));
|
||||
|
||||
describe('ApiService URL patterns', () => {
|
||||
const baseUrl = 'http://test-api';
|
||||
|
||||
it('constructs correct health URL', () => {
|
||||
expect(`${baseUrl}/api/v1/health`).toBe('http://test-api/api/v1/health');
|
||||
});
|
||||
|
||||
it('constructs correct register URL', () => {
|
||||
expect(`${baseUrl}/api/v1/register`).toBe('http://test-api/api/v1/register');
|
||||
});
|
||||
|
||||
it('constructs correct data URL', () => {
|
||||
expect(`${baseUrl}/api/v1/data`).toBe('http://test-api/api/v1/data');
|
||||
});
|
||||
|
||||
it('formats Authorization header correctly', () => {
|
||||
const token = 'abc-def-123';
|
||||
const header = `Bearer ${token}`;
|
||||
expect(header).toBe('Bearer abc-def-123');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
|
||||
interface Subscriber {
|
||||
callback: () => void;
|
||||
object: object;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class CancelService {
|
||||
private subscribers: Subscriber[] = [];
|
||||
|
||||
subscribe(object: object, callback: () => void) {
|
||||
this.subscribers.push({
|
||||
object,
|
||||
callback
|
||||
});
|
||||
}
|
||||
|
||||
cancelAllExcept(except: object) {
|
||||
this.subscribers.filter(s => s.object !== except).map(s => s.callback());
|
||||
}
|
||||
|
||||
cancelAll() {
|
||||
this.subscribers.map(s => s.callback());
|
||||
}
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Root } from '../store/root';
|
||||
import { MapStoreService } from './map-store.service';
|
||||
import { Data } from '../model/data';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DataService extends Root<Data> {
|
||||
private shouldSave = true;
|
||||
constructor(private store: MapStoreService) {
|
||||
super();
|
||||
|
||||
this.store.data.subscribe(d => {
|
||||
if (d) {
|
||||
this.shouldSave = false;
|
||||
this.changeKeys({ children: [d] });
|
||||
}
|
||||
});
|
||||
|
||||
this.children$.subscribe(_ => {
|
||||
this.log();
|
||||
});
|
||||
|
||||
this.children$.subscribe(data => {
|
||||
if (data && data.length && data[0]) {
|
||||
if (!this.shouldSave) {
|
||||
this.shouldSave = true;
|
||||
return;
|
||||
}
|
||||
this.store.save(data[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,191 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import * as uuid from 'uuid';
|
||||
import { Data } from '../model/data';
|
||||
import { ITower } from '../interfaces/persistance/tower';
|
||||
import { IPage } from '../interfaces/persistance/page';
|
||||
import { IData } from '../interfaces/persistance/data';
|
||||
import { IUnique } from '../interfaces/persistance/unique';
|
||||
import { ApiService } from './api.service';
|
||||
import { Unique } from '../store/unique';
|
||||
import { BehaviorSubject } from 'rxjs/internal/BehaviorSubject';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'life-towers.data.v.3';
|
||||
|
||||
interface LifeTowersData {
|
||||
token: string;
|
||||
root: string;
|
||||
objects: {
|
||||
[id: string]: IUnique;
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MapStoreService {
|
||||
private state: LifeTowersData;
|
||||
private canSaveTrigger: () => void;
|
||||
private canSave = new Promise(r => (this.canSaveTrigger = r));
|
||||
private dataToSave: Data;
|
||||
|
||||
private saveEverything = false;
|
||||
|
||||
private readonly _data: BehaviorSubject<Data> = new BehaviorSubject(null);
|
||||
readonly data: Observable<Data> = this._data.asObservable();
|
||||
|
||||
constructor(private api: ApiService) {
|
||||
const storedData: string = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||
|
||||
if (storedData) {
|
||||
this.state = JSON.parse(storedData);
|
||||
this.initWithLoad().catch();
|
||||
} else {
|
||||
this.initWithRegister().catch();
|
||||
}
|
||||
this.api.track(this.state.token).catch();
|
||||
}
|
||||
|
||||
private static getSeed(): LifeTowersData {
|
||||
const towerId = uuid.v4();
|
||||
const tower: ITower = {
|
||||
id: towerId,
|
||||
name: null,
|
||||
blocks: [],
|
||||
baseColor: { h: 0, s: 100, l: 50 }
|
||||
};
|
||||
|
||||
const pageId = uuid.v4();
|
||||
const page: IPage = {
|
||||
id: pageId,
|
||||
name: 'My first page',
|
||||
towers: [towerId],
|
||||
userData: {
|
||||
hideCreateTowerButton: false,
|
||||
defaultDateRange: {
|
||||
from: null,
|
||||
to: null
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const dataId = uuid.v4();
|
||||
const data: IData = {
|
||||
id: dataId,
|
||||
pages: [pageId]
|
||||
};
|
||||
|
||||
return {
|
||||
token: uuid.v4(),
|
||||
root: dataId,
|
||||
objects: {
|
||||
[dataId]: data,
|
||||
[pageId]: page,
|
||||
[towerId]: tower
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
save(root: Data) {
|
||||
this.dataToSave = root;
|
||||
|
||||
setTimeout(() => {
|
||||
if (this.dataToSave === root) {
|
||||
this.realSave(root).catch();
|
||||
}
|
||||
}, 750);
|
||||
}
|
||||
|
||||
private get root(): Data {
|
||||
return new Data(this.state.objects[this.state.root] as IData, id => this.state.objects[id]);
|
||||
}
|
||||
|
||||
get userToken(): string {
|
||||
return this.state.token;
|
||||
}
|
||||
|
||||
set userToken(value: string) {
|
||||
this.state.token = value;
|
||||
this.initWithLoad().catch();
|
||||
}
|
||||
|
||||
private async realSave(root: Data): Promise<void> {
|
||||
await this.canSave;
|
||||
|
||||
const waiting: Array<Unique> = [root];
|
||||
const referenceSerializer = (e: Unique): string => {
|
||||
waiting.push(e);
|
||||
return e.id;
|
||||
};
|
||||
|
||||
while (waiting.length > 0) {
|
||||
const candidate = waiting.pop();
|
||||
if (!this.saveEverything && this.state.objects.hasOwnProperty(candidate.id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const serialized = candidate.serialize(referenceSerializer);
|
||||
|
||||
this.state.objects[candidate.id] = serialized;
|
||||
this.api.postObject(this.state.token, candidate.id, JSON.stringify(serialized)).catch();
|
||||
}
|
||||
|
||||
this.api.setRootId(this.state.token, root.id).catch();
|
||||
this.state.root = root.id;
|
||||
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(this.state));
|
||||
}
|
||||
|
||||
private async initWithRegister(id?: string) {
|
||||
this.state = MapStoreService.getSeed();
|
||||
if (id) {
|
||||
this.state.token = id;
|
||||
}
|
||||
this._data.next(this.root);
|
||||
|
||||
await this.api.register(this.state.token).catch();
|
||||
this.canSaveTrigger();
|
||||
|
||||
this.saveEverything = true;
|
||||
await this.realSave(this.root);
|
||||
this.saveEverything = false;
|
||||
}
|
||||
|
||||
private async initWithLoad() {
|
||||
this.canSave = new Promise(r => (this.canSaveTrigger = r));
|
||||
|
||||
let realRoot: string;
|
||||
try {
|
||||
realRoot = await this.api.getRootId(this.state.token).catch();
|
||||
} catch {
|
||||
this.initWithRegister(this.state.token).catch();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.root !== realRoot) {
|
||||
this.state.root = realRoot;
|
||||
const root = await this.api.getObject(this.state.token, realRoot);
|
||||
this.state.objects[this.state.root] = root;
|
||||
|
||||
const getUnknowns = async (element: any) => {
|
||||
const childrenAliases = ['pages', 'towers', 'blocks'];
|
||||
|
||||
for (const childrenAlias of childrenAliases) {
|
||||
if (element.hasOwnProperty(childrenAlias)) {
|
||||
for (const p of element[childrenAlias]) {
|
||||
if (!this.state.objects.hasOwnProperty(p)) {
|
||||
const unknown = await this.api.getObject(this.state.token, p);
|
||||
this.state.objects[p] = unknown;
|
||||
await getUnknowns(unknown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await getUnknowns(root);
|
||||
}
|
||||
this._data.next(this.root);
|
||||
this.canSaveTrigger();
|
||||
}
|
||||
}
|
||||
25
frontend/src/app/services/modal-state.service.ts
Normal file
25
frontend/src/app/services/modal-state.service.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { Injectable, computed, signal } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Shared counter of currently-open modals. Each `lt-modal` increments on
|
||||
* mount and decrements on destroy. Consumers read `anyOpen` to react.
|
||||
*
|
||||
* Used by `page.component` to disable tower drag-and-drop while any modal
|
||||
* (block-edit carousel, page-settings, tower-settings, confirm-delete,
|
||||
* settings) is on screen — otherwise the user can drag towers from behind
|
||||
* the open card.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ModalStateService {
|
||||
private readonly _openCount = signal(0);
|
||||
|
||||
readonly anyOpen = computed(() => this._openCount() > 0);
|
||||
|
||||
open(): void {
|
||||
this._openCount.update((n) => n + 1);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this._openCount.update((n) => Math.max(0, n - 1));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import { Tower } from '../model/tower';
|
||||
import { top } from '../utils/top';
|
||||
import { CancelService } from './cancel.service';
|
||||
import { Page } from '../model/page';
|
||||
import { Observable } from 'rxjs/internal/Observable';
|
||||
import { Block } from '../model/block';
|
||||
import { Data } from '../model/data';
|
||||
|
||||
export enum ModalType {
|
||||
blocks,
|
||||
settings,
|
||||
removeTower,
|
||||
removePage,
|
||||
getStarted
|
||||
}
|
||||
|
||||
interface Modal {
|
||||
type: ModalType;
|
||||
input: any;
|
||||
resolve: (output: any) => void;
|
||||
reject: () => void;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class ModalService {
|
||||
private modalStack: Modal[] = [];
|
||||
|
||||
constructor(private cancelService: CancelService) {}
|
||||
|
||||
showBlocks(input: { tower$: Observable<Tower>; onlyDone: boolean; startBlock?: Block }): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(input, ModalType.blocks);
|
||||
}
|
||||
|
||||
showSettings(options: { page$: Observable<Page>; data$: Observable<Data> }): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(options, ModalType.settings);
|
||||
}
|
||||
|
||||
showRemoveTower(tower: Tower): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(tower, ModalType.removeTower);
|
||||
}
|
||||
|
||||
showRemovePage(name: string): Promise<void> {
|
||||
return this.createPromiseAndPushToStack(name, ModalType.removePage);
|
||||
}
|
||||
|
||||
get active(): Modal {
|
||||
return top(this.modalStack);
|
||||
}
|
||||
|
||||
submit(output?: any) {
|
||||
const modal = this.modalStack.pop();
|
||||
if (modal) {
|
||||
modal.resolve(output);
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
const modal = this.modalStack.pop();
|
||||
if (modal) {
|
||||
modal.reject();
|
||||
}
|
||||
}
|
||||
|
||||
private createPromiseAndPushToStack(input: any, type: ModalType): Promise<any> {
|
||||
this.cancelService.cancelAll();
|
||||
|
||||
const modal = {
|
||||
input,
|
||||
type,
|
||||
resolve: () => {},
|
||||
reject: () => {}
|
||||
};
|
||||
|
||||
const modalPromise = new Promise((resolve, reject) => {
|
||||
modal.resolve = resolve;
|
||||
modal.reject = reject;
|
||||
});
|
||||
|
||||
this.modalStack.push(modal);
|
||||
return modalPromise;
|
||||
}
|
||||
}
|
||||
585
frontend/src/app/services/store.service.ts
Normal file
585
frontend/src/app/services/store.service.ts
Normal file
|
|
@ -0,0 +1,585 @@
|
|||
import { Injectable, inject, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { ApiService } from './api.service';
|
||||
import { Page, Tower, Block, TreeDto, SaveStatus, HslColor } from '../models';
|
||||
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
const DEBOUNCE_MS = 750;
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
// RFC 4122 v4 UUID. Prefers crypto.randomUUID (secure contexts only) and
|
||||
// falls back to crypto.getRandomValues — which works on plain http origins
|
||||
// behind a non-localhost reverse proxy too.
|
||||
function uuidV4(): string {
|
||||
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
||||
try {
|
||||
return crypto.randomUUID();
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
}
|
||||
const b = new Uint8Array(16);
|
||||
crypto.getRandomValues(b);
|
||||
b[6] = (b[6] & 0x0f) | 0x40;
|
||||
b[8] = (b[8] & 0x3f) | 0x80;
|
||||
const h = Array.from(b, (x) => x.toString(16).padStart(2, '0')).join('');
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
|
||||
}
|
||||
|
||||
function isUuidV4(value: string): boolean {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
|
||||
// localStorage can throw (private mode, quota exceeded, disabled). All
|
||||
// access goes through these helpers so a transient failure never bubbles
|
||||
// up and breaks app init.
|
||||
function safeGet(key: string): string | null {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function safeSet(key: string, value: string): void {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
interface PendingPut {
|
||||
token: string;
|
||||
tree: TreeDto;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class StoreService implements OnDestroy {
|
||||
private readonly api = inject(ApiService);
|
||||
|
||||
// ── State ──────────────────────────────────────────────────────────────────
|
||||
private readonly _pages = signal<Page[]>([]);
|
||||
private readonly _saveStatus = signal<SaveStatus>('idle');
|
||||
private readonly _token = signal<string>('');
|
||||
private readonly _loading = signal<boolean>(true);
|
||||
|
||||
readonly pages = this._pages.asReadonly();
|
||||
readonly saveStatus = this._saveStatus.asReadonly();
|
||||
readonly loading = this._loading.asReadonly();
|
||||
readonly token = this._token.asReadonly();
|
||||
|
||||
readonly pageCount = computed(() => this._pages().length);
|
||||
|
||||
// ── Debounce / retry ───────────────────────────────────────────────────────
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private flushInFlight = false;
|
||||
// True while in-flight if a new mutation arrived; we'll re-flush after.
|
||||
private dirtyDuringFlush = false;
|
||||
|
||||
// ── Cross-tab sync ─────────────────────────────────────────────────────────
|
||||
private readonly storageListener = (e: StorageEvent) => {
|
||||
if (e.key === TOKEN_KEY && e.newValue && e.newValue !== this._token()) {
|
||||
// Another tab switched accounts. Re-init with the new token instead of
|
||||
// continuing to sync the old account's state to the new one.
|
||||
this._token.set(e.newValue);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this.cancelPendingWrites();
|
||||
void this.init();
|
||||
} else if (e.key === CACHE_KEY && e.newValue && !this.flushInFlight) {
|
||||
// Another tab just wrote a fresh cache; adopt it if we're not mid-save
|
||||
// (to avoid clobbering our own state with the other tab's older view).
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(e.newValue);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// ── Single-flight init ─────────────────────────────────────────────────────
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
async init(): Promise<void> {
|
||||
if (this.initPromise) return this.initPromise;
|
||||
this.initPromise = this.doInit().finally(() => {
|
||||
this.initPromise = null;
|
||||
});
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
private async doInit(): Promise<void> {
|
||||
if (typeof window !== 'undefined') {
|
||||
// (idempotent — adding the same listener twice is a no-op)
|
||||
window.addEventListener('storage', this.storageListener);
|
||||
}
|
||||
|
||||
let stored = safeGet(TOKEN_KEY);
|
||||
if (stored && !isUuidV4(stored)) {
|
||||
// Garbage in localStorage from a buggy past version — refuse it.
|
||||
stored = null;
|
||||
}
|
||||
const token = stored ?? uuidV4();
|
||||
if (!stored) {
|
||||
safeSet(TOKEN_KEY, token);
|
||||
}
|
||||
this._token.set(token);
|
||||
|
||||
if (!stored) {
|
||||
try {
|
||||
await this.api.register(token);
|
||||
} catch {
|
||||
// Non-fatal; the 401 path below will re-attempt registration.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
if (status === 401) {
|
||||
// Token unknown to server — re-register (idempotent) and retry.
|
||||
try {
|
||||
await this.api.register(token);
|
||||
const tree = await this.api.getData(token);
|
||||
this.adoptServerTree(tree);
|
||||
} catch {
|
||||
this.loadFromCache();
|
||||
}
|
||||
} else {
|
||||
this.loadFromCache();
|
||||
}
|
||||
} finally {
|
||||
this._loading.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a freshly-fetched server tree. If the server is empty but our local
|
||||
* cache holds data, the cache wins and we schedule a push — otherwise the
|
||||
* "server forgot me" recovery would silently wipe offline edits.
|
||||
*/
|
||||
private adoptServerTree(tree: TreeDto): void {
|
||||
if (tree.pages.length === 0) {
|
||||
const cached = safeGet(CACHE_KEY);
|
||||
if (cached) {
|
||||
try {
|
||||
const cachedTree: TreeDto = JSON.parse(cached);
|
||||
if (cachedTree.pages && cachedTree.pages.length > 0) {
|
||||
this._pages.set(cachedTree.pages);
|
||||
this.scheduleSave();
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
/* fall through to server-empty */
|
||||
}
|
||||
}
|
||||
}
|
||||
this._pages.set(tree.pages);
|
||||
this.updateCache(tree);
|
||||
}
|
||||
|
||||
private loadFromCache(): void {
|
||||
const raw = safeGet(CACHE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const tree: TreeDto = JSON.parse(raw);
|
||||
this._pages.set(tree.pages);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateCache(tree: TreeDto): void {
|
||||
safeSet(CACHE_KEY, JSON.stringify(tree));
|
||||
}
|
||||
|
||||
private cancelPendingWrites(): void {
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
if (this.retryTimer !== null) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
this.dirtyDuringFlush = false;
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
addPage(name: string): void {
|
||||
const page: Page = {
|
||||
id: uuidV4(),
|
||||
name,
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [],
|
||||
};
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updatePage(id: string, patch: Partial<Omit<Page, 'id' | 'towers'>>): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === id ? { ...p, ...patch } : p)),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deletePage(id: string): void {
|
||||
this._pages.update((pages) => pages.filter((p) => p.id !== id));
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
reorderPages(fromIndex: number, toIndex: number): void {
|
||||
this._pages.update((pages) => {
|
||||
const arr = [...pages];
|
||||
const [item] = arr.splice(fromIndex, 1);
|
||||
arr.splice(toIndex, 0, item);
|
||||
return arr;
|
||||
});
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
addTower(pageId: string, name: string, base_color: HslColor): void {
|
||||
const tower: Tower = { id: uuidV4(), name, base_color, blocks: [] };
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => (p.id === pageId ? { ...p, towers: [...p.towers, tower] } : p)),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updateTower(pageId: string, towerId: string, patch: Partial<Omit<Tower, 'id' | 'blocks'>>): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? { ...p, towers: p.towers.map((t) => (t.id === towerId ? { ...t, ...patch } : t)) }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deleteTower(pageId: string, towerId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId ? { ...p, towers: p.towers.filter((t) => t.id !== towerId) } : p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
reorderTowers(pageId: string, fromIndex: number, toIndex: number): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) => {
|
||||
if (p.id !== pageId) return p;
|
||||
const towers = [...p.towers];
|
||||
const [item] = towers.splice(fromIndex, 1);
|
||||
towers.splice(toIndex, 0, item);
|
||||
return { ...p, towers };
|
||||
}),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
addBlock(
|
||||
pageId: string,
|
||||
towerId: string,
|
||||
tag: string,
|
||||
description: string,
|
||||
is_done = false,
|
||||
): void {
|
||||
const block: Block = {
|
||||
id: uuidV4(),
|
||||
tag,
|
||||
description,
|
||||
is_done,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId ? { ...t, blocks: [...t.blocks, block] } : t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
updateBlock(
|
||||
pageId: string,
|
||||
towerId: string,
|
||||
blockId: string,
|
||||
patch: Partial<Omit<Block, 'id' | 'created_at'>>,
|
||||
): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) => (b.id === blockId ? { ...b, ...patch } : b)),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
deleteBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? { ...t, blocks: t.blocks.filter((b) => b.id !== blockId) }
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
toggleBlock(pageId: string, towerId: string, blockId: string): void {
|
||||
this._pages.update((pages) =>
|
||||
pages.map((p) =>
|
||||
p.id === pageId
|
||||
? {
|
||||
...p,
|
||||
towers: p.towers.map((t) =>
|
||||
t.id === towerId
|
||||
? {
|
||||
...t,
|
||||
blocks: t.blocks.map((b) =>
|
||||
b.id === blockId ? { ...b, is_done: !b.is_done } : b,
|
||||
),
|
||||
}
|
||||
: t,
|
||||
),
|
||||
}
|
||||
: p,
|
||||
),
|
||||
);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different user's token. Any pending writes for the OLD
|
||||
* account must be cancelled first — otherwise a queued PUT could fire
|
||||
* against the NEW account with the old account's data (or vice versa,
|
||||
* if the timing flipped).
|
||||
*/
|
||||
switchToken(newToken: string): void {
|
||||
if (!isUuidV4(newToken)) return;
|
||||
this.cancelPendingWrites();
|
||||
safeSet(TOKEN_KEY, newToken);
|
||||
this._token.set(newToken);
|
||||
this._pages.set([]);
|
||||
this._loading.set(true);
|
||||
this._saveStatus.set('idle');
|
||||
void this.init();
|
||||
}
|
||||
|
||||
// ── Save / sync ────────────────────────────────────────────────────────────
|
||||
|
||||
private scheduleSave(): void {
|
||||
if (this.flushInFlight) {
|
||||
// A save is already happening. Mark dirty so we re-flush when it finishes.
|
||||
this.dirtyDuringFlush = true;
|
||||
return;
|
||||
}
|
||||
if (this.debounceTimer !== null) clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.runFlush();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/**
|
||||
* One save attempt with bounded retries. Captures the token and tree
|
||||
* snapshot up front so a mid-flight switchToken can't redirect this
|
||||
* write to a different account.
|
||||
*/
|
||||
private async runFlush(): Promise<void> {
|
||||
if (this.flushInFlight) {
|
||||
this.dirtyDuringFlush = true;
|
||||
return;
|
||||
}
|
||||
const token = this._token();
|
||||
if (!token) return;
|
||||
|
||||
this.flushInFlight = true;
|
||||
this.dirtyDuringFlush = false;
|
||||
|
||||
// Cancel any pending retry — runFlush() supersedes it.
|
||||
if (this.retryTimer !== null) {
|
||||
clearTimeout(this.retryTimer);
|
||||
this.retryTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.attempt({ token, tree: { pages: this._pages() } }, 0);
|
||||
} finally {
|
||||
this.flushInFlight = false;
|
||||
// Coalesce mutations that arrived during the flush into a fresh save.
|
||||
if (this.dirtyDuringFlush) {
|
||||
this.dirtyDuringFlush = false;
|
||||
this.scheduleSave();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async attempt(put: PendingPut, attempt: number): Promise<void> {
|
||||
this._saveStatus.set(attempt === 0 ? 'saving' : 'retrying');
|
||||
try {
|
||||
await this.api.putData(put.token, put.tree);
|
||||
this._saveStatus.set('saved');
|
||||
this.updateCache(put.tree);
|
||||
return;
|
||||
} catch (err: unknown) {
|
||||
const status = (err as { status?: number })?.status;
|
||||
const headers = (err as { headers?: { get(name: string): string | null } })?.headers;
|
||||
|
||||
// Permanent failures: no point retrying.
|
||||
if (status === 400) {
|
||||
this._saveStatus.set('invalid');
|
||||
return;
|
||||
}
|
||||
if (status === 413) {
|
||||
this._saveStatus.set('too-large');
|
||||
return;
|
||||
}
|
||||
|
||||
// 401 mid-PUT: server forgot us. Re-register (idempotent) and retry.
|
||||
if (status === 401) {
|
||||
try {
|
||||
await this.api.register(put.token);
|
||||
} catch {
|
||||
// fall through to retry/backoff
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt >= MAX_RETRIES) {
|
||||
this._saveStatus.set('error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Honor server's Retry-After when present (429 in particular).
|
||||
let delayMs = Math.min(1000 * 2 ** attempt, 30000);
|
||||
if (status === 429) {
|
||||
this._saveStatus.set('rate-limited');
|
||||
const ra = headers?.get('Retry-After') ?? headers?.get('retry-after');
|
||||
if (ra) {
|
||||
const seconds = parseInt(ra, 10);
|
||||
if (Number.isFinite(seconds) && seconds > 0) {
|
||||
delayMs = Math.min(seconds * 1000, 60_000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
this.retryTimer = setTimeout(() => {
|
||||
this.retryTimer = null;
|
||||
resolve();
|
||||
}, delayMs);
|
||||
});
|
||||
|
||||
// The token may have changed during the wait — re-check before retrying.
|
||||
if (this._token() !== put.token) return;
|
||||
// Re-snapshot the latest pages so the retry pushes current state.
|
||||
await this.attempt({ token: put.token, tree: { pages: this._pages() } }, attempt + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Example data ──────────────────────────────────────────────────────────
|
||||
|
||||
loadExample(): void {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const page: Page = {
|
||||
id: uuidV4(),
|
||||
name: 'Hobbies',
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: true,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [
|
||||
this.makeExampleTower('Reading', { h: 0.05, s: 0.7, l: 0.55 }, now, [
|
||||
{ tag: 'novel', desc: 'Finish The Brothers Karamazov', done: false, ageHrs: 0 },
|
||||
{ tag: 'novel', desc: "Read Dostoyevsky's notes", done: true, ageHrs: 2 },
|
||||
{ tag: 'article', desc: 'How does WebAssembly GC work?', done: true, ageHrs: 6 },
|
||||
{ tag: 'paper', desc: 'Re-read "Out of the Tar Pit"', done: true, ageHrs: 30 },
|
||||
{ tag: 'novel', desc: 'Submit a short story', done: true, ageHrs: 72 },
|
||||
]),
|
||||
this.makeExampleTower('Side projects', { h: 0.58, s: 0.65, l: 0.5 }, now, [
|
||||
{ tag: 'angular', desc: 'Modernise the towers app', done: false, ageHrs: 0 },
|
||||
{ tag: 'rust', desc: 'Port the sync layer to Tauri', done: false, ageHrs: 1 },
|
||||
{ tag: 'angular', desc: 'Wire CDK drag-drop', done: true, ageHrs: 24 },
|
||||
{ tag: 'rust', desc: 'Spike SQLite vs LMDB', done: true, ageHrs: 96 },
|
||||
]),
|
||||
this.makeExampleTower('Exercise', { h: 0.36, s: 0.6, l: 0.5 }, now, [
|
||||
{ tag: 'run', desc: '10k Sunday', done: false, ageHrs: 0 },
|
||||
{ tag: 'climb', desc: 'Lead 6a outdoors', done: false, ageHrs: 4 },
|
||||
{ tag: 'climb', desc: 'Bouldering session', done: true, ageHrs: 12 },
|
||||
{ tag: 'run', desc: 'Easy 5k loop', done: true, ageHrs: 48 },
|
||||
{ tag: 'climb', desc: 'Top-roped 5c', done: true, ageHrs: 96 },
|
||||
]),
|
||||
],
|
||||
};
|
||||
|
||||
this._pages.update((pages) => [...pages, page]);
|
||||
this.scheduleSave();
|
||||
}
|
||||
|
||||
private makeExampleTower(
|
||||
name: string,
|
||||
base_color: HslColor,
|
||||
nowSec: number,
|
||||
blocks: Array<{ tag: string; desc: string; done: boolean; ageHrs: number }>,
|
||||
): Tower {
|
||||
return {
|
||||
id: uuidV4(),
|
||||
name,
|
||||
base_color,
|
||||
blocks: blocks.map((b) => ({
|
||||
id: uuidV4(),
|
||||
tag: b.tag,
|
||||
description: b.desc,
|
||||
is_done: b.done,
|
||||
created_at: nowSec - Math.floor(b.ageHrs * 3600),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.cancelPendingWrites();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('storage', this.storageListener);
|
||||
}
|
||||
}
|
||||
}
|
||||
393
frontend/src/app/services/store.service.vitest.ts
Normal file
393
frontend/src/app/services/store.service.vitest.ts
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideZonelessChangeDetection } from '@angular/core';
|
||||
import { StoreService } from './store.service';
|
||||
import { ApiService } from './api.service';
|
||||
import type { TreeDto } from '../models';
|
||||
|
||||
// ── localStorage stub ────────────────────────────────────────────────────────
|
||||
const storage: Record<string, string> = {};
|
||||
let storageThrowsOnSet = false;
|
||||
const localStorageStub = {
|
||||
getItem: (k: string) => storage[k] ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
if (storageThrowsOnSet) throw new Error('QuotaExceededError');
|
||||
storage[k] = v;
|
||||
},
|
||||
removeItem: (k: string) => {
|
||||
delete storage[k];
|
||||
},
|
||||
clear: () => Object.keys(storage).forEach((k) => delete storage[k]),
|
||||
key: (i: number) => Object.keys(storage)[i] ?? null,
|
||||
get length() {
|
||||
return Object.keys(storage).length;
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
value: localStorageStub,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
|
||||
// ── crypto stub for tests that need deterministic UUIDs ─────────────────────
|
||||
const realCrypto = globalThis.crypto;
|
||||
function withFixedUuid<T>(uuid: string, fn: () => T): T {
|
||||
const stub = {
|
||||
randomUUID: () => uuid,
|
||||
getRandomValues: realCrypto.getRandomValues.bind(realCrypto),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'crypto', { value: stub, configurable: true });
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'crypto', { value: realCrypto, configurable: true });
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mock ApiService factory ──────────────────────────────────────────────────
|
||||
interface MockApi {
|
||||
register: ReturnType<typeof vi.fn>;
|
||||
getData: ReturnType<typeof vi.fn>;
|
||||
putData: ReturnType<typeof vi.fn>;
|
||||
health: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function makeMockApi(): MockApi {
|
||||
return {
|
||||
health: vi.fn().mockResolvedValue({ status: 'ok' }),
|
||||
register: vi.fn().mockResolvedValue({ user_id: 'u' }),
|
||||
getData: vi.fn().mockResolvedValue({ pages: [] } satisfies TreeDto),
|
||||
putData: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
const FIXED_UUID = '11111111-2222-4333-8444-555555555555';
|
||||
const TOKEN_KEY = 'life-towers.token.v4';
|
||||
const CACHE_KEY = 'life-towers.cache.v4';
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
function configure(api: MockApi): StoreService {
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideZonelessChangeDetection(),
|
||||
{ provide: ApiService, useValue: api },
|
||||
StoreService,
|
||||
],
|
||||
});
|
||||
return TestBed.inject(StoreService);
|
||||
}
|
||||
|
||||
function mkPage(name: string): TreeDto['pages'][number] {
|
||||
return {
|
||||
id: FIXED_UUID,
|
||||
name,
|
||||
hide_create_tower_button: false,
|
||||
keep_tasks_open: false,
|
||||
default_date_from: null,
|
||||
default_date_to: null,
|
||||
towers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// HttpErrorResponse-compatible shape for rejected promises.
|
||||
function httpError(status: number, headers: Record<string, string> = {}) {
|
||||
const headersObj = {
|
||||
get: (n: string) =>
|
||||
headers[n] ?? headers[n.toLowerCase()] ?? headers[n.toUpperCase()] ?? null,
|
||||
};
|
||||
const err: { status: number; headers: typeof headersObj } = { status, headers: headersObj };
|
||||
return err;
|
||||
}
|
||||
|
||||
describe('StoreService', () => {
|
||||
beforeEach(() => {
|
||||
localStorageStub.clear();
|
||||
storageThrowsOnSet = false;
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// ── Init ───────────────────────────────────────────────────────────────────
|
||||
|
||||
it('mints + persists a UUIDv4 token and calls register on first launch', async () => {
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
expect(storage[TOKEN_KEY]).toBe(FIXED_UUID);
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('reuses an existing stored token without re-registering', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(api.register).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('rejects a non-UUIDv4 stored token and mints a fresh one', async () => {
|
||||
storage[TOKEN_KEY] = 'not-a-uuid';
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await store.init();
|
||||
});
|
||||
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
});
|
||||
|
||||
it('on 401 from getData, re-registers the SAME token (idempotent) and retries', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.getData
|
||||
.mockRejectedValueOnce(httpError(401))
|
||||
.mockResolvedValueOnce({ pages: [mkPage('after-401')] });
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(api.register).toHaveBeenCalledTimes(1);
|
||||
expect(api.register).toHaveBeenCalledWith(FIXED_UUID);
|
||||
expect(api.getData).toHaveBeenCalledTimes(2);
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('after-401');
|
||||
});
|
||||
|
||||
it('falls back to cache on non-401 network error', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('cached')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
api.getData.mockRejectedValue(httpError(0));
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('cached');
|
||||
});
|
||||
|
||||
it('keeps local cache when server returns empty but cache has data', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
storage[CACHE_KEY] = JSON.stringify({ pages: [mkPage('offline-edit')] } satisfies TreeDto);
|
||||
const api = makeMockApi();
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
const store = configure(api);
|
||||
|
||||
await store.init();
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('offline-edit');
|
||||
});
|
||||
|
||||
it('init() doesn\'t crash if localStorage.setItem throws (private mode)', async () => {
|
||||
storageThrowsOnSet = true;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
await withFixedUuid(FIXED_UUID, async () => {
|
||||
await expect(store.init()).resolves.toBeUndefined();
|
||||
});
|
||||
expect(store.loading()).toBe(false);
|
||||
});
|
||||
|
||||
it('init() is single-flight — concurrent calls return the same promise', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveGet: ((v: TreeDto) => void) | null = null;
|
||||
api.getData.mockReturnValue(new Promise<TreeDto>((res) => (resolveGet = res)));
|
||||
const store = configure(api);
|
||||
|
||||
const p1 = store.init();
|
||||
const p2 = store.init();
|
||||
resolveGet!({ pages: [] });
|
||||
await Promise.all([p1, p2]);
|
||||
|
||||
expect(api.getData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// ── Debounced save ─────────────────────────────────────────────────────────
|
||||
|
||||
it('debounces saves: multiple mutations within 750ms → one PUT', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('A');
|
||||
store.addPage('B');
|
||||
store.addPage('C');
|
||||
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
const [, tree] = api.putData.mock.calls[0];
|
||||
expect((tree as TreeDto).pages).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('mutation while a save is in-flight triggers a follow-up save', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
let resolveFirstPut: (() => void) | null = null;
|
||||
api.putData
|
||||
.mockReturnValueOnce(new Promise<void>((res) => (resolveFirstPut = () => res())))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('first');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Mutate while first PUT is still hanging.
|
||||
store.addPage('second');
|
||||
|
||||
// Finish the first save → follow-up should be scheduled.
|
||||
resolveFirstPut!();
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
const lastTree = api.putData.mock.calls[1][1] as TreeDto;
|
||||
expect(lastTree.pages).toHaveLength(2);
|
||||
});
|
||||
|
||||
// ── Error handling ────────────────────────────────────────────────────────
|
||||
|
||||
it('marks status "too-large" on 413 and does NOT retry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData.mockRejectedValue(httpError(413));
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('big');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.saveStatus()).toBe('too-large');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('marks status "invalid" on 400 and does NOT retry', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData.mockRejectedValue(httpError(400));
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('bad');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(store.saveStatus()).toBe('invalid');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('honors Retry-After on 429 (uses it as the next delay)', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(429, { 'Retry-After': '2' }))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('x');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
|
||||
// First attempt failed with 429 — we're now in the Retry-After window.
|
||||
expect(store.saveStatus()).toBe('rate-limited');
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Advance 1.9s → still waiting (Retry-After was 2s).
|
||||
await vi.advanceTimersByTimeAsync(1900);
|
||||
expect(api.putData).toHaveBeenCalledTimes(1);
|
||||
|
||||
// The full 2s → retry fires and succeeds.
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await vi.runAllTimersAsync();
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(store.saveStatus()).toBe('saved');
|
||||
});
|
||||
|
||||
it('re-registers and retries on 401 mid-save', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
api.putData
|
||||
.mockRejectedValueOnce(httpError(401))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
store.addPage('x');
|
||||
await vi.advanceTimersByTimeAsync(750);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(api.register).toHaveBeenCalledTimes(1);
|
||||
expect(api.putData).toHaveBeenCalledTimes(2);
|
||||
expect(store.saveStatus()).toBe('saved');
|
||||
});
|
||||
|
||||
// ── switchToken ───────────────────────────────────────────────────────────
|
||||
|
||||
it('switchToken cancels pending writes and does not flush old tree to new account', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
|
||||
// Mutate, then switch BEFORE the debounce fires.
|
||||
store.addPage('old-account');
|
||||
const newToken = 'aaaabbbb-cccc-4ddd-8eee-ffffffffffff';
|
||||
api.getData.mockResolvedValue({ pages: [] });
|
||||
store.switchToken(newToken);
|
||||
|
||||
// Run all timers — the OLD debounce must have been cancelled,
|
||||
// so no PUT should have happened.
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(api.putData).not.toHaveBeenCalled();
|
||||
expect(store.token()).toBe(newToken);
|
||||
});
|
||||
|
||||
it('switchToken rejects a non-UUIDv4 input', () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
|
||||
store.switchToken('not-a-uuid');
|
||||
expect(store.token()).toBe(''); // never initialized
|
||||
});
|
||||
|
||||
// ── Cross-tab sync ────────────────────────────────────────────────────────
|
||||
|
||||
it('adopts a fresh cache written by another tab via the storage event', async () => {
|
||||
storage[TOKEN_KEY] = FIXED_UUID;
|
||||
const api = makeMockApi();
|
||||
const store = configure(api);
|
||||
await store.init();
|
||||
expect(store.pages()).toHaveLength(0);
|
||||
|
||||
const otherTabTree = { pages: [mkPage('from-other-tab')] };
|
||||
window.dispatchEvent(
|
||||
new StorageEvent('storage', {
|
||||
key: CACHE_KEY,
|
||||
newValue: JSON.stringify(otherTabTree),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(store.pages()).toHaveLength(1);
|
||||
expect(store.pages()[0].name).toBe('from-other-tab');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue