Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ VITE_REALTIME_URL=ws://localhost:1234
# Hocuspocus server (server/hocuspocus): internal URL of the API for session verification (Better Auth cookie).
# 未設定のままでは本番・ステージングで接続拒否。ローカルだけで API なしで試す場合のみ HOCUSPOCUS_DEV_MODE=true(非本番のみ)。
# API_INTERNAL_URL=http://localhost:3000
# API server (restore snapshots): internal URL of Hocuspocus for live document invalidation after restore.
# スナップショット復元後に stale なライブドキュメントを切断するための Hocuspocus 内部 URL。
# ローカル開発では未設定時に http://127.0.0.1:1234 を既定で使用。
# HOCUSPOCUS_INTERNAL_URL=http://localhost:1234
# HOCUSPOCUS_DEV_MODE=true

# Polar (Pro plan billing)
Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,9 @@ src-tauri/gen/
# Sidecar executable for externalBin (build with `bun run sidecar:build` or `tauri:dev` ensure step)
src-tauri/binaries/claude-sidecar*

# Claude Code local worktrees (not tracked)
# Claude Code local worktrees and settings (not tracked)
.claude/worktrees/
.claude/settings.local.json

# Local-only notes (not tracked). See AGENTS.md / SPECIFICATION_POLICY.md.
/docs/
19 changes: 19 additions & 0 deletions db/migrations/002_add_page_snapshots.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
-- Migration: 002_add_page_snapshots
-- Description: Add page_snapshots table for page version history
-- Date: 2026-04-07

