mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
* feat(api): parse DATA_DIR from env for 1.x import auto-detection Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * test(migrator): build 1.17.2 fixtures by replaying legacy migrations Discovered the legacy migrations seed a Default team (0005) and builtin roles (0007), so the replayed fixture carries them. Seed uses a distinct custom team. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * fix(migrator): self-adjusting column copy, jobs.status map, drop sessions, advisory lock The importer now inserts only the intersection of source and live target columns, so the three analytics_* columns 2.x dropped no longer break the first users INSERT (and future dropped columns are handled generically). jobs.status is mapped onto the 2.x enum (error->failed). Sessions are no longer migrated. A pg_advisory_xact_lock serializes concurrent replicas. Includes login-after-migrate and library assertions. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * test(migrator): CI drift guard fails when a required column is unfillable from 1.17.2 Introspects every NOT-NULL-no-default column of each migrated table in the current schema and asserts the engine can fill it from a real 1.17.2 source. Turns a future breaking schema change into a PR-time failure instead of a production import break. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * feat(migrator): orchestrator with detection, boot states, marker, blob count sqlite-import.ts owns source resolution (explicit path, 'off' sentinel, DATA_DIR probe), the four boot states (import/leftover/locked/none), the persisted sqlite_import marker, and a read-only library-blob count. runBootImport wires them together and catches TargetNonEmptyError as a benign multi-replica skip. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * feat(api): route boot through the 1.x import orchestrator; hide marker from non-admins index.ts now calls runBootImport (which owns detection + the four boot states) instead of the inline SQLITE_MIGRATE_PATH block. The sqlite_import marker is added to SENSITIVE_KEYS (but not REDACTED_KEYS) so admins see the counts for the banner while non-admins don't see the key at all. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * feat(migrator): add analyzeSqlite + dry-run/verify CLI analyzeSqlite is a read-only pre-flight (no live Postgres): per-table row counts, library-blob presence, and out-of-enum job statuses. The migrate:sqlite CLI now lives in the orchestrator and supports --dry-run/--verify (prints the analysis and exits without writing) alongside the existing import and --force. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * docs: add 1.x to 2.0 upgrade guide; fix volume-name casing New apps/docs upgrade guide covering auto-detect, the SQLITE_MIGRATE_PATH override + off opt-out, the dry-run, what carries over, locked-state recovery, and non-destructive rollback. Leads with 'back up the WHOLE /data volume, not just snapotter.db' because 1.x WAL mode leaves data in snapotter.db-wal (surfaced by the real-image upgrade test). Standardizes README/DOCKERHUB compose volume names on the canonical SnapOtter-data casing so they match the repo compose and don't orphan an upgrader's volume. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * feat(web): admin 1.x migration banner + 21-locale strings A one-time admin banner reads the sqlite_import marker from /v1/settings and shows the import result (user + saved-file counts) on success, or a warning when a 1.x database was found but not imported. Dismissal persists to a sqlite_import.dismissedAt settings key. shouldShowMigrationBanner/parseMigrationMarker sit in feedback.ts with the other shouldShow helpers; strings added to en.ts and all 20 other locales. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w * style(landing): biome-format Hero.astro trustBadges array Pre-existing formatting drift on main (its Lint check was skipped on the merge that introduced it); this PR's full Lint run surfaced it. Formatting-only, applied via the repo's own biome formatter to unblock the required Lint check. Claude-Session: https://claude.ai/code/session_01721WHAUGxnVk22qEeTub7w
206 lines
5.4 KiB
TypeScript
206 lines
5.4 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
parseMigrationMarker,
|
|
shouldShowInstallFeedbackCard,
|
|
shouldShowMigrationBanner,
|
|
shouldShowUsageSurvey,
|
|
} from "@/lib/feedback";
|
|
|
|
const NOW = new Date("2026-01-15T00:00:00Z").getTime();
|
|
|
|
describe("shouldShowInstallFeedbackCard", () => {
|
|
it("shows only for admins after analytics config is loaded and enabled", () => {
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: {},
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(true);
|
|
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: {},
|
|
role: "user",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: {},
|
|
role: "admin",
|
|
analyticsConfigLoaded: false,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: {},
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: false,
|
|
now: NOW,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("stays hidden after submit or permanent dismiss", () => {
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: { "feedback.install.submittedAt": "2026-01-14T00:00:00Z" },
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: { "feedback.install.dismissedAt": "2026-01-14T00:00:00Z" },
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("honors snooze until the stored timestamp expires", () => {
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: { "feedback.install.snoozedUntil": "2026-01-16T00:00:00Z" },
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowInstallFeedbackCard({
|
|
settings: { "feedback.install.snoozedUntil": "2026-01-14T00:00:00Z" },
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
now: NOW,
|
|
}),
|
|
).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("shouldShowUsageSurvey", () => {
|
|
it("shows only for admins after analytics config is loaded and enabled", () => {
|
|
expect(
|
|
shouldShowUsageSurvey({
|
|
settings: {},
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
}),
|
|
).toBe(true);
|
|
|
|
expect(
|
|
shouldShowUsageSurvey({
|
|
settings: {},
|
|
role: "user",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowUsageSurvey({
|
|
settings: {},
|
|
role: "admin",
|
|
analyticsConfigLoaded: false,
|
|
analyticsEnabled: true,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowUsageSurvey({
|
|
settings: {},
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("stays hidden after answering or permanently dismissing", () => {
|
|
expect(
|
|
shouldShowUsageSurvey({
|
|
settings: { "onboarding.usageSurvey.answeredAt": "2026-01-14T00:00:00Z" },
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
}),
|
|
).toBe(false);
|
|
|
|
expect(
|
|
shouldShowUsageSurvey({
|
|
settings: { "onboarding.usageSurvey.dismissedAt": "2026-01-14T00:00:00Z" },
|
|
role: "admin",
|
|
analyticsConfigLoaded: true,
|
|
analyticsEnabled: true,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("shouldShowMigrationBanner", () => {
|
|
const marker = JSON.stringify({
|
|
status: "completed",
|
|
tables: { users: 2, user_files: 1 },
|
|
blobs: { present: 1, missing: 0 },
|
|
});
|
|
|
|
it("shows for admins when a marker exists and is not dismissed", () => {
|
|
expect(shouldShowMigrationBanner({ settings: { sqlite_import: marker }, role: "admin" })).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("is hidden for non-admins even with a marker", () => {
|
|
expect(shouldShowMigrationBanner({ settings: { sqlite_import: marker }, role: "user" })).toBe(
|
|
false,
|
|
);
|
|
});
|
|
|
|
it("is hidden once dismissed", () => {
|
|
expect(
|
|
shouldShowMigrationBanner({
|
|
settings: { sqlite_import: marker, "sqlite_import.dismissedAt": "2026-01-14T00:00:00Z" },
|
|
role: "admin",
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("is hidden when there is no marker", () => {
|
|
expect(shouldShowMigrationBanner({ settings: {}, role: "admin" })).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("parseMigrationMarker", () => {
|
|
it("parses completed and detected_locked markers", () => {
|
|
expect(parseMigrationMarker(JSON.stringify({ status: "completed" }))?.status).toBe("completed");
|
|
expect(parseMigrationMarker(JSON.stringify({ status: "detected_locked" }))?.status).toBe(
|
|
"detected_locked",
|
|
);
|
|
});
|
|
|
|
it("returns null for missing, invalid, or unknown-status input", () => {
|
|
expect(parseMigrationMarker(undefined)).toBeNull();
|
|
expect(parseMigrationMarker("not json")).toBeNull();
|
|
expect(parseMigrationMarker(JSON.stringify({ status: "bogus" }))).toBeNull();
|
|
});
|
|
});
|