Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,961 @@
/**
* Capability Enforcement Integration Tests (v2)
*
* Tests the capability-based access gating in the v2 plugin context.
* v2 always enforces capabilities - there's no "trusted mode" bypass.
*
*/
import Database from "better-sqlite3";
import { Kysely, SqliteDialect, sql } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { runMigrations } from "../../../src/database/migrations/runner.js";
import { OptionsRepository } from "../../../src/database/repositories/options.js";
import { UserRepository } from "../../../src/database/repositories/user.js";
import type { Database as DbSchema } from "../../../src/database/types.js";
import {
PluginContextFactory,
createContentAccess,
createContentAccessWithWrite,
createHttpAccess,
createUnrestrictedHttpAccess,
createBlockedHttpAccess,
createLogAccess,
createStorageAccess,
createKVAccess,
createSiteInfo,
createUrlHelper,
createUserAccess,
} from "../../../src/plugins/context.js";
import type { ResolvedPlugin } from "../../../src/plugins/types.js";
// Test regex patterns
const NOT_ALLOWED_FETCH_REGEX = /not allowed to fetch from host/;
const NO_ALLOWED_FETCH_REGEX = /not allowed to fetch/;
const NO_NETWORK_FETCH_REGEX = /does not have the "network:request" capability/;
const SEO_NOT_ENABLED_REGEX = /does not have SEO enabled/;
/**
* Create a minimal resolved plugin for testing
*/
function createTestPlugin(overrides: Partial<ResolvedPlugin> = {}): ResolvedPlugin {
return {
id: "test-plugin",
version: "1.0.0",
capabilities: [],
allowedHosts: [],
storage: {},
admin: {
pages: [],
widgets: [],
fieldWidgets: {},
},
hooks: {},
routes: {},
settings: undefined,
...overrides,
};
}
describe("Capability Enforcement Integration (v2)", () => {
let db: Kysely<DbSchema>;
let sqliteDb: Database.Database;
beforeEach(async () => {
// Create in-memory SQLite database
sqliteDb = new Database(":memory:");
db = new Kysely<DbSchema>({
dialect: new SqliteDialect({
database: sqliteDb,
}),
});
// Run migrations
await runMigrations(db);
// Create test content table with actual field columns (not JSON data column)
// The ContentRepository expects real columns for each field
await sql`
CREATE TABLE IF NOT EXISTS ec_posts (
id TEXT PRIMARY KEY,
slug TEXT,
status TEXT DEFAULT 'draft',
author_id TEXT,
primary_byline_id TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
published_at TEXT,
deleted_at TEXT,
version INTEGER DEFAULT 1,
locale TEXT NOT NULL DEFAULT 'en',
translation_group TEXT,
title TEXT,
content TEXT,
UNIQUE(slug, locale)
)
`.execute(db);
// Insert test content with actual column values
await sql`
INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group)
VALUES
('post-1', 'hello-world', 'published', 'Hello World', 'Content 1', 'en', 'post-1'),
('post-2', 'second-post', 'draft', 'Second Post', 'Content 2', 'en', 'post-2')
`.execute(db);
});
afterEach(async () => {
await db.destroy();
sqliteDb.close();
});
describe("Content Access", () => {
describe("createContentAccess (read-only)", () => {
it("can read content by ID", async () => {
const access = createContentAccess(db);
const post = await access.get("posts", "post-1");
expect(post).not.toBeNull();
expect(post!.id).toBe("post-1");
expect(post!.data.title).toBe("Hello World");
});
it("can list content", async () => {
const access = createContentAccess(db);
const result = await access.list("posts");
expect(result.items).toHaveLength(2);
expect(result.hasMore).toBe(false);
});
it("narrows list results by where.status", async () => {
const access = createContentAccess(db);
const result = await access.list("posts", { where: { status: "published" } });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.id).toBe("post-1");
expect(result.items[0]!.status).toBe("published");
});
it("narrows list results by where.locale", async () => {
await sql`
INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group)
VALUES ('post-3', 'bonjour', 'published', 'Bonjour', 'Contenu', 'fr', 'post-3')
`.execute(db);
const access = createContentAccess(db);
const result = await access.list("posts", { where: { locale: "fr" } });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.id).toBe("post-3");
});
it("combines where.status and where.locale", async () => {
await sql`
INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group)
VALUES
('post-3', 'bonjour', 'published', 'Bonjour', 'Contenu', 'fr', 'post-3'),
('post-4', 'brouillon', 'draft', 'Brouillon', 'WIP', 'fr', 'post-4')
`.execute(db);
const access = createContentAccess(db);
const result = await access.list("posts", {
where: { status: "published", locale: "fr" },
});
expect(result.items).toHaveLength(1);
expect(result.items[0]!.id).toBe("post-3");
});
it("paginates consistently with where filters", async () => {
// Three more published posts so the total published count is 4.
// A limit of 2 should yield two pages: [2, 2] — never drafts.
await sql`
INSERT INTO ec_posts (id, slug, status, title, content, locale, translation_group)
VALUES
('post-3', 'a', 'published', 'A', 'a', 'en', 'post-3'),
('post-4', 'b', 'published', 'B', 'b', 'en', 'post-4'),
('post-5', 'c', 'published', 'C', 'c', 'en', 'post-5')
`.execute(db);
const access = createContentAccess(db);
const page1 = await access.list("posts", {
limit: 2,
where: { status: "published" },
});
expect(page1.items).toHaveLength(2);
expect(page1.hasMore).toBe(true);
for (const item of page1.items) expect(item.status).toBe("published");
const page2 = await access.list("posts", {
limit: 2,
cursor: page1.cursor,
where: { status: "published" },
});
expect(page2.items).toHaveLength(2);
expect(page2.hasMore).toBe(false);
for (const item of page2.items) expect(item.status).toBe("published");
// No overlap between pages
const ids = new Set([...page1.items, ...page2.items].map((i) => i.id));
expect(ids.size).toBe(4);
// Drafts never surface
expect(ids.has("post-2")).toBe(false);
});
it("returns null for non-existent content", async () => {
const access = createContentAccess(db);
const post = await access.get("posts", "non-existent");
expect(post).toBeNull();
});
});
describe("createContentAccessWithWrite", () => {
it("includes read methods", async () => {
const access = createContentAccessWithWrite(db);
expect(typeof access.get).toBe("function");
expect(typeof access.list).toBe("function");
});
it("includes write methods", async () => {
const access = createContentAccessWithWrite(db);
expect(typeof access.create).toBe("function");
expect(typeof access.update).toBe("function");
expect(typeof access.delete).toBe("function");
});
it("can create new content", async () => {
const access = createContentAccessWithWrite(db);
const created = await access.create("posts", {
title: "New Post",
content: "New content",
});
expect(created.id).toBeDefined();
expect(created.data.title).toBe("New Post");
// Verify it was created
const found = await access.get("posts", created.id);
expect(found).not.toBeNull();
});
});
describe("SEO panel integration", () => {
beforeEach(async () => {
// Register the "posts" collection with SEO enabled so the plugin
// content API routes `seo` writes to the core SEO panel.
await sql`
INSERT INTO _emdash_collections (slug, label, label_singular, has_seo)
VALUES ('posts', 'Posts', 'Post', 1)
`.execute(db);
});
it("returns seo defaults from get() for SEO-enabled collections", async () => {
const access = createContentAccess(db);
const post = await access.get("posts", "post-1");
expect(post).not.toBeNull();
expect(post!.seo).toEqual({
title: null,
description: null,
image: null,
canonical: null,
noIndex: false,
});
});
it("omits seo from get() for collections without SEO enabled", async () => {
// Reset has_seo on posts so it behaves like a non-SEO collection
await db
.updateTable("_emdash_collections")
.set({ has_seo: 0 })
.where("slug", "=", "posts")
.execute();
const access = createContentAccess(db);
const post = await access.get("posts", "post-1");
expect(post).not.toBeNull();
expect(post!.seo).toBeUndefined();
});
it("update() routes `seo` to the SEO panel instead of failing on missing column", async () => {
const access = createContentAccessWithWrite(db);
// Regression for #374: previously this threw
// "SQLite error: no such column: seo"
const updated = await access.update("posts", "post-1", {
seo: {
title: "Custom SEO Title",
description: "A better meta description",
canonical: "https://example.com/canonical",
noIndex: false,
},
});
expect(updated.seo).toEqual({
title: "Custom SEO Title",
description: "A better meta description",
image: null,
canonical: "https://example.com/canonical",
noIndex: false,
});
// Verify it persisted via a subsequent read
const fresh = await access.get("posts", "post-1");
expect(fresh!.seo?.title).toBe("Custom SEO Title");
expect(fresh!.seo?.description).toBe("A better meta description");
});
it("update() accepts field updates alongside seo in a single call", async () => {
const access = createContentAccessWithWrite(db);
const updated = await access.update("posts", "post-1", {
title: "Updated Title",
seo: {
title: "SEO Title",
description: "SEO Description",
},
});
expect(updated.data.title).toBe("Updated Title");
expect(updated.seo?.title).toBe("SEO Title");
expect(updated.seo?.description).toBe("SEO Description");
});
it("update() only overwrites explicitly-set seo fields (partial updates)", async () => {
const access = createContentAccessWithWrite(db);
await access.update("posts", "post-1", {
seo: { title: "Initial Title", description: "Initial Description" },
});
const updated = await access.update("posts", "post-1", {
seo: { title: "Updated Title" },
});
expect(updated.seo?.title).toBe("Updated Title");
// description must not be clobbered by a partial update
expect(updated.seo?.description).toBe("Initial Description");
});
it("create() routes `seo` to the SEO panel", async () => {
const access = createContentAccessWithWrite(db);
const created = await access.create("posts", {
title: "New Post",
content: "Body",
seo: {
title: "Brand New SEO",
description: "New Description",
},
});
expect(created.data.title).toBe("New Post");
expect(created.seo?.title).toBe("Brand New SEO");
expect(created.seo?.description).toBe("New Description");
const fresh = await access.get("posts", created.id);
expect(fresh!.seo?.title).toBe("Brand New SEO");
});
it("update() throws when seo is provided on a collection without SEO enabled", async () => {
// Disable SEO on posts
await db
.updateTable("_emdash_collections")
.set({ has_seo: 0 })
.where("slug", "=", "posts")
.execute();
const access = createContentAccessWithWrite(db);
await expect(
access.update("posts", "post-1", {
seo: { title: "Won't work" },
}),
).rejects.toThrow(SEO_NOT_ENABLED_REGEX);
});
it("list() hydrates seo for each item in SEO-enabled collections", async () => {
const access = createContentAccessWithWrite(db);
await access.update("posts", "post-1", {
seo: { title: "Post One SEO" },
});
await access.update("posts", "post-2", {
seo: { title: "Post Two SEO" },
});
const result = await access.list("posts");
expect(result.items).toHaveLength(2);
const byId = new Map(result.items.map((i) => [i.id, i]));
expect(byId.get("post-1")?.seo?.title).toBe("Post One SEO");
expect(byId.get("post-2")?.seo?.title).toBe("Post Two SEO");
});
});
});
describe("HTTP Access", () => {
describe("createHttpAccess (with host restrictions)", () => {
it("allows requests to allowed hosts", async () => {
const http = createHttpAccess("test-plugin", ["example.com"]);
// We can't actually make the request in tests, but we can verify
// the function doesn't throw for allowed hosts
expect(typeof http.fetch).toBe("function");
});
it("blocks requests to non-allowed hosts", async () => {
const http = createHttpAccess("test-plugin", ["example.com"]);
await expect(http.fetch("https://evil.com/api")).rejects.toThrow(NOT_ALLOWED_FETCH_REGEX);
});
it("supports wildcard host patterns", { timeout: 15000 }, async () => {
const http = createHttpAccess("test-plugin", ["*.example.com"]);
// Should not throw for subdomains
// (Can't test actual fetch, but verify pattern matching logic)
await expect(http.fetch("https://api.example.com/test")).rejects.not.toThrow(
NO_ALLOWED_FETCH_REGEX,
);
});
});
describe("createBlockedHttpAccess", () => {
it("always throws", async () => {
const http = createBlockedHttpAccess("no-network-plugin");
await expect(http.fetch("https://example.com")).rejects.toThrow(NO_NETWORK_FETCH_REGEX);
});
});
describe("createUnrestrictedHttpAccess", () => {
it("returns an HttpAccess with a fetch function", () => {
const http = createUnrestrictedHttpAccess("unrestricted-plugin");
expect(typeof http.fetch).toBe("function");
});
it("does not throw for any host", async () => {
const http = createUnrestrictedHttpAccess("unrestricted-plugin");
// Can't make a real request in tests, but verify it doesn't throw a
// host-validation error — it will throw a network error instead.
await expect(http.fetch("https://any-host-at-all.example.com/test")).rejects.not.toThrow(
NOT_ALLOWED_FETCH_REGEX,
);
});
});
});
describe("Storage Access", () => {
it("creates collection accessors from config", () => {
const storage = createStorageAccess(db, "test-plugin", {
events: { indexes: ["type"] },
cache: { indexes: ["key"] },
});
expect(storage.events).toBeDefined();
expect(storage.cache).toBeDefined();
});
it("provides full StorageCollection API", () => {
const storage = createStorageAccess(db, "test-plugin", {
items: { indexes: [] },
});
const collection = storage.items;
expect(typeof collection.get).toBe("function");
expect(typeof collection.put).toBe("function");
expect(typeof collection.delete).toBe("function");
expect(typeof collection.exists).toBe("function");
expect(typeof collection.getMany).toBe("function");
expect(typeof collection.putMany).toBe("function");
expect(typeof collection.deleteMany).toBe("function");
expect(typeof collection.query).toBe("function");
expect(typeof collection.count).toBe("function");
});
it("isolates storage between plugins", async () => {
const storage1 = createStorageAccess(db, "plugin-1", {
items: { indexes: [] },
});
const storage2 = createStorageAccess(db, "plugin-2", {
items: { indexes: [] },
});
await storage1.items.put("doc-1", { value: "from plugin 1" });
// Plugin 2 should not see plugin 1's data
const fromPlugin2 = await storage2.items.get("doc-1");
expect(fromPlugin2).toBeNull();
// Plugin 1 should still see its data
const fromPlugin1 = await storage1.items.get("doc-1");
expect(fromPlugin1).toEqual({ value: "from plugin 1" });
});
});
describe("KV Access", () => {
it("prefixes keys with plugin ID", async () => {
const optionsRepo = new OptionsRepository(db);
const kv = createKVAccess(optionsRepo, "test-plugin");
await kv.set("my-key", { foo: "bar" });
// Verify the key is prefixed in the database
const rawValue = await optionsRepo.get("plugin:test-plugin:my-key");
expect(rawValue).toEqual({ foo: "bar" });
});
it("isolates KV between plugins", async () => {
const optionsRepo = new OptionsRepository(db);
const kv1 = createKVAccess(optionsRepo, "plugin-1");
const kv2 = createKVAccess(optionsRepo, "plugin-2");
await kv1.set("shared-key", "value from 1");
await kv2.set("shared-key", "value from 2");
expect(await kv1.get("shared-key")).toBe("value from 1");
expect(await kv2.get("shared-key")).toBe("value from 2");
});
it("supports listing keys with prefix", async () => {
const optionsRepo = new OptionsRepository(db);
const kv = createKVAccess(optionsRepo, "test-plugin");
await kv.set("settings:theme", "dark");
await kv.set("settings:lang", "en");
await kv.set("cache:user-1", { name: "John" });
const settings = await kv.list("settings:");
expect(settings).toHaveLength(2);
expect(settings.map((s) => s.key).toSorted()).toEqual(["settings:lang", "settings:theme"]);
});
});
describe("Log Access", () => {
it("prefixes messages with plugin ID", () => {
const log = createLogAccess("test-plugin");
// These just verify the methods exist and don't throw
expect(() => log.debug("test message")).not.toThrow();
expect(() => log.info("test message", { extra: "data" })).not.toThrow();
expect(() => log.warn("test warning")).not.toThrow();
expect(() => log.error("test error")).not.toThrow();
});
});
describe("PluginContextFactory", () => {
it("creates context with capability-gated access", () => {
const factory = new PluginContextFactory({ db });
const readOnlyPlugin = createTestPlugin({
id: "reader",
capabilities: ["content:read"],
});
const ctx = factory.createContext(readOnlyPlugin);
// Content should be read-only (no create/update/delete)
expect(ctx.content).toBeDefined();
expect(typeof ctx.content!.get).toBe("function");
expect(typeof ctx.content!.list).toBe("function");
expect("create" in ctx.content!).toBe(false);
});
it("provides undefined content for plugins without capability", () => {
const factory = new PluginContextFactory({ db });
const noContentPlugin = createTestPlugin({
id: "no-content",
capabilities: ["network:request"],
});
const ctx = factory.createContext(noContentPlugin);
expect(ctx.content).toBeUndefined();
});
it("provides http for plugins with network:fetch", () => {
const factory = new PluginContextFactory({ db });
const networkPlugin = createTestPlugin({
id: "network",
capabilities: ["network:request"],
allowedHosts: ["api.example.com"],
});
const ctx = factory.createContext(networkPlugin);
expect(ctx.http).toBeDefined();
expect(typeof ctx.http!.fetch).toBe("function");
});
it("provides undefined http for plugins without capability", () => {
const factory = new PluginContextFactory({ db });
const noNetworkPlugin = createTestPlugin({
id: "no-network",
capabilities: [],
});
const ctx = factory.createContext(noNetworkPlugin);
expect(ctx.http).toBeUndefined();
});
it("provides unrestricted http for plugins with network:fetch:any", () => {
const factory = new PluginContextFactory({ db });
const plugin = createTestPlugin({
id: "unrestricted-network",
capabilities: ["network:request:unrestricted", "network:request"],
});
const ctx = factory.createContext(plugin);
expect(ctx.http).toBeDefined();
expect(typeof ctx.http!.fetch).toBe("function");
});
it("prefers network:fetch:any over network:fetch when both present", async () => {
const factory = new PluginContextFactory({ db });
const plugin = createTestPlugin({
id: "both-fetch",
capabilities: ["network:request", "network:request:unrestricted"],
allowedHosts: ["restricted.example.com"],
});
const ctx = factory.createContext(plugin);
expect(ctx.http).toBeDefined();
// With network:fetch:any, arbitrary hosts should not throw a host validation error
await expect(ctx.http!.fetch("https://unrestricted.example.com/test")).rejects.not.toThrow(
NOT_ALLOWED_FETCH_REGEX,
);
});
it("always provides kv, storage, and log", () => {
const factory = new PluginContextFactory({ db });
const minimalPlugin = createTestPlugin({
id: "minimal",
capabilities: [],
storage: {
items: { indexes: [] },
},
});
const ctx = factory.createContext(minimalPlugin);
expect(ctx.kv).toBeDefined();
expect(ctx.storage).toBeDefined();
expect(ctx.storage.items).toBeDefined();
expect(ctx.log).toBeDefined();
});
it("provides write:content access with create/update/delete", () => {
const factory = new PluginContextFactory({ db });
const writePlugin = createTestPlugin({
id: "writer",
capabilities: ["content:write"],
});
const ctx = factory.createContext(writePlugin);
expect(ctx.content).toBeDefined();
expect("create" in ctx.content!).toBe(true);
expect("update" in ctx.content!).toBe(true);
expect("delete" in ctx.content!).toBe(true);
});
it("always provides site info", () => {
const factory = new PluginContextFactory({ db });
const plugin = createTestPlugin({ id: "site-test", capabilities: [] });
const ctx = factory.createContext(plugin);
expect(ctx.site).toBeDefined();
expect(typeof ctx.site.name).toBe("string");
expect(typeof ctx.site.url).toBe("string");
expect(typeof ctx.site.locale).toBe("string");
});
it("always provides url() helper", () => {
const factory = new PluginContextFactory({
db,
siteInfo: { siteUrl: "https://example.com" },
});
const plugin = createTestPlugin({ id: "url-test", capabilities: [] });
const ctx = factory.createContext(plugin);
expect(typeof ctx.url).toBe("function");
expect(ctx.url("/posts")).toBe("https://example.com/posts");
});
it("provides users for plugins with read:users", () => {
const factory = new PluginContextFactory({ db });
const plugin = createTestPlugin({
id: "user-reader",
capabilities: ["users:read"],
});
const ctx = factory.createContext(plugin);
expect(ctx.users).toBeDefined();
expect(typeof ctx.users!.get).toBe("function");
expect(typeof ctx.users!.getByEmail).toBe("function");
expect(typeof ctx.users!.list).toBe("function");
});
it("provides undefined users for plugins without read:users", () => {
const factory = new PluginContextFactory({ db });
const plugin = createTestPlugin({
id: "no-users",
capabilities: [],
});
const ctx = factory.createContext(plugin);
expect(ctx.users).toBeUndefined();
});
});
describe("Site Info", () => {
it("creates site info with all options", () => {
const info = createSiteInfo({
siteName: "My Site",
siteUrl: "https://example.com/",
locale: "fr",
});
expect(info.name).toBe("My Site");
expect(info.url).toBe("https://example.com"); // trailing slash stripped
expect(info.locale).toBe("fr");
});
it("uses defaults for missing values", () => {
const info = createSiteInfo({});
expect(info.name).toBe("");
expect(info.url).toBe("");
expect(info.locale).toBe("en");
});
it("strips trailing slash from URL", () => {
const info = createSiteInfo({ siteUrl: "https://example.com/" });
expect(info.url).toBe("https://example.com");
});
});
describe("URL Helper", () => {
it("creates absolute URLs from paths", () => {
const url = createUrlHelper("https://example.com");
expect(url("/posts")).toBe("https://example.com/posts");
expect(url("/")).toBe("https://example.com/");
});
it("strips trailing slash from base URL", () => {
const url = createUrlHelper("https://example.com/");
expect(url("/posts")).toBe("https://example.com/posts");
});
it("throws for paths not starting with /", () => {
const url = createUrlHelper("https://example.com");
expect(() => url("posts")).toThrow('URL path must start with "/"');
});
it("works with empty base URL", () => {
const url = createUrlHelper("");
expect(url("/posts")).toBe("/posts");
});
it("rejects protocol-relative paths (//)", () => {
const url = createUrlHelper("https://example.com");
expect(() => url("//evil.com")).toThrow("protocol-relative");
});
it("rejects protocol-relative paths with empty base URL", () => {
const url = createUrlHelper("");
expect(() => url("//evil.com/path")).toThrow("protocol-relative");
});
});
describe("User Access", () => {
let userRepo: UserRepository;
beforeEach(async () => {
userRepo = new UserRepository(db);
// Create test users with all 5 role levels
await userRepo.create({ email: "admin@test.com", name: "Admin User", role: "admin" });
await userRepo.create({ email: "editor@test.com", name: "Editor User", role: "editor" });
await userRepo.create({ email: "author@test.com", name: "Author User", role: "author" });
await userRepo.create({
email: "contrib@test.com",
name: "Contributor User",
role: "contributor",
});
await userRepo.create({
email: "sub@test.com",
name: "Subscriber User",
role: "subscriber",
});
});
it("gets user by ID", async () => {
const user = await userRepo.findByEmail("admin@test.com");
const access = createUserAccess(db);
const result = await access.get(user!.id);
expect(result).not.toBeNull();
expect(result!.email).toBe("admin@test.com");
expect(result!.name).toBe("Admin User");
expect(result!.role).toBe(50); // admin = 50
});
it("gets user by email", async () => {
const access = createUserAccess(db);
const result = await access.getByEmail("editor@test.com");
expect(result).not.toBeNull();
expect(result!.email).toBe("editor@test.com");
expect(result!.role).toBe(40); // editor = 40
});
it("returns null for non-existent user", async () => {
const access = createUserAccess(db);
expect(await access.get("non-existent")).toBeNull();
expect(await access.getByEmail("nobody@test.com")).toBeNull();
});
it("lists users", async () => {
const access = createUserAccess(db);
const result = await access.list();
expect(result.items).toHaveLength(5);
// All users should have role as number
for (const user of result.items) {
expect(typeof user.role).toBe("number");
}
});
it("excludes sensitive fields", async () => {
const access = createUserAccess(db);
const result = await access.list();
for (const user of result.items) {
// UserInfo should only have: id, email, name, role, createdAt
const keys = Object.keys(user);
expect(keys).toContain("id");
expect(keys).toContain("email");
expect(keys).toContain("name");
expect(keys).toContain("role");
expect(keys).toContain("createdAt");
// Should NOT have sensitive fields
expect(keys).not.toContain("avatarUrl");
expect(keys).not.toContain("emailVerified");
expect(keys).not.toContain("data");
expect(keys).not.toContain("password_hash");
}
});
it("converts role strings to numeric levels", async () => {
const access = createUserAccess(db);
const admin = await access.getByEmail("admin@test.com");
const editor = await access.getByEmail("editor@test.com");
const subscriber = await access.getByEmail("sub@test.com");
expect(admin!.role).toBe(50);
expect(editor!.role).toBe(40);
expect(subscriber!.role).toBe(10);
});
it("respects limit on list", async () => {
const access = createUserAccess(db);
const result = await access.list({ limit: 2 });
expect(result.items).toHaveLength(2);
expect(result.nextCursor).toBeDefined();
});
it("clamps limit to maximum of 100", async () => {
const access = createUserAccess(db);
// Should not throw for large limits — just clamp
const result = await access.list({ limit: 500 });
expect(result.items).toHaveLength(5);
});
it("clamps negative limit to minimum of 1", async () => {
const access = createUserAccess(db);
// Negative limit should be clamped to 1, not passed through
const result = await access.list({ limit: -999 });
expect(result.items).toHaveLength(1);
});
it("preserves contributor (20) and author (30) roles", async () => {
// beforeEach creates users via UserRepository with all 5 roles.
// Verify that contributor (20) and author (30) survive the round-trip.
const access = createUserAccess(db);
const contributor = await access.getByEmail("contrib@test.com");
expect(contributor).not.toBeNull();
expect(contributor!.role).toBe(20);
const author = await access.getByEmail("author@test.com");
expect(author).not.toBeNull();
expect(author!.role).toBe(30);
});
it("filters users by exact role number", async () => {
// beforeEach creates one user per role level (10, 20, 30, 40, 50)
const access = createUserAccess(db);
const contributors = await access.list({ role: 20 });
expect(contributors.items).toHaveLength(1);
expect(contributors.items[0]!.email).toBe("contrib@test.com");
expect(contributors.items[0]!.role).toBe(20);
const authors = await access.list({ role: 30 });
expect(authors.items).toHaveLength(1);
expect(authors.items[0]!.email).toBe("author@test.com");
expect(authors.items[0]!.role).toBe(30);
const admins = await access.list({ role: 50 });
expect(admins.items).toHaveLength(1);
expect(admins.items[0]!.email).toBe("admin@test.com");
});
it("supports cursor-based pagination", async () => {
const access = createUserAccess(db);
const seen = new Set<string>();
// Page through all 5 users one at a time
let cursor: string | undefined;
let pageCount = 0;
// eslint-disable-next-line no-constant-condition
while (true) {
const page = await access.list({ limit: 1, cursor });
if (page.items.length === 0) break;
expect(page.items).toHaveLength(1);
const userId = page.items[0]!.id;
expect(seen.has(userId)).toBe(false); // no duplicates
seen.add(userId);
pageCount++;
if (!page.nextCursor) break; // last page
cursor = page.nextCursor;
}
expect(seen.size).toBe(5);
expect(pageCount).toBe(5);
});
});
});

