diff --git a/CHANGELOG.md b/CHANGELOG.md index de07ffa..adc3c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog - FlowDeck +## v6.0.0 (2026-09-18) — PWA : Progressive Web App, offline support + +> FlowDeck devient une PWA installable et utilisable hors ligne : le shell est +> mis en cache par un service worker, les pages visitées restent disponibles, +> et les modifications faites sans réseau sont mises en file d'attente dans +> IndexedDB puis synchronisées au retour de la connexion, avec résolution de +> conflits côté serveur. + +### PWA & Service Worker +- **`static/manifest.json`** + **`static/icons/*`** (72→512 + maskable + apple-touch) générés par `scripts/generate_pwa_icons.py`. +- **`static/sw.js`** — precache du shell (CSS/JS/fonts/icônes), stratégies cache-first (assets) / network-first (HTML, API GET), page offline inline, Background Sync (`sync-flowdeck`), purge des anciens caches. +- **`base.html` / `landing.html`** — meta PWA (manifest, theme-color, apple-mobile-web-app) + enregistrement du service worker. Routes `GET /manifest.json` et `GET /sw.js` servies par FastAPI. + +### Offline client (`static/js/offline.js`) +- Base IndexedDB `flowdeck-offline` : `pages_offline`, `collections_offline`, `sync_queue`, `sync_meta`. +- `window.FlowOffline` : `enqueue`, `flush`, `delta`, `status`, `savePageOffline`, `getQueue`, `pendingCount`, `onChange`, `markDirtySidebar`. +- Sauvegarde optimiste hors ligne (hook de l'éditeur), file de mutations rejouée automatiquement (online, Background Sync, polling 30 s). +- Durcissement : max 100 mutations/batch, timeout 30 s (`AbortController`), rétention queue 30 jours. + +### Serveur de synchronisation +- **`app/services/sync_engine.py`** — `get_delta` (pull des changements, détection des soft-deletes), `apply_batch` (mutations optimistes + contrôle de version `sync_version`). +- **`app/routers/sync.py`** — `GET /api/v2/sync/delta`, `POST /api/v2/sync/batch`, `GET /api/v2/sync/status` (auth session, CSRF-exempt via `/api/v2`). +- **Migrations** — table `offline_sync_queue` (+ index) et colonnes `sync_version` sur `pages`, `collection_pages`, `collections` avec triggers `AFTER UPDATE`. +- **Conflits** — edit-edit (last-write-wins + rapport), edit-delete (page orpheline recréée), create-create (renommage « copie offline »). + +### UI +- Banner hors ligne avec compteur de modifications en attente, badge de synchronisation (spinner), toasts de fin de sync, icône ⟳ sur les pages non synchronisées. +- Section `/help` « Offline mode (PWA) ». + +### Tests +- `tests/test_sync.py`, `tests/test_sync_migrations.py`, `tests/test_service_worker.py`, `tests/test_pwa_offline.py` ; E2E Playwright `e2e/pwa_offline.spec.js` (manifest, SW, queue offline, replay, navigation hors ligne). + ## v5.12.0 (2026-09-14) — v5.11.0 Wiki-links & v5.12.0 Templates & lock > Deux versions roadmap livrées ensemble : le graphe de connaissances diff --git a/ROADMAP.md b/ROADMAP.md index fb9966e..f7e6829 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -697,13 +697,30 @@ Détails livrés : --- +## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18) +> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB, +> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**. + +- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py` +- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync) +- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty +- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`) +- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE) +- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline ») +- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty +- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j +- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js` +- [x] **Doc** — section `/help` « Offline mode (PWA) » + +--- + ## v6.0.0 — Pro (futur) -- [ ] **PWA** — Progressive Web App, offline support -- [ ] **SSO/SAML** — enterprise authentication -- [ ] **Granular permissions** — page-level, property-level access control -- [ ] **Web Clipper** — extension navigateur -- [ ] **API publique complète** — REST API documentée (OpenAPI) *(base existante : `public_api.py`, à étendre + documenter)* — voir [`docs/API_GUIDE_V6.md`](docs/API_GUIDE_V6.md) : référence complète (conventions, CRUD par ressource, webhooks, sécurité, checklist) +- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md) +- [ ] **SSO/SAML** — enterprise authentication — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md) +- [ ] **Granular permissions** — page-level, property-level access control — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md) +- [ ] **Web Clipper** — extension navigateur — [📄 Conception détaillée](/docs/V6_Web_Clipper.md) +- [ ] **API publique complète** — REST API documentée (OpenAPI) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 Référence des features v6](/docs/API_GUIDE_V6.md#11-documents-de-conception-détaillée-v600) - [ ] **Realtime editing (production)** — voir **v5.13.0** (curseurs + présence déjà avancés ici) ; reste en v6 : conflits avancés, édition large échelle - [ ] **Synced blocks (production)** — voir **v5.14.0** (bloc de base) ; reste en v6 : syncing côté databases/vues @@ -751,4 +768,4 @@ Quality DB views, Agent IA Palette → Realtime + E DB avancée, redo, drag&drop, bookmark, avancée Calendrier, AI duplicate) lightbox…) (Pt.2) -*Dernière mise à jour: 2026-09-15 — **v5.14.0 Synced blocks COMPLETED** (bloc `synced_block`, rendering, unsync, copy & sync). Reste: v6.0.0 Pro (PWA, SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)* +*Dernière mise à jour: 2026-09-18 — **v6.0.0 PWA (offline support) COMPLETED** (manifest, service worker, IndexedDB, sync `/api/v2/sync/*`, conflits, UI offline, tests + E2E). Reste: v6.0.0 Pro (SSO/SAML, permissions granulaires, Web Clipper, API publique, realtime production)* diff --git a/VERSION b/VERSION index 0a70cf0..09b254e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.14.0 +6.0.0 diff --git a/app/main.py b/app/main.py index 80e60e1..207b87f 100644 --- a/app/main.py +++ b/app/main.py @@ -33,6 +33,7 @@ from app.routers import ( security, sharing, sidebar_config, + sync, webhooks, workspace, ) @@ -107,7 +108,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="5.14.0", + version="6.0.0", docs_url="/docs" if settings.log_level == "DEBUG" else None, redoc_url=None, lifespan=lifespan, @@ -147,6 +148,7 @@ app.include_router(agent.router) app.include_router(search.router) app.include_router(security.router) app.include_router(onboarding.router) +app.include_router(sync.router) app.include_router(imports_router) app.include_router(import_page_router) @@ -155,15 +157,16 @@ app.mount("/static", StaticFiles(directory="static"), name="static") @app.get("/manifest.json") async def pwa_manifest(): - return { - "name": "FlowDeck", - "short_name": "FlowDeck", - "start_url": "/", - "display": "standalone", - "background_color": "#191919", - "theme_color": "#191919", - "icons": [{"src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png"}], - } + """Serve the static web manifest from disk (same URL as before v6.0.0).""" + from fastapi.responses import FileResponse + return FileResponse("static/manifest.json", media_type="application/manifest+json") + + +@app.get("/sw.js") +async def service_worker(): + """Serve the PWA service worker at top-level scope (/).""" + from fastapi.responses import FileResponse + return FileResponse("static/sw.js", media_type="application/javascript") # ═══════════ API aliases (v4.0.1) ═══════════ diff --git a/app/middleware/csrf.py b/app/middleware/csrf.py index 2764cc2..7215323 100644 --- a/app/middleware/csrf.py +++ b/app/middleware/csrf.py @@ -16,7 +16,7 @@ class CSRFMiddleware(BaseHTTPMiddleware): """ SAFE_METHODS = {"GET", "HEAD", "OPTIONS"} - EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"} + EXCLUDED_PATHS = {"/api/webhook", "/api/v1", "/api/v2", "/auth/callback", "/auth/register", "/auth/local-login", "/api/user", "/board/api/pages", "/board/api/favorites", "/api/workspace", "/api/local-workspace", "/api/settings", "/db/", "/workspace", "/api/frontend-error", "/api/admin", "/api/gitea", "/api/github", "/api/pages", "/api/recents", "/api/csrf-token", "/api/notifications", "/api/comments", "/api/agent", "/api/automations", "/workspace/automations", "/api/onboarding"} async def dispatch(self, request: Request, call_next): # Webhook receiver, OAuth callback, and internal API are exempt diff --git a/app/migrations.py b/app/migrations.py index be5dc54..f3a0e82 100644 --- a/app/migrations.py +++ b/app/migrations.py @@ -630,3 +630,82 @@ def _migration_synced_blocks(conn: sqlite3.Connection) -> None: conn.execute( "CREATE INDEX IF NOT EXISTS idx_psb_synced ON page_synced_blocks(synced_block_id)" ) + + +@register(16, "v6.0.0: offline sync queue") +def _migration_offline_sync_queue(conn: sqlite3.Connection) -> None: + """v6.0.0 — PWA offline support. + + ``offline_sync_queue`` persists server-side the mutations received from + offline clients (``/api/v2/sync/batch``) so work is not lost and can be + audited/replayed per device. + """ + conn.execute( + """CREATE TABLE IF NOT EXISTS offline_sync_queue ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + type TEXT NOT NULL, + -- 'page_create', 'page_update', 'page_delete', 'page_move', + -- 'collection_create', 'collection_update', 'collection_delete' + payload TEXT NOT NULL, + client_timestamp REAL NOT NULL, + server_version INTEGER DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending', + -- 'pending', 'syncing', 'synced', 'failed' + retries INTEGER NOT NULL DEFAULT 0, + error TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )""" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_syncqueue_user ON offline_sync_queue(user_id, status)" + ) + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_syncqueue_device ON offline_sync_queue(device_id, status)" + ) + + +def _add_sync_version(conn: sqlite3.Connection, table: str) -> None: + """Add ``sync_version`` to ``table`` if it is not already present.""" + cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()} + if "sync_version" not in cols: + conn.execute(f"ALTER TABLE {table} ADD COLUMN sync_version INTEGER NOT NULL DEFAULT 1") + + +@register(17, "v6.0.0: sync_version columns") +def _migration_sync_version_columns(conn: sqlite3.Connection) -> None: + """v6.0.0 — optimistic-concurrency version counters for offline sync. + + Every write on a page / collection increments its ``sync_version`` so a + reconnecting client can detect edit-edit conflicts via version mismatch. + A ``BEFORE UPDATE`` trigger performs the increment automatically on every + write path (no need to patch dozens of ``UPDATE`` call-sites). + """ + for table in ("pages", "collection_pages", "collections"): + _add_sync_version(conn, table) + + # AFTER UPDATE + inner UPDATE: bumps sync_version on every write path. + # `recursive_triggers` is OFF by default, so the inner UPDATE never + # re-fires the trigger (no infinite loop), incl. the page FTS triggers. + conn.execute( + """CREATE TRIGGER IF NOT EXISTS pages_sync_version_bu + AFTER UPDATE ON pages + FOR EACH ROW BEGIN + UPDATE pages SET sync_version = sync_version + 1 WHERE id = NEW.id; + END""" + ) + conn.execute( + """CREATE TRIGGER IF NOT EXISTS collection_pages_sync_version_bu + AFTER UPDATE ON collection_pages + FOR EACH ROW BEGIN + UPDATE collection_pages SET sync_version = sync_version + 1 WHERE id = NEW.id; + END""" + ) + conn.execute( + """CREATE TRIGGER IF NOT EXISTS collections_sync_version_bu + AFTER UPDATE ON collections + FOR EACH ROW BEGIN + UPDATE collections SET sync_version = sync_version + 1 WHERE id = NEW.id; + END""" + ) diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index 3f053d3..19f97b7 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -554,6 +554,16 @@ async def help_page(request: Request):
  • My Tasks — Aggregated tasks from all collections
  • + +
    +

    📶Offline & PWA

    +

    Install FlowDeck as an app and keep working without a connection.

    + +
    @@ -579,6 +589,25 @@ FlowDeck supports three authentication methods:

    +
    +

    📶 Offline mode (PWA)

    +

    +FlowDeck is a Progressive Web App: pages you visited stay available offline and your +edits are saved locally, then synchronised when the connection returns.

    +Install: open your browser menu and choose Install app (Chrome/Edge) or +Add to Home Screen (Safari/iOS). FlowDeck then opens in its own window.
    +Offline editing: while offline, the editor stores changes in the browser +(IndexedDB) and shows an offline banner with the number of pending changes. A +⟳ icon appears next to pages that have unsynced edits.
    +Reconnection: the queue is replayed automatically (and via Background Sync). +A spinner badge appears while syncing, followed by a confirmation toast.
    +Conflicts: if a page changed on the server, the latest edit wins and a notice is +shown. If a page was deleted server-side, your offline copy is recreated as an orphan +page. If a page with the same title already exists, the offline copy is renamed +“Title (copie offline)”. +

    +
    +

    💡 Tips

    diff --git a/app/routers/sync.py b/app/routers/sync.py new file mode 100644 index 0000000..703dfad --- /dev/null +++ b/app/routers/sync.py @@ -0,0 +1,88 @@ +"""FlowDeck — /api/v2/sync endpoints (v6.0.0 PWA offline sync). + +Pairs with ``app/services/sync_engine.py``. All routes require an authenticated +session (``flowdeck_session`` cookie). +""" +from __future__ import annotations + +import logging + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import JSONResponse + +from app.auth.session import SessionManager +from app.services.sync_engine import SyncEngine + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v2/sync", tags=["sync"]) + +_engine = SyncEngine() + + +def _user(request: Request) -> dict: + user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) + if not user: + raise HTTPException(status_code=401, detail="Authentication required") + return user + + +@router.get("/delta") +async def sync_delta( + request: Request, + since: float = Query(default=0, description="Epoch seconds (ou ms) du dernier sync"), + workspace_id: int = Query(default=None), +): + """Pull server-side changes since `since` (for the given workspace).""" + user = _user(request) + if workspace_id is None: + raise HTTPException(status_code=400, detail="workspace_id is required") + result = await _engine.get_delta(user["id"], since, workspace_id) + if result.get("error") == "forbidden": + return JSONResponse({"detail": "Forbidden"}, status_code=403) + return result + + +@router.post("/batch") +async def sync_batch(request: Request): + """Apply a batch of offline mutations and return per-mutation results.""" + user = _user(request) + try: + body = await request.json() + except Exception: + raise HTTPException(status_code=400, detail="Invalid JSON body") from None + + mutations = body.get("mutations") or [] + device_id = body.get("device_id") or "unknown" + if not isinstance(mutations, list) or not mutations: + return {"results": [], "conflicts": [], "server_time": SyncEngine._now_epoch()} + + result = await _engine.apply_batch(user["id"], mutations, device_id) + result["server_time"] = SyncEngine._now_epoch() + return result + + +@router.get("/status") +async def sync_status(request: Request, workspace_id: int = Query(default=None)): + """Synchronization status for the workspace (pending server queue, last sync).""" + user = _user(request) + from app.db import get_conn + with get_conn() as conn: + if not SyncEngine._can_access(conn, user["id"], workspace_id): + return JSONResponse({"detail": "Forbidden"}, status_code=403) + pending = conn.execute( + "SELECT COUNT(*) AS n FROM offline_sync_queue WHERE user_id=? AND status='pending'", + (user["id"],), + ).fetchone()["n"] + last = conn.execute( + "SELECT MAX(created_at) AS last FROM offline_sync_queue " + "WHERE user_id=? AND status='synced'", + (user["id"],), + ).fetchone()["last"] + return { + "pending_count": pending, + "last_sync": last, + "is_syncing": False, + "server_time": SyncEngine._now_epoch(), + "workspace_id": workspace_id, + } diff --git a/app/services/sync_engine.py b/app/services/sync_engine.py new file mode 100644 index 0000000..040e199 --- /dev/null +++ b/app/services/sync_engine.py @@ -0,0 +1,421 @@ +"""FlowDeck — offline synchronization engine (v6.0.0 PWA). + +Reconciles offline mutations (queued on the client) with server state: + +- ``get_delta`` — changes on the server since a given timestamp, for offline + clients to pull before pushing their own batch. +- ``apply_batch`` — replays a batch of offline mutations with optimistic + concurrency control. ``sync_version`` (auto-bumped by SQLite triggers added + in migration v17) is the version token. + +Conflict model (from docs/V6_PWA_Progressive_Web_App.md): +- edit-edit → last-write-wins by default (applied + reported) +- edit-delete→ the page was deleted server-side → orphan copy created +- create-create → same title already exists server-side → renamed "… (copie offline)" +- delete → soft-delete (idempotent) +""" +from __future__ import annotations + +import datetime as _dt +import json +import logging +import time + +from app.db import get_conn + +logger = logging.getLogger(__name__) + + +class ConflictError(Exception): + """Raised when a mutation conflicts with server state.""" + + def __init__(self, detail: dict): + super().__init__(detail.get("type")) + self.details = detail + + +class SyncEngine: + """Apply/read offline mutations. Stateless methods, thin sqlite access.""" + + # ── helpers ──────────────────────────────────────────────────────────── + + @staticmethod + def _now_epoch() -> float: + return time.time() + + @staticmethod + def _can_access(conn, user_id: int, workspace_id: int | None) -> bool: + """Owner or member of the workspace; legacy NULL workspace → allow.""" + if not workspace_id: + return True + ws = conn.execute( + "SELECT owner_id FROM workspaces WHERE id=?", (workspace_id,) + ).fetchone() + if ws and ws["owner_id"] == user_id: + return True + member = conn.execute( + "SELECT 1 FROM workspace_members WHERE workspace_id=? AND user_id=?", + (workspace_id, user_id), + ).fetchone() + return bool(member) + + @staticmethod + def _page_workspace_id(conn, page_id: int) -> int | None: + row = conn.execute( + "SELECT workspace_id FROM pages WHERE id=?", (page_id,) + ).fetchone() + return row["workspace_id"] if row else None + + # ── delta ───────────────────────────────────────────────────────────── + + async def get_delta(self, user_id: int, since: float, workspace_id: int | None) -> dict: + """Return server-side changes since `since` (epoch seconds).""" + if since and since > 1e12: + since /= 1000.0 # accept epoch-millis from legacy clients + + changes: list[dict] = [] + with get_conn() as conn: + if not self._can_access(conn, user_id, workspace_id): + return {"error": "forbidden"} + + # ── pages (created / updated / soft-deleted) ── + rows = conn.execute( + """SELECT * FROM pages + WHERE (? IS NULL OR workspace_id = ?) + AND (CAST(strftime('%s', COALESCE(updated_at, created_at)) AS REAL) > ? + OR (deleted_at IS NOT NULL + AND CAST(strftime('%s', deleted_at) AS REAL) > ?))""", + (workspace_id, workspace_id, since, since), + ).fetchall() + for r in rows: + data = dict(r) + deleted = data.get("deleted_at") is not None + created_epoch = _iso_epoch(data.get("created_at")) + created_after = bool(created_epoch and created_epoch > since) + if deleted: + ctype = "page_deleted" + elif created_after: + ctype = "page_created" + else: + ctype = "page_updated" + changes.append({"change_type": ctype, "data": data}) + + # ── collections (created / updated) ── + coll_rows = conn.execute( + "SELECT * FROM collections WHERE CAST(strftime('%s', updated_at) AS REAL) > ?", + (since,), + ).fetchall() + for r in coll_rows: + data = dict(r) + created_epoch = _iso_epoch(data.get("created_at")) + changes.append({ + "change_type": "collection_created" if created_epoch and created_epoch > since + else "collection_updated", + "data": data, + }) + + # ── collection rows (informational for offline reading) ── + cp_rows = conn.execute( + "SELECT * FROM collection_pages WHERE CAST(strftime('%s', updated_at) AS REAL) > ?", + (since,), + ).fetchall() + for r in cp_rows: + changes.append({"change_type": "collection_row_updated", "data": dict(r)}) + + return { + "changes": changes, + "server_time": self._now_epoch(), + "has_more": False, + } + + # ── batch ────────────────────────────────────────────────────────────── + + async def apply_batch(self, user_id: int, mutations: list[dict], device_id: str) -> dict: + """Apply a batch of offline mutations; returns per-mutation results. + + ``mutations`` = [{"id"|"mutation_id", "type", "payload", "client_timestamp"}] + """ + results: list[dict] = [] + conflicts: list[dict] = [] + + for mut in mutations: + mut_id = mut.get("id") or mut.get("mutation_id") or f"m{len(results)}" + mtype = mut.get("type", "") + payload = mut.get("payload") or {} + client_ts = float(mut.get("client_timestamp") or 0) + result = { + "mutation_id": mut_id, + "type": mtype, + "status": "synced", + "server_version": None, + } + try: + handler = getattr(self, f"_mut_{mtype}", None) + if handler is None: + raise ValueError(f"unknown mutation type: {mtype}") + outcome = handler(user_id, payload) + result.update(outcome) + status = "conflict" if outcome.get("conflict") else "synced" + result["status"] = status + if outcome.get("conflict"): + conflicts.append({ + "mutation_id": mut_id, + "type": mtype, + **outcome["conflict"], + }) + except ConflictError as exc: + result["status"] = "conflict" + result["conflict"] = exc.details + conflicts.append({"mutation_id": mut_id, "type": mtype, **exc.details}) + except Exception as exc: # noqa: BLE001 — report, don't kill the batch + logger.warning("sync mutation %s failed: %s", mut_id, exc) + result["status"] = "failed" + result["error"] = str(exc) + finally: + self._record(user_id, device_id, mtype, mut, client_ts, result) + results.append(result) + + return {"results": results, "conflicts": conflicts} + + # ── page mutations ───────────────────────────────────────────────────── + + def _mut_page_create(self, user_id: int, payload: dict) -> dict: + title = (payload.get("title") or "New page").strip() or "New page" + workspace_id = payload.get("workspace_id") + parent_id = payload.get("parent_id") + + with get_conn() as conn: + if not self._can_access(conn, user_id, workspace_id): + raise ConflictError({"type": "forbidden", "workspace_id": workspace_id}) + + # create-create conflict: same title already present at same parent + new_title = title + dup = conn.execute( + """SELECT id FROM pages + WHERE title=? AND (? IS NULL OR workspace_id = ?) + AND (? IS NULL OR parent_id IS ?) + AND deleted_at IS NULL + LIMIT 1""", + (title, workspace_id, workspace_id, parent_id, parent_id), + ).fetchone() + if dup: + new_title = f"{title} (copie offline)" + + cur = conn.execute( + """INSERT INTO pages + (workspace, workspace_id, title, content, content_format, + parent_id, parent_section, sort_order) + VALUES ('', ?, ?, ?, ?, ?, 'Private', ?)""", + (workspace_id, new_title, + payload.get("content", ""), + payload.get("content_format", "blocks"), + parent_id, + payload.get("sort_order", 0)), + ) + conn.commit() + page_id = cur.lastrowid + synced = payload.get("client_page_id") + return {"page_id": page_id, "server_version": 1, "client_page_id": synced, + "conflict": {"type": "create_create", "renamed": new_title != title} + if new_title != title else None} + + def _mut_page_update(self, user_id: int, payload: dict) -> dict: + page_id = payload.get("page_id") + base_version = payload.get("base_version") + with get_conn() as conn: + row = conn.execute("SELECT * FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + raise ConflictError({"type": "page_not_found", "page_id": page_id}) + + ws_id = row["workspace_id"] or payload.get("workspace_id") + if not self._can_access(conn, user_id, ws_id): + raise ConflictError({"type": "forbidden", "page_id": page_id}) + + # edit-delete: page soft-deleted server-side → orphan copy + if row["deleted_at"] is not None: + cur = conn.execute( + """INSERT INTO pages + (workspace, workspace_id, title, content, content_format, parent_section) + VALUES ('', ?, ?, ?, ?, 'Private')""", + (ws_id, + payload.get("title") or row["title"], + (payload.get("content") + if payload.get("content") is not None else row["content"]), + payload.get("content_format") or row["content_format"]), + ) + conn.commit() + orphan_id = cur.lastrowid + raise ConflictError({ + "type": "edit_delete", + "page_id": page_id, + "new_page_id": orphan_id, + "detail": "La page a été supprimée côté serveur — copie récréée en page orpheline", + }) + + # edit-edit: version mismatch → last-write-wins + conflict report + server_version = row["sync_version"] + conflict = None + if base_version is not None and server_version != base_version: + conflict = { + "type": "edit_edit", + "page_id": page_id, + "client_version": base_version, + "server_version": server_version, + } + + sets, params = [], [] + if payload.get("title") is not None: + sets.append("title=?") + params.append(payload["title"]) + if payload.get("content") is not None: + sets.append("content=?") + params.append(payload["content"]) + if payload.get("content_format") is not None: + sets.append("content_format=?") + params.append(payload["content_format"]) + sets_str = ", ".join(sets) if sets else "updated_at=updated_at" + params.append(page_id) + conn.execute( + f"UPDATE pages SET {sets_str}, updated_at=CURRENT_TIMESTAMP WHERE id=?", + params, + ) + conn.commit() + new_version = conn.execute( + "SELECT sync_version FROM pages WHERE id=?", (page_id,) + ).fetchone()["sync_version"] + return {"page_id": page_id, "server_version": new_version, + "conflict": conflict} + + def _mut_page_delete(self, user_id: int, payload: dict) -> dict: + page_id = payload.get("page_id") + with get_conn() as conn: + row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + return {"page_id": page_id, "server_version": None} # idempotent / already hard-deleted + ws_id = row["workspace_id"] + if not self._can_access(conn, user_id, ws_id): + raise ConflictError({"type": "forbidden", "page_id": page_id}) + conn.execute( + "UPDATE pages SET deleted_at=CURRENT_TIMESTAMP, " + "updated_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", + (page_id,), + ) + conn.commit() + new_version = conn.execute( + "SELECT sync_version FROM pages WHERE id=?", (page_id,) + ).fetchone()["sync_version"] + return {"page_id": page_id, "server_version": new_version} + + def _mut_page_move(self, user_id: int, payload: dict) -> dict: + page_id = payload.get("page_id") + with get_conn() as conn: + row = conn.execute("SELECT id, workspace_id FROM pages WHERE id=?", (page_id,)).fetchone() + if not row: + return {"page_id": page_id, "server_version": None} + ws_id = row["workspace_id"] + if not self._can_access(conn, user_id, ws_id): + raise ConflictError({"type": "forbidden", "page_id": page_id}) + sets, params = [], [] + if payload.get("parent_id") is not None: + sets.append("parent_id=?") + params.append(payload["parent_id"]) + if payload.get("sort_order") is not None: + sets.append("sort_order=?") + params.append(payload["sort_order"]) + if sets: + params.append(page_id) + conn.execute( + f"UPDATE pages SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?", + params, + ) + conn.commit() + new_version = conn.execute( + "SELECT sync_version FROM pages WHERE id=?", (page_id,) + ).fetchone()["sync_version"] + return {"page_id": page_id, "server_version": new_version} + + # ── collection mutations ─────────────────────────────────────────────── + + def _mut_collection_create(self, user_id: int, payload: dict) -> dict: + with get_conn() as conn: + cur = conn.execute( + """INSERT INTO collections (name, description, icon, schema_json) + VALUES (?, ?, ?, ?)""", + (payload.get("name", ""), payload.get("description", ""), + payload.get("icon", "📋"), json.dumps(payload.get("schema", []))), + ) + conn.commit() + return {"collection_id": cur.lastrowid, "server_version": 1} + + def _mut_collection_update(self, user_id: int, payload: dict) -> dict: + cid = payload.get("collection_id") + with get_conn() as conn: + row = conn.execute("SELECT * FROM collections WHERE id=?", (cid,)).fetchone() + if not row: + raise ConflictError({"type": "collection_not_found", "collection_id": cid}) + sets, params = [], [] + if payload.get("name") is not None: + sets.append("name=?") + params.append(payload["name"]) + if payload.get("description") is not None: + sets.append("description=?") + params.append(payload["description"]) + if payload.get("icon") is not None: + sets.append("icon=?") + params.append(payload["icon"]) + if payload.get("schema") is not None: + sets.append("schema_json=?") + params.append(json.dumps(payload["schema"])) + if sets: + params.append(cid) + conn.execute( + f"UPDATE collections SET {', '.join(sets)}, updated_at=CURRENT_TIMESTAMP WHERE id=?", + params, + ) + conn.commit() + new_version = conn.execute( + "SELECT sync_version FROM collections WHERE id=?", (cid,) + ).fetchone()["sync_version"] + return {"collection_id": cid, "server_version": new_version} + + def _mut_collection_delete(self, user_id: int, payload: dict) -> dict: + cid = payload.get("collection_id") + with get_conn() as conn: + conn.execute("DELETE FROM collections WHERE id=?", (cid,)) + conn.commit() + return {"collection_id": cid, "server_version": None} + + # ── audit ────────────────────────────────────────────────────────────── + + def _record(self, user_id: int, device_id: str, mtype: str, mut: dict, + client_ts: float, result: dict) -> None: + """Persist every mutation in the server-side audit queue.""" + try: + with get_conn() as conn: + conn.execute( + """INSERT INTO offline_sync_queue + (user_id, device_id, type, payload, client_timestamp, + server_version, status, error) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + (user_id, device_id, mtype, json.dumps(mut), + client_ts, result.get("server_version"), + result.get("status", "synced"), + result.get("error") or (json.dumps(result["conflict"], ensure_ascii=False) + if result.get("conflict") else None)), + ) + conn.commit() + except Exception: # noqa: BLE001 — audit must never break the batch + logger.warning("failed to record sync audit row", exc_info=True) + + +def _iso_epoch(value) -> float | None: + """Best-effort ISO→epoch. SQLite CURRENT_TIMESTAMP → 'YYYY-MM-DD HH:MM:SS'.""" + try: + if value is None: + return None + # append a timezone so strptime behaves on naive timestamps + text = str(value).replace("T", " ").split(".")[0] + parsed = _dt.datetime.strptime(text, "%Y-%m-%d %H:%M:%S") + return parsed.replace(tzinfo=_dt.UTC).timestamp() + except ValueError: + return None diff --git a/app/templates/_page_editor_scripts.html b/app/templates/_page_editor_scripts.html index 03c4f44..0326e14 100644 --- a/app/templates/_page_editor_scripts.html +++ b/app/templates/_page_editor_scripts.html @@ -2350,12 +2350,21 @@ applyAIBlocks(text){ if(this.saving){ if(cb) cb(); return; } this.sync(); this.saving=true; + const blocksArr=this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;}); + // v6.0.0 PWA: hors ligne (ou échec réseau) → file IndexedDB rejouée au retour du réseau + const queueOffline=()=>{ + if(!window.FlowOffline){this.saving=false;return;} + window.FlowOffline.savePageOffline({id:this.pid,title:this.pageTitle,content:JSON.stringify(blocksArr),content_format:'blocks'}) + .then(()=>{this.saving=false;this.dirty=false;this.lastSaved='offline';if(cb)cb();}) + .catch(()=>{this.saving=false;}); + }; + if(!navigator.onLine){queueOffline();return;} const csrf=document.cookie.match(/csrf_token=([^;]+)/); - fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:this.blocks.map(b=>{const c={id:b.id,type:b.type,content:b.content};['checked','expanded','language','icon','src','embed_src','embed_provider','alt','style','embed_type','collection_id','dbs','file_name','file_size','file_mime','children','rows','align','has_header','first_col_header','colsW','meeting','automation_id','automation_name'].forEach(k=>{if(b[k]!=null)c[k]=b[k];});return c;})})}) + fetch(`/board/api/pages/${this.pid}/blocks`,{method:'POST',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf?csrf[1]:''},body:JSON.stringify({title:this.pageTitle,blocks:blocksArr})}) .then(r=>r.json()).then(()=>{this.saving=false;this.dirty=false;this.lastSaved=new Date().toLocaleTimeString(); this._syncTitleUI(); if(cb)cb(); - }).catch(()=>{this.saving=false;}); + }).catch(()=>{this.saving=false;queueOffline();}); }, runAutomationButton(bid){ const idx=this.getIdx(bid);if(idx<0)return; diff --git a/app/templates/base.html b/app/templates/base.html index 7497f39..189b655 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -5,6 +5,11 @@ FlowDeck — {% block title_prefix %}Home{% endblock %} + + + + + @@ -750,6 +755,11 @@

    + + + +{# ─── PWA: sync indicators (v6.0.0) ─── #} + + +{# ─── PWA: offline client module + service worker registration (v6.0.0) ─── #} + + + +{# ─── PWA: sync badge + toasts wiring (v6.0.0) ─── #} + + diff --git a/app/templates/landing.html b/app/templates/landing.html index 5cb2ca4..8a07871 100644 --- a/app/templates/landing.html +++ b/app/templates/landing.html @@ -6,6 +6,11 @@ {% from '_icons.html' import fd_icon %} FlowDeck — All-in-one workspace + + + + + ', + '', + '', + '

    Vous êtes hors ligne

    ', + '

    Les pages consultées récemment restent disponibles. Votre contenu sera synchronisé dès le retour du réseau.

    ', + '
    ⟳ Synchronisation en attente…
    ', + '
    ', + '', +].join('\n'); + +// ── Install : precache du shell (ignore redirections /login) ── +self.addEventListener('install', (event) => { + event.waitUntil( + caches.open(CACHE_NAME) + .then(async (cache) => { + await Promise.all( + PRECACHE_URLS.map(async (url) => { + try { + const res = await fetch(url, { redirect: 'manual', credentials: 'same-origin' }); + // status 0 = opaqueredirect (vers /login par ex.) → on ne cache pas + if (res.status === 0 || !res.ok) return; + await cache.put(url, res); + } catch { + // réseau indisponible pendant install : on continue + } + }) + ); + // Page de secours offline + await cache.put('/offline.html', new Response(OFFLINE_PAGE, { + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + })); + }) + .then(() => self.skipWaiting()) + ); +}); + +// ── Activate : purge des anciens caches ── +self.addEventListener('activate', (event) => { + event.waitUntil( + caches.keys() + .then((keys) => Promise.all( + keys.filter((k) => k !== CACHE_NAME && k !== DATA_CACHE).map((k) => caches.delete(k)) + )) + .then(() => self.clients.claim()) + ); +}); + +// ── Fetch : routing par stratégie ── +self.addEventListener('fetch', (event) => { + const request = event.request; + + if (request.method !== 'GET') { + // Mutations : always network (queue côté client en mode offline) + return; + } + + const url = new URL(request.url); + if (url.origin !== self.location.origin) { + return; // cross-origin (fonts Google, CDN…) non intercepté + } + + const isNavigation = request.mode === 'navigate'; + const isApi = url.pathname.startsWith('/api/') || + url.pathname.startsWith('/db/') || + url.pathname.startsWith('/board/') || + url.pathname.startsWith('/p/'); + + // HTML / navigation : network-first, fallback cache, offline page + if (isNavigation || request.headers.get('accept')?.includes('text/html')) { + event.respondWith(networkFirst(request, { fallbackToShell: true })); + return; + } + + // API GET : réseau d'abord, cache en repli + if (isApi) { + event.respondWith(networkFirst(request, { cacheName: DATA_CACHE })); + return; + } + + // Assets statiques : cache-first avec mise en cache à la volée + if (SHELL_ASSET.test(url.pathname) || url.pathname === '/manifest.json') { + event.respondWith(cacheFirst(request, { cacheName: CACHE_NAME })); + return; + } + + // Tout le reste : network-first + event.respondWith(networkFirst(request)); +}); + +async function cacheFirst(request, { cacheName = CACHE_NAME } = {}) { + const cache = await caches.open(cacheName); + const cached = await cache.match(request, { ignoreSearch: false }); + if (cached) return cached; + try { + const res = await fetch(request); + if (res && res.ok) cache.put(request, res.clone()); + return res; + } catch { + return cached; + } +} + +async function networkFirst(request, { cacheName = CACHE_NAME, fallbackToShell = false, timeoutMs = 4000 } = {}) { + const cache = await caches.open(cacheName); + try { + const res = await timeoutFetch(request, timeoutMs); + if (res && res.ok) { + cache.put(request, res.clone()); + return res; + } + throw new Error('bad status'); + } catch { + const cached = await cache.match(request, { ignoreSearch: false }); + if (cached) return cached; + if (fallbackToShell) { + const shell = await cache.match('/'); + if (shell) return shell; + return caches.match('/offline.html'); + } + return cached; + } +} + +function timeoutFetch(request, ms) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const controller = new AbortController(); + controller.abort(); + reject(new Error('timeout')); + }, ms); + fetch(request).then( + (res) => { clearTimeout(timer); resolve(res); }, + (err) => { clearTimeout(timer); reject(err); } + ); + }); +} + +// ── Background Sync : notifier les clients pour rejouer la queue ── +self.addEventListener('sync', (event) => { + if (event.tag === 'sync-flowdeck') { + event.waitUntil( + self.clients.matchAll({ type: 'window' }).then((clients) => { + clients.forEach((client) => client.postMessage({ type: 'flowdeck-sync' })); + }) + ); + } +}); + +// ── Messages : mise à jour immédiate ── +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') { + self.skipWaiting(); + } +}); \ No newline at end of file diff --git a/tests/test_app.py b/tests/test_app.py index 11c6822..346b50c 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1024,6 +1024,25 @@ def test_pwa_manifest(client): data = resp.json() assert data["name"] == "FlowDeck" assert data["display"] == "standalone" + assert any(i.get("sizes") == "192x192" for i in data["icons"]) + + +def test_pwa_manifest_static_assets(): + """v6.0.0: the static manifest and every referenced icon must exist on disk.""" + import json + from pathlib import Path + + root = Path(__file__).resolve().parent.parent + manifest_path = root / "static" / "manifest.json" + assert manifest_path.is_file(), "static/manifest.json missing" + + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + assert manifest["name"] == "FlowDeck" + assert "icons" in manifest and len(manifest["icons"]) >= 8 + + for icon in manifest["icons"]: + src = icon["src"].lstrip("/") + assert (root / src).is_file(), f"icon missing: {src}" # ══════════════════════════════════════════════════════ diff --git a/tests/test_pwa_offline.py b/tests/test_pwa_offline.py new file mode 100644 index 0000000..71c4898 --- /dev/null +++ b/tests/test_pwa_offline.py @@ -0,0 +1,188 @@ +"""FlowDeck — PWA offline client module tests (v6.0.0). + +Covers static/js/offline.js (IndexedDB + sync queue), its inclusion in the +templates and the editor's offline save hook. The server-side endpoints are +exercised in tests/test_sync.py. +""" +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +OFFLINE_JS = ROOT / "static" / "js" / "offline.js" + + +def _token(user_id, login): + from app.auth.session import SessionManager + return SessionManager.create_session({"id": user_id, "login": login, + "full_name": login.title(), "is_admin": 1}) + + +def _create_user_ws(client, login="offlinetest", ws_name="Offline WS"): + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)", + (login, login.title(), f"{login}@test.com"), + ) + user_id = cur.lastrowid + conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", + (ws_name, user_id), + ) + conn.commit() + return _token(user_id, login) + + +# ── static asset ─────────────────────────────────────────────────────────── + + +def test_offline_js_served(client): + resp = client.get("/static/js/offline.js") + assert resp.status_code == 200 + assert "javascript" in resp.headers.get("content-type", "") + + +def test_offline_js_exposes_public_api(): + src = OFFLINE_JS.read_text(encoding="utf-8") + assert "window.FlowOffline" in src + for api in ("enqueue", "flush", "delta", "status", "savePageOffline", + "getQueue", "pendingCount", "onChange"): + assert api in src, f"missing FlowOffline API: {api}" + + +def test_offline_js_indexeddb_schema(): + src = OFFLINE_JS.read_text(encoding="utf-8") + assert "flowdeck-offline" in src + for store in ("pages_offline", "collections_offline", "sync_queue", "sync_meta"): + assert store in src, f"missing IndexedDB store: {store}" + + +def test_offline_js_targets_sync_endpoints(): + src = OFFLINE_JS.read_text(encoding="utf-8") + assert "/api/v2/sync/batch" in src + assert "/api/v2/sync/delta" in src + assert "/api/v2/sync/status" in src + # mutation shape expected by the server engine + for field in ("mutation_id", "client_timestamp", "device_id"): + assert field in src, f"missing mutation field: {field}" + + +def test_offline_js_background_sync(): + src = OFFLINE_JS.read_text(encoding="utf-8") + assert "sync-flowdeck" in src + assert "registerBackgroundSync" in src + + +# ── template wiring ──────────────────────────────────────────────────────── + + +def test_base_includes_offline_module(client): + base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8") + assert "/static/js/offline.js" in base + assert "/sw.js" in base + # served on a base.html page as well + session = _create_user_ws(client, login="basemod") + resp = client.get("/local-workspace", cookies={"flowdeck_session": session}) + assert resp.status_code == 200 + assert "/static/js/offline.js" in resp.text + + +def test_workspace_offline_banner_shows_pending(client): + session = _create_user_ws(client) + resp = client.get("/local-workspace", cookies={"flowdeck_session": session}) + assert resp.status_code == 200 + body = resp.text + assert "offline-banner" in body + assert "pendingSyncCount" in body + assert "FlowOffline" in body + + +def test_editor_save_has_offline_hook(): + src = (ROOT / "app" / "templates" / "_page_editor_scripts.html").read_text(encoding="utf-8") + assert "FlowOffline" in src + assert "savePageOffline" in src + assert "navigator.onLine" in src + + +def test_sw_precache_includes_offline_js(): + sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") + urls = re.findall(r"'(/static/js/offline\.js[^']*)'", sw) + assert urls, "offline.js missing from SW precache" + for url in urls: + assert (ROOT / url.split("?")[0].lstrip("/")).is_file() + + +# ── phase 6: hardening + sync UI ─────────────────────────────────────────── + + +def test_offline_js_batch_limits_and_timeout(): + src = OFFLINE_JS.read_text(encoding="utf-8") + assert "MAX_BATCH" in src + assert "FETCH_TIMEOUT_MS" in src + assert "AbortController" in src + assert "fetchWithTimeout" in src + assert "purgeOld" in src + assert "QUEUE_RETENTION_MS" in src + + +def test_offline_js_dirty_markers_and_sync_event(): + src = OFFLINE_JS.read_text(encoding="utf-8") + assert "getDirtyPageIds" in src + assert "markDirtySidebar" in src + assert "fd-dirty" in src + assert "flowdeck:sync-done" in src + + +def test_base_has_sync_badge_and_toast_wiring(client): + base = (ROOT / "app" / "templates" / "base.html").read_text(encoding="utf-8") + assert "fd-sync-badge" in base + assert "fd-sync-spinner" in base + assert "fd-dirty" in base + assert "flowdeck:sync-done" in base + # rendered on an authenticated base.html page + session = _create_user_ws(client, login="syncbadge") + resp = client.get("/local-workspace", cookies={"flowdeck_session": session}) + assert resp.status_code == 200 + assert "fd-sync-badge" in resp.text + + +# ── phase 7: E2E spec (runs against a live server via Playwright) ─────────── + + +def test_e2e_pwa_offline_spec_present(): + spec = ROOT / "e2e" / "pwa_offline.spec.js" + assert spec.is_file() + src = spec.read_text(encoding="utf-8") + assert "FlowOffline" in src + assert "/api/v2/sync/delta" in src + assert "setOffline(true)" in src + assert "setOffline(false)" in src + assert "navigator.serviceWorker" in src + assert "manifest" in src + # skips gracefully when the server is not running + assert "test.skip" in src + + +# ── phase 8: release docs + version ──────────────────────────────────────── + + +def test_version_bumped_to_6_0_0(): + assert (ROOT / "VERSION").read_text(encoding="utf-8").strip() == "6.0.0" + assert 'version="6.0.0"' in (ROOT / "app" / "main.py").read_text(encoding="utf-8") + + +def test_help_documents_offline_mode(client): + session = _create_user_ws(client, login="helpuser") + resp = client.get("/help", cookies={"flowdeck_session": session}) + assert resp.status_code == 200 + body = resp.text + assert "Offline mode (PWA)" in body + assert "IndexedDB" in body + + +def test_changelog_and_roadmap_mention_v6(client): + changelog = (ROOT / "CHANGELOG.md").read_text(encoding="utf-8") + roadmap = (ROOT / "ROADMAP.md").read_text(encoding="utf-8") + assert "## v6.0.0" in changelog + assert "PWA" in changelog + assert "v6.0.0 — PWA" in roadmap diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py new file mode 100644 index 0000000..2dae327 --- /dev/null +++ b/tests/test_service_worker.py @@ -0,0 +1,112 @@ +"""FlowDeck — PWA service worker tests (v6.0.0). + +Validates that the service worker, static manifest and PWA icons are served +correctly (status codes, content types) and stay in sync with the manifest. +""" +import json +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def _token(user_id, login): + from app.auth.session import SessionManager + return SessionManager.create_session({"id": user_id, "login": login, + "full_name": login.title(), "is_admin": 1}) + + +def _create_user_ws(client, login="pwatest", ws_name="PWA WS"): + """Insert a user + workspace and return an authenticated session cookie.""" + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)", + (login, login.title(), f"{login}@test.com"), + ) + user_id = cur.lastrowid + conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", + (ws_name, user_id), + ) + conn.commit() + return _token(user_id, login) + + +def test_sw_served_at_top_level(client): + """GET /sw.js returns the JS source so registration at scope '/' works.""" + resp = client.get("/sw.js") + assert resp.status_code == 200 + assert "javascript" in resp.headers.get("content-type", "") + + +def test_sw_served_via_static_mount(client): + resp = client.get("/static/sw.js") + assert resp.status_code == 200 + assert "javascript" in resp.headers.get("content-type", "") + + +def test_sw_registration_present_on_landing(client): + """Anonymous entry point (/) registers the SW (assets are public).""" + resp = client.get("/") + assert resp.status_code in (200, 302) + body = resp.text + assert "serviceWorker" in body + assert "/sw.js" in body + + +def test_sw_registration_with_background_sync_in_base(client): + """base.html pages register /sw.js and the background-sync tag.""" + session = _create_user_ws(client) + resp = client.get("/local-workspace", cookies={"flowdeck_session": session}) + assert resp.status_code == 200 + body = resp.text + assert "serviceWorker" in body + assert "/sw.js" in body + assert "sync-flowdeck" in body + + +def test_base_has_pwa_meta_tags(client): + resp = client.get("/") + body = resp.text + assert 'rel="manifest"' in body + assert "theme-color" in body + assert "apple-touch-icon" in body + + +def test_manifest_self_consistent(client): + """Every icon listed in the served manifest exists on disk.""" + resp = client.get("/manifest.json") + assert resp.status_code == 200 + manifest = resp.json() + + for icon in manifest["icons"]: + path = ROOT / icon["src"].lstrip("/") + assert path.is_file(), f"manifest references missing icon: {icon['src']}" + + pwa_manifest = ROOT / "static" / "manifest.json" + assert pwa_manifest.is_file() + assert json.loads(pwa_manifest.read_text(encoding="utf-8")) == manifest + + +def test_sw_precache_urls_exist(): + """Every URL listed in the SW precache maps to a served static asset.""" + sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") + urls = re.findall(r"'(/static/[^']+)'", sw) + + assert urls, "no static URLs found in precache list" + for url in urls: + file_path = ROOT / url.split("?")[0].lstrip("/") + assert file_path.is_file(), f"precache URL missing on disk: {url}" + + +def test_sw_cache_strategies_defined(): + """The SW must define both cache-first and network-first helpers.""" + sw = (ROOT / "static" / "sw.js").read_text(encoding="utf-8") + assert "cacheFirst" in sw + assert "networkFirst" in sw + assert "CACHE_NAME" in sw + assert "addEventListener('install'" in sw + assert "addEventListener('activate'" in sw + assert "addEventListener('fetch'" in sw + assert "addEventListener('sync'" in sw diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..7bc8bf4 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,372 @@ +"""FlowDeck — v6.0.0 PWA offline sync: engine + /api/v2/sync endpoints.""" +import time + +# ── helpers ──────────────────────────────────────────────────────────────── + + +def _token(user_id, login): + from app.auth.session import SessionManager + return SessionManager.create_session({"id": user_id, "login": login, + "full_name": login.title(), "is_admin": 0}) + + +def _user_ws(client, login="syncer"): + """Create user + workspace, return (session_cookie, user_id, workspace_id).""" + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO users (login, full_name, email) VALUES (?, ?, ?)", + (login, login.title(), f"{login}@test.co"), + ) + uid = cur.lastrowid + ws = conn.execute( + "INSERT INTO workspaces (name, owner_id) VALUES (?, ?)", ("Sync WS", uid) + ) + conn.commit() + return _token(uid, login), uid, ws.lastrowid + + +def _insert_page(ws_id, title="P", content="[]", content_format="blocks"): + from app.db import get_conn + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO pages (workspace, workspace_id, title, content, content_format, parent_section) " + "VALUES ('', ?, ?, ?, ?, 'Private')", + (ws_id, title, content, content_format), + ) + conn.commit() + return cur.lastrowid + + +def _page_version(pid): + from app.db import get_conn + with get_conn() as conn: + row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone() + return row["sync_version"] + + +def _page_updated_epoch(pid) -> float: + """Epoch (float) of the page's stored updated_at (second granularity).""" + from app.db import get_conn + with get_conn() as conn: + row = conn.execute("SELECT strftime('%s', updated_at) AS s FROM pages WHERE id=?", + (pid,)).fetchone() + return float(row["s"]) + + +# ── authentication & routing ─────────────────────────────────────────────── + + +def test_sync_requires_auth(client): + assert client.get("/api/v2/sync/status").status_code == 401 + assert client.post("/api/v2/sync/batch", json={"mutations": []}).status_code == 401 + assert client.get("/api/v2/sync/delta").status_code == 401 + + +def test_delta_requires_workspace(client): + sess, _, _ = _user_ws(client) + resp = client.get("/api/v2/sync/delta", cookies={"flowdeck_session": sess}) + assert resp.status_code == 400 + + +def test_delta_forbidden_other_user_workspace(client): + session, _, ws_id = _user_ws(client, "owner_a") + sess2, uid2, _ = _user_ws(client, "owner_b") + # owner_b has no access to owner_a's workspace + resp = client.get( + f"/api/v2/sync/delta?since=0&workspace_id={ws_id}", + cookies={"flowdeck_session": sess2}, + ) + assert resp.status_code == 403 + + +# ── delta ────────────────────────────────────────────────────────────────── + + +def test_delta_returns_changes_since(client): + session, _, ws_id = _user_ws(client) + pid = _insert_page(ws_id, "D1", "[]") + since = _page_updated_epoch(pid) + time.sleep(1.1) # CURRENT_TIMESTAMP has 1s granularity → ensure next second + _insert_page(ws_id, "D2", "[]") + + resp = client.get( + f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}", + cookies={"flowdeck_session": session}, + ) + assert resp.status_code == 200 + changes = resp.json()["changes"] + assert len(changes) == 1 + assert changes[0]["change_type"] == "page_created" + assert changes[0]["data"]["title"] == "D2" + + # full history from since=0 + resp2 = client.get( + f"/api/v2/sync/delta?since=0&workspace_id={ws_id}", + cookies={"flowdeck_session": session}, + ) + titles = {c["data"]["title"] for c in resp2.json()["changes"] if c["change_type"].startswith("page_")} + assert {"D1", "D2"} <= titles + + +def test_delta_includes_soft_deleted(client): + from app.db import get_conn + session, _, ws_id = _user_ws(client) + pid = _insert_page(ws_id, "ToDelete") + time.sleep(1.1) # 1s granularity: ensure the delete lands in a later second + since = _page_updated_epoch(pid) + with get_conn() as conn: + conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,)) + conn.commit() + + resp = client.get( + f"/api/v2/sync/delta?since={since}&workspace_id={ws_id}", + cookies={"flowdeck_session": session}, + ) + changes = resp.json()["changes"] + assert any(c["change_type"] == "page_deleted" for c in changes) + + +# ── batch: page mutations ────────────────────────────────────────────────── + + +def test_batch_page_create(client): + session, _, ws_id = _user_ws(client) + resp = client.post( + "/api/v2/sync/batch", + json={ + "device_id": "dev-1", + "mutations": [{ + "id": "m1", + "type": "page_create", + "client_timestamp": time.time(), + "payload": {"title": "Offline Page", "content": "[]", + "content_format": "blocks", "workspace_id": ws_id}, + }], + }, + cookies={"flowdeck_session": session}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["results"][0]["status"] == "synced" + pid = body["results"][0]["page_id"] + assert body["results"][0]["server_version"] == 1 + + from app.db import get_conn + with get_conn() as conn: + row = conn.execute("SELECT title, workspace_id FROM pages WHERE id=?", (pid,)).fetchone() + assert row["title"] == "Offline Page" + assert row["workspace_id"] == ws_id + + +def test_batch_page_update_synced(client): + session, _, ws_id = _user_ws(client) + pid = _insert_page(ws_id, "Versioned", "[]") + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m1", "type": "page_update", "client_timestamp": time.time(), + "payload": {"page_id": pid, "base_version": 1, + "title": "Versioned v2", "content": "[1,2,3]"}, + }]}, + cookies={"flowdeck_session": session}, + ) + r = resp.json()["results"][0] + assert r["status"] == "synced" + assert r["server_version"] == 2 # trigger bumped 1 → 2 + + +def test_batch_page_update_edit_edit_conflict_lww(client): + """Server page already moved to v2 → client base v1 → LWW applied + conflict.""" + session, _, ws_id = _user_ws(client) + pid = _insert_page(ws_id, "Conflict", "[]") + from app.db import get_conn + with get_conn() as conn: + conn.execute("UPDATE pages SET content='[server edit]' WHERE id=?", (pid,)) + conn.commit() + assert _page_version(pid) == 2 + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m2", "type": "page_update", "client_timestamp": time.time(), + "payload": {"page_id": pid, "base_version": 1, "content": "[client edit]"}, + }]}, + cookies={"flowdeck_session": session}, + ) + r = resp.json()["results"][0] + assert r["status"] == "conflict" + assert r["conflict"]["type"] == "edit_edit" + assert r["conflict"]["server_version"] == 2 + assert r["server_version"] == 3 # LWW applied + # content overwritten by client (last write wins) + from app.db import get_conn + with get_conn() as conn: + content = conn.execute("SELECT content FROM pages WHERE id=?", (pid,)).fetchone()["content"] + assert content == "[client edit]" + + +def test_batch_page_update_edit_delete_orphan(client): + session, _, ws_id = _user_ws(client) + pid = _insert_page(ws_id, "Deleted", "[old]") + from app.db import get_conn + with get_conn() as conn: + conn.execute("UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=?", (pid,)) + conn.commit() + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m3", "type": "page_update", "client_timestamp": time.time(), + "payload": {"page_id": pid, "base_version": 1, "content": "[client]"}, + }]}, + cookies={"flowdeck_session": session}, + ) + r = resp.json()["results"][0] + assert r["status"] == "conflict" + assert r["conflict"]["type"] == "edit_delete" + orphan_id = r["conflict"]["new_page_id"] + with get_conn() as conn: + orphan = conn.execute("SELECT content, deleted_at FROM pages WHERE id=?", (orphan_id,)).fetchone() + original = conn.execute("SELECT deleted_at FROM pages WHERE id=?", (pid,)).fetchone() + assert orphan["content"] == "[client]" + assert not orphan["deleted_at"] # orphan is live + assert original["deleted_at"] # original stays deleted + + +def test_batch_page_create_create_create_conflict(client): + session, _, ws_id = _user_ws(client) + _insert_page(ws_id, "Same Title", "[]") + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m4", "type": "page_create", "client_timestamp": time.time(), + "payload": {"title": "Same Title", "content": "[]", "workspace_id": ws_id}, + }]}, + cookies={"flowdeck_session": session}, + ) + r = resp.json()["results"][0] + assert r["status"] == "conflict" + assert r["conflict"]["type"] == "create_create" + assert r["conflict"]["renamed"] is True + from app.db import get_conn + with get_conn() as conn: + title = conn.execute("SELECT title FROM pages WHERE id=?", (r["page_id"],)).fetchone()["title"] + assert title == "Same Title (copie offline)" + + +def test_batch_page_delete_idempotent(client): + session, _, ws_id = _user_ws(client) + pid = _insert_page(ws_id, "D") + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m5", "type": "page_delete", "client_timestamp": time.time(), + "payload": {"page_id": pid}, + }]}, + cookies={"flowdeck_session": session}, + ) + assert resp.json()["results"][0]["status"] == "synced" + # second delete is still ok (idempotent) + resp2 = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m6", "type": "page_delete", "client_timestamp": time.time(), + "payload": {"page_id": pid}, + }]}, + cookies={"flowdeck_session": session}, + ) + assert resp2.json()["results"][0]["status"] == "synced" + + +def test_batch_unknown_type_failed(client): + session, _, ws_id = _user_ws(client) + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "m7", "type": "nope", "client_timestamp": time.time(), "payload": {}, + }]}, + cookies={"flowdeck_session": session}, + ) + r = resp.json()["results"][0] + assert r["status"] == "failed" + + +# ── batch: collections ───────────────────────────────────────────────────── + + +def test_batch_collection_crud(client): + from app.db import get_conn + session, _, _ = _user_ws(client) + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "c1", "type": "collection_create", "client_timestamp": time.time(), + "payload": {"name": "DB Off", "schema": [{"name": "Type", "prop_type": "text"}]}, + }]}, + cookies={"flowdeck_session": session}, + ) + r = resp.json()["results"][0] + assert r["status"] == "synced" + cid = r["collection_id"] + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "c2", "type": "collection_update", "client_timestamp": time.time(), + "payload": {"collection_id": cid, "name": "DB Off v2"}, + }]}, + cookies={"flowdeck_session": session}, + ) + assert resp.json()["results"][0]["server_version"] == 2 + + resp = client.post( + "/api/v2/sync/batch", + json={"device_id": "dev-1", "mutations": [{ + "id": "c3", "type": "collection_delete", "client_timestamp": time.time(), + "payload": {"collection_id": cid}, + }]}, + cookies={"flowdeck_session": session}, + ) + assert resp.json()["results"][0]["status"] == "synced" + with get_conn() as conn: + assert conn.execute("SELECT id FROM collections WHERE id=?", (cid,)).fetchone() is None + + +# ── status & audit ───────────────────────────────────────────────────────── + + +def test_sync_status_zero(client): + session, _, ws_id = _user_ws(client) + resp = client.get( + f"/api/v2/sync/status?workspace_id={ws_id}", + cookies={"flowdeck_session": session}, + ) + body = resp.json() + assert body["pending_count"] == 0 + assert body["is_syncing"] is False + + +def test_batch_records_audit_rows(client): + session, uid, ws_id = _user_ws(client) + client.post( + "/api/v2/sync/batch", + json={"device_id": "audit-dev", "mutations": [{ + "id": "a1", "type": "page_create", "client_timestamp": 123.0, + "payload": {"title": "Audited", "content": "[]", "workspace_id": ws_id}, + }]}, + cookies={"flowdeck_session": session}, + ) + from app.db import get_conn + with get_conn() as conn: + row = conn.execute( + "SELECT user_id, device_id, type, status FROM offline_sync_queue ORDER BY id" + ).fetchone() + assert row["user_id"] == uid + assert row["device_id"] == "audit-dev" + assert row["type"] == "page_create" + assert row["status"] == "synced" diff --git a/tests/test_sync_migrations.py b/tests/test_sync_migrations.py new file mode 100644 index 0000000..ff504c1 --- /dev/null +++ b/tests/test_sync_migrations.py @@ -0,0 +1,125 @@ +"""FlowDeck — v6.0.0 PWA: offline-sync schema migrations. + +Validates the ``offline_sync_queue`` table, the ``sync_version`` columns and +the BEFORE-UPDATE triggers that bump them on every write. +""" +from app.db import get_conn + + +def _insert_page(title="Page"): + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO pages (workspace, title, content, content_format, parent_section) " + "VALUES ('', ?, '', 'blocks', 'Private')", + (title,), + ) + conn.commit() + return cur.lastrowid + + +def _insert_collection(name="Coll"): + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO collections (name, schema_json) VALUES (?, '[]')", (name,) + ) + conn.commit() + return cur.lastrowid + + +def _insert_collection_page(coll_id, title="Row"): + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO collection_pages (collection_id, title) VALUES (?, ?)", + (coll_id, title), + ) + conn.commit() + return cur.lastrowid + + +def test_offline_sync_queue_table_and_indexes(client): + with get_conn() as conn: + cols = {r[1] for r in conn.execute("PRAGMA table_info(offline_sync_queue)")} + for expected in ("id", "user_id", "device_id", "type", "payload", + "client_timestamp", "server_version", "status", + "retries", "error", "created_at"): + assert expected in cols, f"offline_sync_queue missing column {expected}" + + indexes = {r[1] for r in conn.execute("PRAGMA index_list('offline_sync_queue')")} + assert "idx_syncqueue_user" in indexes + assert "idx_syncqueue_device" in indexes + + +def test_offline_sync_queue_insert(client): + with get_conn() as conn: + uid = conn.execute( + "INSERT INTO users (login, full_name, email) VALUES ('queuetest', 'Queue', 'q@t.co')" + ).lastrowid + cur = conn.execute( + "INSERT INTO offline_sync_queue (user_id, device_id, type, payload, client_timestamp) " + "VALUES (?, ?, ?, ?, ?)", + (uid, "dev-abc", "page_update", '{"page_id": 1}', 1750000000000.0), + ) + conn.commit() + qid = cur.lastrowid + row = conn.execute( + "SELECT status, server_version, retries FROM offline_sync_queue WHERE id=?", + (qid,), + ).fetchone() + assert row["status"] == "pending" + assert row["server_version"] == 0 + assert row["retries"] == 0 + + +def test_sync_version_column_and_defaults(client): + pid = _insert_page() + cid = _insert_collection() + cpid = _insert_collection_page(cid) + with get_conn() as conn: + for table, pk in (("pages", pid), ("collection_pages", cpid), ("collections", cid)): + row = conn.execute(f"SELECT sync_version FROM {table} WHERE id=?", (pk,)).fetchone() + assert row is not None, f"sync_version missing on {table}" + assert row["sync_version"] == 1, f"{table} default sync_version should be 1" + + +def test_sync_version_increments_on_update(client): + pid = _insert_page() + with get_conn() as conn: + conn.execute("UPDATE pages SET title='Renamed' WHERE id=?", (pid,)) + conn.commit() + v1 = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone() + conn.execute("UPDATE pages SET content='[...]' WHERE id=?", (pid,)) + conn.commit() + v2 = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone() + assert v1["sync_version"] == 2 + assert v2["sync_version"] == 3 + + +def test_sync_version_increments_on_soft_delete(client): + """deleted_at changes must bump the version too (delta sync sees deletions).""" + pid = _insert_page() + with get_conn() as conn: + conn.execute( + "UPDATE pages SET deleted_at=CURRENT_TIMESTAMP WHERE id=? AND deleted_at IS NULL", + (pid,), + ) + conn.commit() + row = conn.execute("SELECT sync_version FROM pages WHERE id=?", (pid,)).fetchone() + assert row["sync_version"] == 2 + + +def test_sync_version_increments_collection_and_rows(client): + cid = _insert_collection() + cpid = _insert_collection_page(cid) + with get_conn() as conn: + conn.execute("UPDATE collections SET name='Renamed' WHERE id=?", (cid,)) + conn.execute( + "UPDATE collection_pages SET title='Row2', property_values_json='{}' WHERE id=?", + (cpid,), + ) + conn.commit() + col = conn.execute("SELECT sync_version FROM collections WHERE id=?", (cid,)).fetchone() + row = conn.execute( + "SELECT sync_version FROM collection_pages WHERE id=?", (cpid,) + ).fetchone() + assert col["sync_version"] == 2 + assert row["sync_version"] == 2