first commit
This commit is contained in:
19
packages/plugins/atproto/tests/atproto.test.ts
Normal file
19
packages/plugins/atproto/tests/atproto.test.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { rkeyFromUri } from "../src/atproto.js";
|
||||
|
||||
describe("rkeyFromUri", () => {
|
||||
it("extracts rkey from a standard AT-URI", () => {
|
||||
const rkey = rkeyFromUri("at://did:plc:abc123/site.standard.document/3lwafzkjqm25s");
|
||||
expect(rkey).toBe("3lwafzkjqm25s");
|
||||
});
|
||||
|
||||
it("extracts rkey from a Bluesky post URI", () => {
|
||||
const rkey = rkeyFromUri("at://did:plc:abc123/app.bsky.feed.post/3k4duaz5vfs2b");
|
||||
expect(rkey).toBe("3k4duaz5vfs2b");
|
||||
});
|
||||
|
||||
it("throws on empty URI", () => {
|
||||
expect(() => rkeyFromUri("")).toThrow("Invalid AT-URI");
|
||||
});
|
||||
});
|
||||
209
packages/plugins/atproto/tests/bluesky.test.ts
Normal file
209
packages/plugins/atproto/tests/bluesky.test.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildBskyPost, buildFacets } from "../src/bluesky.js";
|
||||
|
||||
describe("buildFacets", () => {
|
||||
it("detects URLs and returns correct byte offsets", () => {
|
||||
const text = "Check out https://example.com for more";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(1);
|
||||
|
||||
const facet = facets[0]!;
|
||||
expect(facet.features[0]).toEqual({
|
||||
$type: "app.bsky.richtext.facet#link",
|
||||
uri: "https://example.com",
|
||||
});
|
||||
|
||||
// Verify byte offsets match
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(text);
|
||||
const extracted = new TextDecoder().decode(
|
||||
bytes.slice(facet.index.byteStart, facet.index.byteEnd),
|
||||
);
|
||||
expect(extracted).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("handles multiple URLs", () => {
|
||||
const text = "Visit https://a.com and https://b.com today";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(2);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://a.com");
|
||||
expect(facets[1]!.features[0]).toHaveProperty("uri", "https://b.com");
|
||||
});
|
||||
|
||||
it("detects hashtags", () => {
|
||||
const text = "Hello #world #atproto";
|
||||
const facets = buildFacets(text);
|
||||
const tagFacets = facets.filter((f) => f.features[0]?.$type === "app.bsky.richtext.facet#tag");
|
||||
expect(tagFacets).toHaveLength(2);
|
||||
expect(tagFacets[0]!.features[0]).toHaveProperty("tag", "world");
|
||||
expect(tagFacets[1]!.features[0]).toHaveProperty("tag", "atproto");
|
||||
});
|
||||
|
||||
it("handles UTF-8 multibyte characters before URLs", () => {
|
||||
// Emoji is multiple UTF-8 bytes but one grapheme
|
||||
const text = "Great post! 🎉 https://example.com";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(1);
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const bytes = encoder.encode(text);
|
||||
const extracted = new TextDecoder().decode(
|
||||
bytes.slice(facets[0]!.index.byteStart, facets[0]!.index.byteEnd),
|
||||
);
|
||||
expect(extracted).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("returns empty array for text with no URLs or hashtags", () => {
|
||||
const facets = buildFacets("Just some plain text here");
|
||||
expect(facets).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not match hashtag at start of word", () => {
|
||||
// Hashtag requires preceding whitespace or start of string
|
||||
const text = "foo#bar";
|
||||
const facets = buildFacets(text);
|
||||
const tagFacets = facets.filter((f) => f.features[0]?.$type === "app.bsky.richtext.facet#tag");
|
||||
expect(tagFacets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("strips trailing punctuation from URLs", () => {
|
||||
const text = "Visit https://example.com/post. More text";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(1);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com/post");
|
||||
});
|
||||
|
||||
it("strips trailing comma from URL", () => {
|
||||
const text = "See https://example.com/a, https://example.com/b";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets).toHaveLength(2);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com/a");
|
||||
expect(facets[1]!.features[0]).toHaveProperty("uri", "https://example.com/b");
|
||||
});
|
||||
|
||||
it("strips trailing exclamation from URL", () => {
|
||||
const text = "Check https://example.com!";
|
||||
const facets = buildFacets(text);
|
||||
expect(facets[0]!.features[0]).toHaveProperty("uri", "https://example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBskyPost", () => {
|
||||
const baseContent = {
|
||||
title: "My Article",
|
||||
slug: "my-article",
|
||||
excerpt: "A short description",
|
||||
};
|
||||
|
||||
it("builds a post with template substitution", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}\n\n{url}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
|
||||
expect(post.$type).toBe("app.bsky.feed.post");
|
||||
expect(post.text).toBe("My Article\n\nhttps://myblog.com/my-article");
|
||||
expect(post.createdAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("includes langs when provided", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
langs: ["en", "fr"],
|
||||
});
|
||||
expect(post.langs).toEqual(["en", "fr"]);
|
||||
});
|
||||
|
||||
it("limits langs to 3", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
langs: ["en", "fr", "de", "es"],
|
||||
});
|
||||
expect(post.langs).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("includes link card embed", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
|
||||
expect(post.embed).toEqual({
|
||||
$type: "app.bsky.embed.external",
|
||||
external: {
|
||||
uri: "https://myblog.com/my-article",
|
||||
title: "My Article",
|
||||
description: "A short description",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("includes thumb in embed when provided", () => {
|
||||
const thumb = {
|
||||
$type: "blob" as const,
|
||||
ref: { $link: "bafkrei123" },
|
||||
mimeType: "image/jpeg",
|
||||
size: 45000,
|
||||
};
|
||||
|
||||
const post = buildBskyPost({
|
||||
template: "{title}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
thumbBlob: thumb,
|
||||
});
|
||||
|
||||
expect(post.embed?.external.thumb).toBe(thumb);
|
||||
});
|
||||
|
||||
it("auto-detects URLs in text for facets", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "New post: {url}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
|
||||
expect(post.facets).toBeDefined();
|
||||
expect(post.facets!.length).toBeGreaterThan(0);
|
||||
expect(post.facets![0]!.features[0]).toHaveProperty("uri", "https://myblog.com/my-article");
|
||||
});
|
||||
|
||||
it("substitutes {excerpt} in template", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{title}: {excerpt}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
expect(post.text).toBe("My Article: A short description");
|
||||
});
|
||||
|
||||
it("strips trailing slash from siteUrl", () => {
|
||||
const post = buildBskyPost({
|
||||
template: "{url}",
|
||||
content: baseContent,
|
||||
siteUrl: "https://myblog.com/",
|
||||
});
|
||||
expect(post.text).toBe("https://myblog.com/my-article");
|
||||
});
|
||||
|
||||
it("skips facets when text is truncated to avoid partial URL links", () => {
|
||||
// Create content with very long excerpt that forces truncation
|
||||
const longExcerpt = "A".repeat(300);
|
||||
const post = buildBskyPost({
|
||||
template: "{excerpt} {url}",
|
||||
content: { ...baseContent, excerpt: longExcerpt },
|
||||
siteUrl: "https://myblog.com",
|
||||
});
|
||||
// Text was truncated (>300 graphemes), so facets should be omitted
|
||||
expect(post.facets).toBeUndefined();
|
||||
// But embed should still have the full URL
|
||||
expect(post.embed?.external.uri).toBe("https://myblog.com/my-article");
|
||||
});
|
||||
});
|
||||
82
packages/plugins/atproto/tests/plugin.test.ts
Normal file
82
packages/plugins/atproto/tests/plugin.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { atprotoPlugin, createPlugin } from "../src/index.js";
|
||||
|
||||
describe("atprotoPlugin descriptor", () => {
|
||||
it("returns a valid PluginDescriptor", () => {
|
||||
const descriptor = atprotoPlugin();
|
||||
expect(descriptor.id).toBe("atproto");
|
||||
expect(descriptor.version).toBe("0.1.0");
|
||||
expect(descriptor.entrypoint).toBe("@emdashcms/plugin-atproto");
|
||||
expect(descriptor.adminPages).toHaveLength(1);
|
||||
expect(descriptor.adminWidgets).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("passes options through", () => {
|
||||
const descriptor = atprotoPlugin({});
|
||||
expect(descriptor.options).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPlugin", () => {
|
||||
it("returns a valid ResolvedPlugin", () => {
|
||||
const plugin = createPlugin();
|
||||
expect(plugin.id).toBe("atproto");
|
||||
expect(plugin.version).toBe("0.1.0");
|
||||
expect(plugin.capabilities).toContain("read:content");
|
||||
expect(plugin.capabilities).toContain("network:fetch:any");
|
||||
});
|
||||
|
||||
it("uses unrestricted network access (implies network:fetch)", () => {
|
||||
const plugin = createPlugin();
|
||||
expect(plugin.capabilities).toContain("network:fetch:any");
|
||||
// network:fetch:any implies network:fetch via definePlugin normalization
|
||||
expect(plugin.capabilities).toContain("network:fetch");
|
||||
});
|
||||
|
||||
it("declares storage with records collection", () => {
|
||||
const plugin = createPlugin();
|
||||
expect(plugin.storage).toHaveProperty("records");
|
||||
expect(plugin.storage!.records!.indexes).toContain("contentId");
|
||||
expect(plugin.storage!.records!.indexes).toContain("status");
|
||||
});
|
||||
|
||||
it("has content:afterSave hook with errorPolicy continue", () => {
|
||||
const plugin = createPlugin();
|
||||
const hook = plugin.hooks!["content:afterSave"];
|
||||
expect(hook).toBeDefined();
|
||||
// Hook is configured with full config object
|
||||
expect((hook as { errorPolicy: string }).errorPolicy).toBe("continue");
|
||||
});
|
||||
|
||||
it("has content:afterDelete hook", () => {
|
||||
const plugin = createPlugin();
|
||||
expect(plugin.hooks!["content:afterDelete"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has page:metadata hook", () => {
|
||||
const plugin = createPlugin();
|
||||
expect(plugin.hooks!["page:metadata"]).toBeDefined();
|
||||
});
|
||||
|
||||
it("has settings schema with required fields", () => {
|
||||
const plugin = createPlugin();
|
||||
const schema = plugin.admin!.settingsSchema!;
|
||||
expect(schema).toHaveProperty("handle");
|
||||
expect(schema).toHaveProperty("appPassword");
|
||||
expect(schema).toHaveProperty("siteUrl");
|
||||
expect(schema).toHaveProperty("enableBskyCrosspost");
|
||||
expect(schema).toHaveProperty("crosspostTemplate");
|
||||
expect(schema).toHaveProperty("langs");
|
||||
expect(schema.appPassword!.type).toBe("secret");
|
||||
});
|
||||
|
||||
it("has routes for status, test-connection, sync-publication", () => {
|
||||
const plugin = createPlugin();
|
||||
expect(plugin.routes).toHaveProperty("status");
|
||||
expect(plugin.routes).toHaveProperty("test-connection");
|
||||
expect(plugin.routes).toHaveProperty("sync-publication");
|
||||
expect(plugin.routes).toHaveProperty("recent-syncs");
|
||||
expect(plugin.routes).toHaveProperty("verification");
|
||||
});
|
||||
});
|
||||
174
packages/plugins/atproto/tests/standard-site.test.ts
Normal file
174
packages/plugins/atproto/tests/standard-site.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildPublication, buildDocument, extractPlainText } from "../src/standard-site.js";
|
||||
|
||||
describe("buildPublication", () => {
|
||||
it("builds a publication record with required fields", () => {
|
||||
const pub = buildPublication("https://myblog.com", "My Blog");
|
||||
expect(pub).toEqual({
|
||||
$type: "site.standard.publication",
|
||||
url: "https://myblog.com",
|
||||
name: "My Blog",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips trailing slash from URL", () => {
|
||||
const pub = buildPublication("https://myblog.com/", "My Blog");
|
||||
expect(pub.url).toBe("https://myblog.com");
|
||||
});
|
||||
|
||||
it("includes description when provided", () => {
|
||||
const pub = buildPublication("https://myblog.com", "My Blog", "A personal blog");
|
||||
expect(pub.description).toBe("A personal blog");
|
||||
});
|
||||
|
||||
it("omits description when not provided", () => {
|
||||
const pub = buildPublication("https://myblog.com", "My Blog");
|
||||
expect(pub).not.toHaveProperty("description");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDocument", () => {
|
||||
const baseOpts = {
|
||||
publicationUri: "at://did:plc:abc123/site.standard.publication/3lwafz",
|
||||
content: {
|
||||
title: "Hello World",
|
||||
slug: "hello-world",
|
||||
excerpt: "A great post",
|
||||
published_at: "2025-01-15T12:00:00.000Z",
|
||||
updated_at: "2025-01-16T10:00:00.000Z",
|
||||
body: "<p>This is the body</p>",
|
||||
tags: ["tech", "web"],
|
||||
},
|
||||
};
|
||||
|
||||
it("builds a document with all fields", () => {
|
||||
const doc = buildDocument(baseOpts);
|
||||
expect(doc.$type).toBe("site.standard.document");
|
||||
expect(doc.site).toBe(baseOpts.publicationUri);
|
||||
expect(doc.title).toBe("Hello World");
|
||||
expect(doc.path).toBe("/hello-world");
|
||||
expect(doc.description).toBe("A great post");
|
||||
expect(doc.publishedAt).toBe("2025-01-15T12:00:00.000Z");
|
||||
expect(doc.updatedAt).toBe("2025-01-16T10:00:00.000Z");
|
||||
expect(doc.tags).toEqual(["tech", "web"]);
|
||||
expect(doc.textContent).toBe("This is the body");
|
||||
});
|
||||
|
||||
it("uses excerpt field for description", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { ...baseOpts.content, excerpt: undefined, description: "fallback desc" },
|
||||
});
|
||||
expect(doc.description).toBe("fallback desc");
|
||||
});
|
||||
|
||||
it("defaults title to Untitled", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { published_at: "2025-01-15T12:00:00.000Z" },
|
||||
});
|
||||
expect(doc.title).toBe("Untitled");
|
||||
});
|
||||
|
||||
it("omits path when slug is missing", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { title: "No Slug", published_at: "2025-01-15T12:00:00.000Z" },
|
||||
});
|
||||
expect(doc.path).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes bskyPostRef when provided", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
bskyPostRef: { uri: "at://did:plc:xyz/app.bsky.feed.post/abc", cid: "bafyrei123" },
|
||||
});
|
||||
expect(doc.bskyPostRef).toEqual({
|
||||
uri: "at://did:plc:xyz/app.bsky.feed.post/abc",
|
||||
cid: "bafyrei123",
|
||||
});
|
||||
});
|
||||
|
||||
it("includes coverImage when provided", () => {
|
||||
const blob = {
|
||||
$type: "blob" as const,
|
||||
ref: { $link: "bafkrei123" },
|
||||
mimeType: "image/jpeg",
|
||||
size: 45000,
|
||||
};
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
coverImageBlob: blob,
|
||||
});
|
||||
expect(doc.coverImage).toBe(blob);
|
||||
});
|
||||
|
||||
it("handles tag objects with name property", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: {
|
||||
...baseOpts.content,
|
||||
tags: [{ name: "javascript" }, { name: "#python" }],
|
||||
},
|
||||
});
|
||||
expect(doc.tags).toEqual(["javascript", "python"]);
|
||||
});
|
||||
|
||||
it("strips # prefix from string tags", () => {
|
||||
const doc = buildDocument({
|
||||
...baseOpts,
|
||||
content: { ...baseOpts.content, tags: ["#tech", "web", "#dev"] },
|
||||
});
|
||||
expect(doc.tags).toEqual(["tech", "web", "dev"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractPlainText", () => {
|
||||
it("strips HTML tags", () => {
|
||||
const text = extractPlainText({ body: "<p>Hello <strong>world</strong></p>" });
|
||||
expect(text).toBe("Hello world");
|
||||
});
|
||||
|
||||
it("decodes HTML entities", () => {
|
||||
const text = extractPlainText({ body: "Tom & Jerry <3 > "fun"" });
|
||||
expect(text).toBe('Tom & Jerry <3 > "fun"');
|
||||
});
|
||||
|
||||
it("collapses whitespace", () => {
|
||||
const text = extractPlainText({ body: "<p>Hello</p>\n\n<p>World</p>" });
|
||||
expect(text).toBe("Hello World");
|
||||
});
|
||||
|
||||
it("tries body, content, then text fields", () => {
|
||||
expect(extractPlainText({ body: "from body" })).toBe("from body");
|
||||
expect(extractPlainText({ content: "from content" })).toBe("from content");
|
||||
expect(extractPlainText({ text: "from text" })).toBe("from text");
|
||||
});
|
||||
|
||||
it("returns undefined when no content field exists", () => {
|
||||
expect(extractPlainText({ title: "just a title" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty body", () => {
|
||||
expect(extractPlainText({ body: "" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles ", () => {
|
||||
const text = extractPlainText({ body: "hello world" });
|
||||
expect(text).toBe("hello world");
|
||||
});
|
||||
|
||||
it("does not double-decode &lt;", () => {
|
||||
// &lt; should become < (literal text), not <
|
||||
const text = extractPlainText({ body: "code: &lt;div&gt;" });
|
||||
expect(text).toBe("code: <div>");
|
||||
});
|
||||
|
||||
it("truncates very long text content", () => {
|
||||
const longBody = "A".repeat(20_000);
|
||||
const text = extractPlainText({ body: longBody });
|
||||
expect(text!.length).toBeLessThanOrEqual(10_000);
|
||||
expect(text!.endsWith("\u2026")).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user