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:
961
packages/core/tests/integration/plugins/capabilities.test.ts
Normal file
961
packages/core/tests/integration/plugins/capabilities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
236
packages/core/tests/integration/plugins/field-widgets.test.ts
Normal file
236
packages/core/tests/integration/plugins/field-widgets.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
380
packages/core/tests/integration/plugins/storage-indexes.test.ts
Normal file
380
packages/core/tests/integration/plugins/storage-indexes.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
293
packages/core/tests/integration/plugins/storage.test.ts
Normal file
293
packages/core/tests/integration/plugins/storage.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user