View File

@@ -0,0 +1,236 @@
/**
* Integration tests for field widget manifest pipeline.
*
* Tests that field widgets declared on collections flow through
* the manifest builder correctly, including the widget property
* and select options for select/multiSelect fields.
*/
import type { Kysely } from "kysely";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { Database } from "../../../src/database/types.js";
import { SchemaRegistry } from "../../../src/schema/registry.js";
import { setupTestDatabase } from "../../utils/test-db.js";
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await db.destroy();
});
describe("field widget on schema fields", () => {
it("should store and retrieve widget property on a field", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "theme_color",
label: "Theme Color",
type: "string",
widget: "color:picker",
});
const collection = await registry.getCollectionWithFields("posts");
expect(collection).toBeTruthy();
const colorField = collection!.fields.find((f) => f.slug === "theme_color");
expect(colorField).toBeTruthy();
expect(colorField!.widget).toBe("color:picker");
expect(colorField!.type).toBe("string");
});
it("should store and retrieve widget on a json field", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "pricing",
label: "Pricing",
type: "json",
widget: "x402:pricing",
});
const collection = await registry.getCollectionWithFields("posts");
const pricingField = collection!.fields.find((f) => f.slug === "pricing");
expect(pricingField).toBeTruthy();
expect(pricingField!.widget).toBe("x402:pricing");
expect(pricingField!.type).toBe("json");
});
it("should return undefined widget when not set", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
const collection = await registry.getCollectionWithFields("posts");
const titleField = collection!.fields.find((f) => f.slug === "title");
expect(titleField).toBeTruthy();
expect(titleField!.widget).toBeUndefined();
});
it("should update widget on an existing field", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "color",
label: "Color",
type: "string",
});
// Update to add widget
await registry.updateField("posts", "color", {
widget: "color:picker",
});
const collection = await registry.getCollectionWithFields("posts");
const colorField = collection!.fields.find((f) => f.slug === "color");
expect(colorField!.widget).toBe("color:picker");
});
it("should include select options from validation in manifest format", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "priority",
label: "Priority",
type: "select",
validation: {
options: ["low", "medium", "high"],
},
});
const collection = await registry.getCollectionWithFields("posts");
const priorityField = collection!.fields.find((f) => f.slug === "priority");
expect(priorityField).toBeTruthy();
expect(priorityField!.type).toBe("select");
expect(priorityField!.validation?.options).toEqual(["low", "medium", "high"]);
});
});
describe("field widget content CRUD", () => {
it("should save and retrieve content with a widget field value", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "title",
label: "Title",
type: "string",
});
await registry.createField("posts", {
slug: "theme_color",
label: "Theme Color",
type: "string",
widget: "color:picker",
});
// Insert content with the widget field value
const { ulid } = await import("ulidx");
const id = ulid();
await db
.insertInto("ec_posts" as never)
.values({
id,
slug: "test-post",
status: "draft",
title: "Test Post",
theme_color: "#ff6600",
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
version: 1,
} as never)
.execute();
// Read it back
const row = await db
.selectFrom("ec_posts" as never)
.selectAll()
.where("id" as never, "=", id)
.executeTakeFirst();
expect(row).toBeTruthy();
expect((row as Record<string, unknown>).theme_color).toBe("#ff6600");
});
it("should save and retrieve json widget field value", async () => {
const registry = new SchemaRegistry(db);
await registry.createCollection({
slug: "posts",
label: "Posts",
labelSingular: "Post",
});
await registry.createField("posts", {
slug: "pricing",
label: "Pricing",
type: "json",
widget: "x402:pricing",
});
const { ulid } = await import("ulidx");
const id = ulid();
const pricingValue = JSON.stringify({ enabled: true, price: "$0.10", gateMode: "bots" });
await db
.insertInto("ec_posts" as never)
.values({
id,
slug: "premium-post",
status: "draft",
pricing: pricingValue,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
version: 1,
} as never)
.execute();
const row = await db
.selectFrom("ec_posts" as never)
.selectAll()
.where("id" as never, "=", id)
.executeTakeFirst();
expect(row).toBeTruthy();
const pricing = JSON.parse((row as Record<string, unknown>).pricing as string);
expect(pricing.enabled).toBe(true);
expect(pricing.price).toBe("$0.10");
expect(pricing.gateMode).toBe("bots");
});
});

