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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ Required in `.env.local`:
- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_PHONE_NUMBER`
- `NEXT_PUBLIC_BASE_URL`
- `CRON_SECRET` (Vercel Cron authentication)
- `SLACK_BOT_TOKEN`, `SLACK_SIGNING_SECRET` (Slack bot for article generation)

## Logging

Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/helpers/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,12 @@ export async function cleanAll() {
const { error: orgErr } = await db.from("organizations").delete().neq("id", NIL);
if (orgErr) console.error("cleanAll organizations:", orgErr.message);

const { error: threadErr } = await db.from("marketing_article_threads").delete().neq("id", NIL);
if (threadErr) console.error("cleanAll marketing_article_threads:", threadErr.message);

const { error: articleErr } = await db.from("marketing_articles").delete().neq("id", NIL);
if (articleErr) console.error("cleanAll marketing_articles:", articleErr.message);

const { error: profErr } = await db.from("profiles").delete().neq("id", NIL);
if (profErr) console.error("cleanAll profiles:", profErr.message);

Expand Down
286 changes: 286 additions & 0 deletions src/__tests__/lib/db/articles.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,286 @@
import { describe, it, expect, afterEach } from "vitest";
import { createDbClient } from "@/lib/db/client";
import {
getPublishedArticles,
getArticleBySlug,
getAllPublishedSlugs,
insertArticle,
updateArticle,
insertArticleThread,
getThreadBySlack,
updateThreadResponseId,
} from "@/lib/db/articles";

const db = createDbClient();
const NIL = "00000000-0000-0000-0000-000000000000";

async function seedArticle(overrides: Record<string, unknown> = {}) {
const { data, error } = await db
.from("marketing_articles")
.insert({
slug: `test-article-${crypto.randomUUID().slice(0, 8)}`,
title: "Test Article",
description: "A test article description",
content: "# Hello\n\nThis is test content.",
author: "Post Imp Team",
tags: ["test"],
published: true,
published_at: new Date().toISOString(),
...overrides,
})
.select("id, slug")
.single();

if (error) throw new Error(`seedArticle: ${error.message}`);
return data!;
}

async function cleanAll() {
await db.from("marketing_article_threads").delete().neq("id", NIL);
await db.from("marketing_articles").delete().neq("id", NIL);
}

describe("marketing_articles", () => {
afterEach(async () => {
await cleanAll();
});

describe("getPublishedArticles", () => {
it("returns published articles ordered by date", async () => {
await seedArticle({
slug: "older-post",
title: "Older Post",
published_at: "2026-01-01T00:00:00Z",
});
await seedArticle({
slug: "newer-post",
title: "Newer Post",
published_at: "2026-03-01T00:00:00Z",
});

const articles = await getPublishedArticles(db);

expect(articles).toHaveLength(2);
expect(articles[0].title).toBe("Newer Post");
expect(articles[1].title).toBe("Older Post");
});

it("excludes unpublished articles", async () => {
await seedArticle({ slug: "published", published: true });
await seedArticle({ slug: "draft", published: false });

const articles = await getPublishedArticles(db);

expect(articles).toHaveLength(1);
expect(articles[0].slug).toBe("published");
});

it("does not include content field", async () => {
await seedArticle();

const articles = await getPublishedArticles(db);

expect(articles).toHaveLength(1);
expect(articles[0]).not.toHaveProperty("content");
});
});

describe("getArticleBySlug", () => {
it("returns a published article with content", async () => {
const { slug } = await seedArticle({ content: "# Full content here" });

const article = await getArticleBySlug(db, slug);

expect(article).not.toBeNull();
expect(article!.slug).toBe(slug);
expect(article!.content).toBe("# Full content here");
});

it("returns null for unpublished articles", async () => {
const { slug } = await seedArticle({ published: false });

const article = await getArticleBySlug(db, slug);

expect(article).toBeNull();
});

it("returns null for non-existent slugs", async () => {
const article = await getArticleBySlug(db, "does-not-exist");

expect(article).toBeNull();
});

it("includes SEO fields", async () => {
const { slug } = await seedArticle({
og_title: "Custom OG Title",
og_description: "Custom OG Description",
og_image_url: "https://example.com/image.jpg",
canonical_url: "https://example.com/canonical",
});

const article = await getArticleBySlug(db, slug);

expect(article!.og_title).toBe("Custom OG Title");
expect(article!.og_description).toBe("Custom OG Description");
expect(article!.og_image_url).toBe("https://example.com/image.jpg");
expect(article!.canonical_url).toBe("https://example.com/canonical");
});
});

describe("getAllPublishedSlugs", () => {
it("returns slugs and published_at for published articles", async () => {
await seedArticle({ slug: "slug-one", published_at: "2026-02-01T00:00:00Z" });
await seedArticle({ slug: "slug-two", published_at: "2026-03-01T00:00:00Z" });
await seedArticle({ slug: "draft-slug", published: false });

const slugs = await getAllPublishedSlugs(db);

expect(slugs).toHaveLength(2);
expect(slugs[0].slug).toBe("slug-two");
expect(slugs[1].slug).toBe("slug-one");
expect(slugs[0].published_at).toBeTruthy();
});
});

describe("insertArticle", () => {
it("creates an article and returns it with all fields", async () => {
const article = await insertArticle(db, {
slug: "new-article",
title: "New Article",
description: "A new article",
content: "# Content",
tags: ["social", "tips"],
og_title: "OG Title",
og_description: "OG Desc",
});

expect(article.id).toBeTruthy();
expect(article.slug).toBe("new-article");
expect(article.title).toBe("New Article");
expect(article.tags).toEqual(["social", "tips"]);
expect(article.published).toBe(false);
expect(article.author).toBe("Post Imp Team");
});

it("throws on duplicate slug", async () => {
await seedArticle({ slug: "duplicate" });

await expect(
insertArticle(db, {
slug: "duplicate",
title: "Another",
description: "Desc",
content: "Content",
}),
).rejects.toThrow();
});
});

describe("updateArticle", () => {
it("updates specified fields", async () => {
const { id, slug } = await seedArticle({ title: "Original" });

await updateArticle(db, id, { title: "Updated Title", description: "Updated desc" });

const article = await getArticleBySlug(db, slug);
expect(article!.title).toBe("Updated Title");
expect(article!.description).toBe("Updated desc");
});

it("can publish an article", async () => {
const { id, slug } = await seedArticle({ published: false });

expect(await getArticleBySlug(db, slug)).toBeNull();

await updateArticle(db, id, {
published: true,
published_at: new Date().toISOString(),
});

const article = await getArticleBySlug(db, slug);
expect(article).not.toBeNull();
expect(article!.published).toBe(true);
});
});
});

describe("marketing_article_threads", () => {
afterEach(async () => {
await cleanAll();
});

it("insertArticleThread creates a thread and getThreadBySlack retrieves it", async () => {
const article = await insertArticle(db, {
slug: "thread-test",
title: "Thread Test",
description: "Desc",
content: "Content",
});

const thread = await insertArticleThread(db, {
article_id: article.id,
slack_channel_id: "C123",
slack_thread_ts: "1234567890.123456",
openai_response_id: "resp_abc",
created_by_slack_user: "U456",
});

expect(thread.id).toBeTruthy();
expect(thread.article_id).toBe(article.id);
expect(thread.slack_channel_id).toBe("C123");

const found = await getThreadBySlack(db, "C123", "1234567890.123456");
expect(found).not.toBeNull();
expect(found!.id).toBe(thread.id);
expect(found!.article.title).toBe("Thread Test");
});

it("getThreadBySlack returns null for unknown thread", async () => {
const found = await getThreadBySlack(db, "C999", "0000000000.000000");
expect(found).toBeNull();
});

it("updateThreadResponseId updates the openai_response_id", async () => {
const article = await insertArticle(db, {
slug: "update-resp-test",
title: "Update Resp",
description: "Desc",
content: "Content",
});

const thread = await insertArticleThread(db, {
article_id: article.id,
slack_channel_id: "C789",
slack_thread_ts: "9999999999.999999",
openai_response_id: "resp_old",
});

await updateThreadResponseId(db, thread.id, "resp_new");

const found = await getThreadBySlack(db, "C789", "9999999999.999999");
expect(found!.openai_response_id).toBe("resp_new");
});

it("enforces unique constraint on channel + thread_ts", async () => {
const article = await insertArticle(db, {
slug: "unique-test",
title: "Unique",
description: "Desc",
content: "Content",
});

await insertArticleThread(db, {
article_id: article.id,
slack_channel_id: "C111",
slack_thread_ts: "1111111111.111111",
});

await expect(
insertArticleThread(db, {
article_id: article.id,
slack_channel_id: "C111",
slack_thread_ts: "1111111111.111111",
}),
).rejects.toThrow();
});
});
Loading
Loading