-- ページスナップショット(バージョン履歴)
-- Page snapshots (version history)
CREATE TABLE IF NOT EXISTS page_snapshots (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
page_id UUID NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
version BIGINT NOT NULL,
ydoc_state BYTEA NOT NULL,
content_text TEXT,
created_by TEXT,
trigger TEXT NOT NULL DEFAULT 'auto',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_id ON page_snapshots(page_id);
CREATE INDEX IF NOT EXISTS idx_page_snapshots_page_created ON page_snapshots(page_id, created_at DESC);
18 changes: 18 additions & 0 deletions server/api/src/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* 共通定数のテスト
* Tests for shared constants
*/
import { describe, it, expect } from "vitest";
import { SNAPSHOT_INTERVAL_MS, MAX_SNAPSHOTS_PER_PAGE } from "../constants.js";

describe("SNAPSHOT_INTERVAL_MS", () => {
it("is 10 minutes in milliseconds", () => {
expect(SNAPSHOT_INTERVAL_MS).toBe(600_000);
});
});

describe("MAX_SNAPSHOTS_PER_PAGE", () => {
it("is 100", () => {
expect(MAX_SNAPSHOTS_PER_PAGE).toBe(100);
});
});
318 changes: 318 additions & 0 deletions server/api/src/__tests__/routes/pageSnapshots.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,318 @@
/**
* pageSnapshots ルートのテスト(認可・CRUD)
* Tests for page snapshots routes: authorization, list, detail, restore.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { Context, Next } from "hono";
import type { AppEnv } from "../../types/index.js";

vi.mock("../../middleware/auth.js", () => ({
authRequired: async (c: Context<AppEnv>, next: Next) => {
const userId = c.req.header("x-test-user-id");
if (!userId) return c.json({ message: "Unauthorized" }, 401);
c.set("userId", userId);
await next();
},
}));

import { Hono } from "hono";
import pageSnapshotRoutes from "../../routes/pageSnapshots.js";
import { createMockDb } from "../createMockDb.js";

const OWNER_ID = "owner-user-001";
const MEMBER_ID = "member-user-002";
const OTHER_ID = "other-user-003";
const PAGE_ID = "page-snap-test-001";
const SNAPSHOT_ID = "snap-001";
const NOTE_ID = "note-001";

function authHeaders(userId: string = OWNER_ID) {
return {
"x-test-user-id": userId,
"Content-Type": "application/json",
};
}

function createSnapshotsApp(dbResults: unknown[]) {
const { db } = createMockDb(dbResults);
const app = new Hono<AppEnv>();
app.use("*", async (c, next) => {
c.set("db", db as unknown as AppEnv["Variables"]["db"]);
await next();
});
app.route("/api/pages", pageSnapshotRoutes);
return app;
}

beforeEach(() => {
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true, status: 200, statusText: "OK" }));
});

afterEach(() => {
vi.unstubAllGlobals();
});

// ── 認証 / Authentication ──────────────────────────────────────────────────

describe("Authentication", () => {
it("returns 401 without auth header", async () => {
const app = createSnapshotsApp([]);
const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, {
method: "GET",
});
expect(res.status).toBe(401);
});
});

// ── GET /snapshots — 一覧 / List ────────────────────────────────────────────

describe("GET /api/pages/:id/snapshots", () => {
it("returns snapshots for page owner", async () => {
const now = new Date();
const app = createSnapshotsApp([
// assertPageViewAccess: pages query
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// snapshots query
[
{
id: SNAPSHOT_ID,
version: 1,
contentText: "hello",
createdBy: OWNER_ID,
trigger: "auto",
createdAt: now,
},
],
// users query (email resolution)
[{ id: OWNER_ID, email: "owner@example.com" }],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, {
method: "GET",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(200);
const body = (await res.json()) as {
snapshots: Array<{
id: string;
version: number;
content_text: string;
created_by: string;
created_by_email: string;
trigger: string;
created_at: string;
}>;
};
expect(body.snapshots).toEqual([
expect.objectContaining({
id: SNAPSHOT_ID,
created_by_email: "owner@example.com",
}),
]);
});

it("returns 404 when page does not exist", async () => {
const app = createSnapshotsApp([
// assertPageViewAccess: pages query returns empty
[],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, {
method: "GET",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(404);
});

it("returns 403 when user is not owner and not a note member", async () => {
const app = createSnapshotsApp([
// assertPageViewAccess: pages query
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// user email lookup
[{ email: "other@example.com" }],
// notePages + noteMembers JOIN returns empty
[],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, {
method: "GET",
headers: authHeaders(OTHER_ID),
});

expect(res.status).toBe(403);
});

it("allows access for note member", async () => {
const app = createSnapshotsApp([
// assertPageViewAccess: pages query (owner is different)
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// user email lookup
[{ email: "member@example.com" }],
// notePages + noteMembers JOIN returns a match
[{ noteId: NOTE_ID }],
// snapshots query
[],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots`, {
method: "GET",
headers: authHeaders(MEMBER_ID),
});

expect(res.status).toBe(200);
});
});

// ── GET /snapshots/:snapshotId — 詳細 / Detail ─────────────────────────────

describe("GET /api/pages/:id/snapshots/:snapshotId", () => {
it("returns snapshot detail for owner", async () => {
const now = new Date();
const ydocBuffer = Buffer.from("test-ydoc");
const app = createSnapshotsApp([
// assertPageViewAccess: pages query
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// snapshot query
[
{
id: SNAPSHOT_ID,
pageId: PAGE_ID,
version: 1,
ydocState: ydocBuffer,
contentText: "hello",
createdBy: OWNER_ID,
trigger: "auto",
createdAt: now,
},
],
// user email lookup for created_by
[{ email: "owner@example.com" }],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}`, {
method: "GET",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(200);
const body = (await res.json()) as {
id: string;
ydoc_state: string;
content_text: string;
};
expect(body.id).toBe(SNAPSHOT_ID);
expect(body.ydoc_state).toBe(ydocBuffer.toString("base64"));
expect(body.content_text).toBe("hello");
});

it("returns 404 when snapshot does not exist", async () => {
const app = createSnapshotsApp([
// assertPageViewAccess: pages query
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// snapshot query returns empty
[],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/nonexistent`, {
method: "GET",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(404);
});
});

// ── POST /snapshots/:snapshotId/restore — 復元 / Restore ──────────────────

describe("POST /api/pages/:id/snapshots/:snapshotId/restore", () => {
it("returns 403 when non-owner tries to restore", async () => {
const app = createSnapshotsApp([
// page ownership check
[{ id: PAGE_ID, ownerId: OWNER_ID }],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, {
method: "POST",
headers: authHeaders(OTHER_ID),
});

expect(res.status).toBe(403);
});

it("returns 404 when page does not exist for restore", async () => {
const app = createSnapshotsApp([
// page query returns empty
[],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, {
method: "POST",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(404);
});

it("returns 404 when snapshot does not exist for restore", async () => {
const app = createSnapshotsApp([
// page ownership check
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// snapshot query returns empty
[],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, {
method: "POST",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(404);
});

it("restores snapshot and returns new version for owner", async () => {
const ydocBuffer = Buffer.from("restored-ydoc");
const app = createSnapshotsApp([
// page ownership check
[{ id: PAGE_ID, ownerId: OWNER_ID }],
// snapshot query
[
{
id: SNAPSHOT_ID,
pageId: PAGE_ID,
version: 1,
ydocState: ydocBuffer,
contentText: "restored content",
createdBy: OWNER_ID,
trigger: "auto",
createdAt: new Date(),
},
],
// transaction: row lock
[{}],
// transaction: current content
[{ version: 2, ydocState: Buffer.from("current"), contentText: "current" }],
// transaction: insert current snapshot
[{}],
// transaction: update page_contents
[{ version: 3, pageId: PAGE_ID }],
// transaction: insert restore snapshot
[{ id: "snap-restore-001" }],
// transaction: update pages metadata
[{}],
// transaction: prune old snapshots
[{}],
]);

const res = await app.request(`/api/pages/${PAGE_ID}/snapshots/${SNAPSHOT_ID}/restore`, {
method: "POST",
headers: authHeaders(OWNER_ID),
});

expect(res.status).toBe(200);
const body = (await res.json()) as { version: number; snapshot_id: string };
expect(body.version).toBe(3);
expect(body.snapshot_id).toBe("snap-restore-001");
});
});
Loading
Loading