View File

@@ -0,0 +1,380 @@
import type { Kysely } from "kysely";
import { sql } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { PluginStorageRepository } from "../../../src/database/repositories/plugin-storage.js";
import type { Database } from "../../../src/database/types.js";
import {
createStorageIndexes,
removeOrphanedIndexes,
syncStorageIndexes,
removeAllPluginIndexes,
getPluginIndexStatus,
} from "../../../src/plugins/storage-indexes.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
const UNIQUE_CONSTRAINT_PATTERN = /UNIQUE constraint failed/;
describe("Plugin Storage Indexes Integration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
describe("createStorageIndexes", () => {
it("should create single-field index", async () => {
const result = await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
expect(result.created).toContain("idx_plugin_my-plugin_events_eventType");
expect(result.errors).toHaveLength(0);
});
it("should create composite index", async () => {
const result = await createStorageIndexes(db, "my-plugin", "events", [
["status", "createdAt"],
]);
expect(result.created).toContain("idx_plugin_my-plugin_events_status_createdAt");
expect(result.errors).toHaveLength(0);
});
it("should create multiple indexes", async () => {
const result = await createStorageIndexes(db, "my-plugin", "events", [
"eventType",
"userId",
["status", "timestamp"],
]);
expect(result.created).toHaveLength(3);
expect(result.errors).toHaveLength(0);
});
it("should track indexes in _plugin_indexes table", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]);
const indexes = await db
.selectFrom("_plugin_indexes")
.selectAll()
.where("plugin_id", "=", "my-plugin")
.execute();
expect(indexes).toHaveLength(2);
expect(indexes.map((i) => JSON.parse(i.fields))).toContainEqual(["eventType"]);
expect(indexes.map((i) => JSON.parse(i.fields))).toContainEqual(["userId"]);
});
it("should be idempotent", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
const result = await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
// Should still succeed
expect(result.errors).toHaveLength(0);
// Should not duplicate tracking records
const indexes = await db
.selectFrom("_plugin_indexes")
.selectAll()
.where("plugin_id", "=", "my-plugin")
.execute();
expect(indexes).toHaveLength(1);
});
});
describe("removeOrphanedIndexes", () => {
it("should remove indexes no longer in declaration", async () => {
// Create initial indexes
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId", "status"]);
// Remove one
const result = await removeOrphanedIndexes(db, "my-plugin", "events", [
"eventType",
"userId",
]);
expect(result.removed).toContain("idx_plugin_my-plugin_events_status");
expect(result.errors).toHaveLength(0);
});
it("should keep indexes that are still declared", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]);
const result = await removeOrphanedIndexes(db, "my-plugin", "events", [
"eventType",
"userId",
]);
expect(result.removed).toHaveLength(0);
});
it("should update tracking table", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "status"]);
await removeOrphanedIndexes(db, "my-plugin", "events", ["eventType"]);
const indexes = await db
.selectFrom("_plugin_indexes")
.selectAll()
.where("plugin_id", "=", "my-plugin")
.execute();
expect(indexes).toHaveLength(1);
expect(JSON.parse(indexes[0].fields)).toEqual(["eventType"]);
});
});
describe("syncStorageIndexes", () => {
it("should create new and remove old indexes in one call", async () => {
// Initial state
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "oldField"]);
// Sync to new state
const result = await syncStorageIndexes(db, "my-plugin", "events", ["eventType", "newField"]);
expect(result.created).toContain("idx_plugin_my-plugin_events_newField");
expect(result.removed).toContain("idx_plugin_my-plugin_events_oldField");
const status = await getPluginIndexStatus(db, "my-plugin");
const fields = status.map((s) => s.fields);
expect(fields).toContainEqual(["eventType"]);
expect(fields).toContainEqual(["newField"]);
expect(fields).not.toContainEqual(["oldField"]);
});
});
describe("removeAllPluginIndexes", () => {
it("should remove all indexes for a plugin", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType", "userId"]);
await createStorageIndexes(db, "my-plugin", "cache", ["key", "expiresAt"]);
const result = await removeAllPluginIndexes(db, "my-plugin");
expect(result.removed).toHaveLength(4);
expect(result.errors).toHaveLength(0);
const remaining = await db
.selectFrom("_plugin_indexes")
.selectAll()
.where("plugin_id", "=", "my-plugin")
.execute();
expect(remaining).toHaveLength(0);
});
it("should not affect other plugins", async () => {
await createStorageIndexes(db, "plugin1", "events", ["eventType"]);
await createStorageIndexes(db, "plugin2", "events", ["eventType"]);
await removeAllPluginIndexes(db, "plugin1");
const plugin2Indexes = await db
.selectFrom("_plugin_indexes")
.selectAll()
.where("plugin_id", "=", "plugin2")
.execute();
expect(plugin2Indexes).toHaveLength(1);
});
});
describe("getPluginIndexStatus", () => {
it("should return all indexes for a plugin", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType", ["status", "timestamp"]]);
await createStorageIndexes(db, "my-plugin", "cache", ["key"]);
const status = await getPluginIndexStatus(db, "my-plugin");
expect(status).toHaveLength(3);
expect(status).toContainEqual(
expect.objectContaining({
collection: "events",
fields: ["eventType"],
}),
);
expect(status).toContainEqual(
expect.objectContaining({
collection: "events",
fields: ["status", "timestamp"],
}),
);
expect(status).toContainEqual(
expect.objectContaining({
collection: "cache",
fields: ["key"],
}),
);
});
it("should return empty array for plugin with no indexes", async () => {
const status = await getPluginIndexStatus(db, "nonexistent-plugin");
expect(status).toEqual([]);
});
});
describe("query performance with indexes", () => {
it("should efficiently query using indexed fields", async () => {
const pluginId = "perf-test";
const collection = "events";
// Create index first
await createStorageIndexes(db, pluginId, collection, ["eventType"]);
// Create repository with the indexed field
const repo = new PluginStorageRepository<{ eventType: string }>(db, pluginId, collection, [
"eventType",
]);
// Insert test data
const items = Array.from({ length: 100 }, (_, i) => ({
id: `event-${i}`,
data: { eventType: i % 2 === 0 ? "pageview" : "click" },
}));
await repo.putMany(items);
// Query should work and use the index
const result = await repo.query({
where: { eventType: "pageview" },
});
expect(result.items).toHaveLength(50);
expect(result.items.every((i) => i.data.eventType === "pageview")).toBe(true);
});
});
describe("index verification", () => {
it("should create actual SQLite index", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
// Query SQLite's index list
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master
WHERE type = 'index'
AND name LIKE 'idx_plugin_%'
`.execute(db);
expect(indexes.rows.map((r) => r.name)).toContain("idx_plugin_my-plugin_events_eventType");
});
it("should drop actual SQLite index on removal", async () => {
await createStorageIndexes(db, "my-plugin", "events", ["eventType"]);
await removeAllPluginIndexes(db, "my-plugin");
const indexes = await sql<{ name: string }>`
SELECT name FROM sqlite_master
WHERE type = 'index'
AND name LIKE 'idx_plugin_my-plugin_%'
`.execute(db);
expect(indexes.rows).toHaveLength(0);
});
});
describe("unique indexes", () => {
it("should create a unique index", async () => {
const result = await createStorageIndexes(db, "my-plugin", "forms", [], {
uniqueIndexes: ["slug"],
});
expect(result.created).toContain("uidx_plugin_my-plugin_forms_slug");
expect(result.errors).toHaveLength(0);
// Verify it's actually a UNIQUE index in SQLite
const indexSql = await sql<{ sql: string }>`
SELECT sql FROM sqlite_master
WHERE type = 'index'
AND name = 'uidx_plugin_my-plugin_forms_slug'
`.execute(db);
expect(indexSql.rows).toHaveLength(1);
expect(indexSql.rows[0].sql).toContain("UNIQUE");
});
it("should enforce uniqueness on insert", async () => {
await createStorageIndexes(db, "my-plugin", "forms", [], {
uniqueIndexes: ["slug"],
});
const repo = new PluginStorageRepository<{ slug: string; name: string }>(
db,
"my-plugin",
"forms",
["slug"],
);
await repo.put("form-1", { slug: "contact", name: "Contact" });
// Second insert with a different ID but same slug should fail
await expect(repo.put("form-2", { slug: "contact", name: "Contact Copy" })).rejects.toThrow(
UNIQUE_CONSTRAINT_PATTERN,
);
});
it("should allow updating the same document", async () => {
await createStorageIndexes(db, "my-plugin", "forms", [], {
uniqueIndexes: ["slug"],
});
const repo = new PluginStorageRepository<{ slug: string; name: string }>(
db,
"my-plugin",
"forms",
["slug"],
);
await repo.put("form-1", { slug: "contact", name: "Contact" });
// Updating the same ID should succeed (upsert)
await repo.put("form-1", { slug: "contact", name: "Contact Updated" });
const result = await repo.get("form-1");
expect(result?.name).toBe("Contact Updated");
});
it("should allow different slugs across different collections", async () => {
await createStorageIndexes(db, "my-plugin", "forms", [], {
uniqueIndexes: ["slug"],
});
await createStorageIndexes(db, "my-plugin", "templates", [], {
uniqueIndexes: ["slug"],
});
const formsRepo = new PluginStorageRepository<{ slug: string }>(db, "my-plugin", "forms", [
"slug",
]);
const templatesRepo = new PluginStorageRepository<{ slug: string }>(
db,
"my-plugin",
"templates",
["slug"],
);
// Same slug in different collections should work (partial index scoped by collection)
await formsRepo.put("form-1", { slug: "contact" });
await templatesRepo.put("tmpl-1", { slug: "contact" });
expect(await formsRepo.get("form-1")).toEqual({ slug: "contact" });
expect(await templatesRepo.get("tmpl-1")).toEqual({ slug: "contact" });
});
it("should include unique index fields in queryable fields", async () => {
await createStorageIndexes(db, "my-plugin", "forms", ["status"], {
uniqueIndexes: ["slug"],
});
const repo = new PluginStorageRepository<{ slug: string; status: string }>(
db,
"my-plugin",
"forms",
["status", "slug"],
);
await repo.put("form-1", { slug: "contact", status: "active" });
await repo.put("form-2", { slug: "feedback", status: "active" });
// Query by unique field should work
const result = await repo.query({ where: { slug: "contact" } });
expect(result.items).toHaveLength(1);
expect(result.items[0].data.slug).toBe("contact");
});
});
});

View File

@@ -0,0 +1,293 @@
import type { Kysely } from "kysely";
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import {
PluginStorageRepository,
createPluginStorageAccessor,
deleteAllPluginStorage,
deletePluginCollection,
} from "../../../src/database/repositories/plugin-storage.js";
import type { Database } from "../../../src/database/types.js";
import { setupTestDatabase, teardownTestDatabase } from "../../utils/test-db.js";
interface AnalyticsEvent {
eventType: string;
userId: string;
timestamp: string;
metadata: Record<string, unknown>;
}
describe("Plugin Storage Integration", () => {
let db: Kysely<Database>;
beforeEach(async () => {
db = await setupTestDatabase();
});
afterEach(async () => {
await teardownTestDatabase(db);
});
describe("full storage flow", () => {
it("should support complete CRUD cycle", async () => {
const repo = new PluginStorageRepository<AnalyticsEvent>(db, "analytics-plugin", "events", [
"eventType",
"userId",
"timestamp",
]);
// Create
const event: AnalyticsEvent = {
eventType: "pageview",
userId: "user123",
timestamp: new Date().toISOString(),
metadata: { page: "/home", referrer: "google.com" },
};
await repo.put("event1", event);
// Read
const fetched = await repo.get("event1");
expect(fetched).toEqual(event);
// Update
const updatedEvent = {
...event,
metadata: { ...event.metadata, duration: 5000 },
};
await repo.put("event1", updatedEvent);
const refetched = await repo.get("event1");
expect(refetched?.metadata).toHaveProperty("duration", 5000);
// Delete
const deleted = await repo.delete("event1");
expect(deleted).toBe(true);
expect(await repo.get("event1")).toBeNull();
});
it("should support complex queries with JSON extraction", async () => {
const repo = new PluginStorageRepository<AnalyticsEvent>(db, "analytics-plugin", "events", [
"eventType",
"userId",
"timestamp",
]);
// Create events
await repo.putMany([
{
id: "e1",
data: {
eventType: "pageview",
userId: "user1",
timestamp: "2024-01-01T10:00:00Z",
metadata: {},
},
},
{
id: "e2",
data: {
eventType: "click",
userId: "user1",
timestamp: "2024-01-01T10:05:00Z",
metadata: {},
},
},
{
id: "e3",
data: {
eventType: "pageview",
userId: "user2",
timestamp: "2024-01-01T11:00:00Z",
metadata: {},
},
},
]);
// Query by eventType
const pageviews = await repo.query({ where: { eventType: "pageview" } });
expect(pageviews.items).toHaveLength(2);
// Query by userId
const user1Events = await repo.query({ where: { userId: "user1" } });
expect(user1Events.items).toHaveLength(2);
// Combined query
const user1Pageviews = await repo.query({
where: { eventType: "pageview", userId: "user1" },
});
expect(user1Pageviews.items).toHaveLength(1);
});
});
describe("createPluginStorageAccessor", () => {
it("should create accessor with multiple collections", async () => {
const accessor = createPluginStorageAccessor(db, "my-plugin", {
events: { indexes: ["eventType", "timestamp"] },
cache: { indexes: ["key", "expiresAt"] },
});
expect(accessor).toHaveProperty("events");
expect(accessor).toHaveProperty("cache");
// Use events collection
await accessor.events.put("e1", {
eventType: "test",
timestamp: new Date().toISOString(),
});
const event = await accessor.events.get("e1");
expect(event).toBeDefined();
// Use cache collection
await accessor.cache.put("c1", {
key: "test-key",
value: "test-value",
expiresAt: new Date().toISOString(),
});
const cached = await accessor.cache.get("c1");
expect(cached).toBeDefined();
});
it("should isolate collections from each other", async () => {
const accessor = createPluginStorageAccessor(db, "my-plugin", {
events: { indexes: ["eventType"] },
cache: { indexes: ["key"] },
});
await accessor.events.put("item1", { eventType: "test" });
await accessor.cache.put("item1", { key: "test" });
// Both should exist independently
expect(await accessor.events.get("item1")).toEqual({ eventType: "test" });
expect(await accessor.cache.get("item1")).toEqual({ key: "test" });
// Count should be separate
expect(
await (accessor.events as PluginStorageRepository<any>).count({
eventType: "test",
}),
).toBe(1);
expect(
await (accessor.cache as PluginStorageRepository<any>).count({
key: "test",
}),
).toBe(1);
});
});
describe("deleteAllPluginStorage", () => {
it("should delete all data for a plugin", async () => {
const accessor = createPluginStorageAccessor(db, "cleanup-plugin", {
events: { indexes: ["eventType"] },
cache: { indexes: ["key"] },
});
// Add data
await accessor.events.put("e1", { eventType: "test" });
await accessor.events.put("e2", { eventType: "test2" });
await accessor.cache.put("c1", { key: "test" });
// Delete all
const deleted = await deleteAllPluginStorage(db, "cleanup-plugin");
expect(deleted).toBe(3);
// Verify empty
expect(await accessor.events.get("e1")).toBeNull();
expect(await accessor.events.get("e2")).toBeNull();
expect(await accessor.cache.get("c1")).toBeNull();
});
it("should not affect other plugins", async () => {
const plugin1 = createPluginStorageAccessor(db, "plugin1", {
data: { indexes: ["key"] },
});
const plugin2 = createPluginStorageAccessor(db, "plugin2", {
data: { indexes: ["key"] },
});
await plugin1.data.put("item1", { key: "test" });
await plugin2.data.put("item1", { key: "test" });
await deleteAllPluginStorage(db, "plugin1");
expect(await plugin1.data.get("item1")).toBeNull();
expect(await plugin2.data.get("item1")).toEqual({ key: "test" });
});
});
describe("deletePluginCollection", () => {
it("should delete specific collection", async () => {
const accessor = createPluginStorageAccessor(db, "my-plugin", {
events: { indexes: ["eventType"] },
cache: { indexes: ["key"] },
});
await accessor.events.put("e1", { eventType: "test" });
await accessor.cache.put("c1", { key: "test" });
await deletePluginCollection(db, "my-plugin", "events");
expect(await accessor.events.get("e1")).toBeNull();
expect(await accessor.cache.get("c1")).toEqual({ key: "test" });
});
});
describe("pagination", () => {
it("should paginate through large datasets", async () => {
const repo = new PluginStorageRepository<{ index: number }>(
db,
"pagination-test",
"items",
[],
);
// Create 25 items
const items = Array.from({ length: 25 }, (_, i) => ({
id: `item-${String(i).padStart(3, "0")}`,
data: { index: i },
}));
await repo.putMany(items);
// Paginate with limit of 10
const pages: Array<Array<{ id: string; data: { index: number } }>> = [];
let cursor: string | undefined;
do {
const result = await repo.query({ limit: 10, cursor });
pages.push(result.items);
cursor = result.cursor;
} while (cursor);
expect(pages).toHaveLength(3);
expect(pages[0]).toHaveLength(10);
expect(pages[1]).toHaveLength(10);
expect(pages[2]).toHaveLength(5);
// Verify all items were retrieved
const allItems = pages.flat();
expect(allItems).toHaveLength(25);
expect(new Set(allItems.map((i) => i.id)).size).toBe(25);
});
});
describe("concurrent operations", () => {
it("should handle concurrent puts", async () => {
const repo = new PluginStorageRepository<{ value: number }>(
db,
"concurrent-test",
"items",
[],
);
// Concurrent puts
await Promise.all([
repo.put("item1", { value: 1 }),
repo.put("item2", { value: 2 }),
repo.put("item3", { value: 3 }),
repo.put("item4", { value: 4 }),
repo.put("item5", { value: 5 }),
]);
const count = await repo.count();
expect(count).toBe(5);
});
});
});