logicspike/docs

Blog Engine

Blog System — Testing Guide

Last Updated: 2026-05-06 Status: Active

Test inventory, how to run tests, patterns, and coverage gaps.


1. Test Counts at a Glance

Package Tests Runner Last known pass
apps/blog-service 94 / 94 Vitest
packages/blog-sdk 394 / 394 Vitest
apps/seller-dashboard (blog module) No unit tests

2. Running Tests

# Run all tests across the monorepo
turbo run test
 
# Run just blog-service tests
cd apps/blog-service && npm run test
 
# Run just SDK tests
cd packages/blog-sdk && pnpm test
 
# Watch mode (blog-service)
cd apps/blog-service && npx vitest

3. blog-service Tests

Location: apps/blog-service/test/ and apps/blog-service/src/ (co-located schemas)

Database: Tests use PGlite — an in-process Postgres that runs inside Node.js. Drizzle migrations are applied to a fresh PGlite instance before each test suite. No external database needed.

Test Files

File What it tests
test/smoke.test.ts Boot test — PGlite + migrations + basic insert/read. First test to run; if this fails, something is broken at the DB layer.
test/tenant-isolation.test.ts Multi-tenancy security — Verifies that tenant A cannot read, modify, or delete tenant B's posts, categories, or tags. Tests the core IDOR (Insecure Direct Object Reference) attack surface.
test/slug-collision.test.ts Slug uniqueness — duplicate title in same tenant gets -1, -2, etc. suffix. Cross-tenant duplicates are allowed.
test/publish-flow.test.ts Status transitions — draft → published → unpublished → scheduled. Verifies published_at is set/cleared correctly.
test/tiptap-renderer.test.ts HTML renderer — Tests every supported TipTap node type renders to the expected HTML. Covers security: javascript: URLs blocked, data: URLs blocked, HTML-escaping of special chars.
src/schemas/post.schema.test.ts Zod schema validation — valid and invalid inputs for createPostSchema, updatePostSchema, listPostsSchema.
src/services/post.utils.test.ts Utility functions — slug generation from various titles, slug uniqueness logic, ID format.

Test Setup

// test/setup.ts (pattern)
import { PGlite } from "@electric-sql/pglite"
import { drizzle } from "drizzle-orm/pglite"
import { migrate } from "drizzle-orm/pglite/migrator"
 
export async function createTestDb() {
    const pglite = new PGlite()
    const db = drizzle(pglite, { schema })
    await migrate(db, { migrationsFolder: "./drizzle" })
    return db
}

Tests use makeTestApp(db, context) — a factory that creates the full Hono app wired to a test DB and a fake request context (tenant_id, user_id, permissions). This means route handler tests go through the actual middleware pipeline.

Key Test Patterns

Testing tenant isolation (most important pattern):

// Seed data for two tenants
await seedPost(db, { tenantId: TENANT_A, title: "A-post-1" })
await seedPost(db, { tenantId: TENANT_B, title: "B-post-1" })
 
// App running as TENANT_A
const app = makeTestApp(db, { tenantId: TENANT_A })
 
// TENANT_A can read their own post
const res = await app.request("/admin/posts/a-post-1-id")
expect(res.status).toBe(200)
 
// TENANT_A cannot read TENANT_B's post — returns 404, not 403
// (404 is correct: leaking "exists but forbidden" is an information disclosure)
const res2 = await app.request("/admin/posts/b-post-1-id")
expect(res2.status).toBe(404)

Testing the renderer (typical pattern):

// test/tiptap-renderer.test.ts
it("renders a table with header and data rows", () => {
    const result = renderTipTap({
        type: "doc",
        content: [{
            type: "table",
            content: [/* ... */]
        }]
    })
    expect(result).toContain("<table>")
    expect(result).toContain("<thead>")
    expect(result).toContain("<tbody>")
})
 
it("blocks javascript: URLs in links", () => {
    const result = renderTipTap({
        type: "doc",
        content: [{ type: "paragraph", content: [{
            type: "text",
            text: "click me",
            marks: [{ type: "link", attrs: { href: "javascript:alert(1)" }}]
        }]}]
    })
    expect(result).toContain('href="#"')  // blocked, replaced with #
    expect(result).not.toContain("javascript:")
})

4. @vlozi/blog SDK Tests

Location: packages/blog-sdk/test/

Approach: Pure unit tests — no real HTTP calls, no server needed. Uses vi.fn() to mock fetch.

Test Files

File What it tests
client.test.ts VloziClient — list, get, categories, tags; query parameter construction; error handling; all VloziError subclasses
sanitize.test.ts sanitizeHtml() — 5-pass sanitizer; XSS vectors (script tags, onerror, javascript: hrefs, data: URLs); allowed vs disallowed tags
hydrate.test.ts Client-side hydration scanner — Mermaid, YouTube, Carousel node detection and mount
cache.test.ts InMemoryCache — TTL expiry, SWR (stale-while-revalidate) behavior, max entry limit
cache-integration.test.ts Cache + client integration — hooks serve from cache on second call, revalidate in background
feeds.test.ts RSS/Atom feed generation — correct XML structure, escaped entities, required fields
next.test.ts generateStaticParamsForPosts, generateMetadataForPost — correct output shapes for Next.js
api-surface.test.ts Smoke test of all public exports — nothing is accidentally removed from index.ts
build-artifacts.test.ts Verifies the compiled package has all four entry points and CSS file
resilience.test.ts Network failures, 429 rate limiting, malformed JSON responses — error propagation

Key Test Patterns

Mocking fetch:

import { vi, expect } from "vitest"
import { VloziClient } from "../src/client"
 
const mockFetch = vi.fn()
global.fetch = mockFetch
 
beforeEach(() => {
    mockFetch.mockReset()
})
 
it("returns paginated posts", async () => {
    mockFetch.mockResolvedValueOnce({
        ok: true,
        json: async () => ({ data: [{ title: "Post 1", slug: "post-1" }], meta: { total: 1, page: 1, limit: 10, totalPages: 1 } }),
    })
    const client = new VloziClient({ apiKey: "test-key", baseUrl: "https://example.com" })
    const result = await client.blog.list()
    expect(result.data).toHaveLength(1)
    expect(mockFetch).toHaveBeenCalledWith(
        "https://example.com/blog/public/posts?page=1&limit=10",
        expect.objectContaining({ headers: expect.any(Headers) })
    )
})

Testing sanitization:

it("strips script tags", () => {
    const dirty = '<p>hello</p><script>alert(1)</script>'
    expect(sanitizeHtml(dirty)).not.toContain("<script>")
    expect(sanitizeHtml(dirty)).toContain("hello")
})
 
it("blocks javascript: hrefs", () => {
    const dirty = '<a href="javascript:void(0)">click</a>'
    const clean = sanitizeHtml(dirty)
    expect(clean).not.toContain("javascript:")
})

5. Coverage Gaps

Area Current coverage Risk
blog-service route handlers (integration) ✅ Covered via makeTestApp Low
blog-service tenant isolation ✅ Covered Low
TipTap renderer — all node types ✅ Covered Low
Zod validation schemas ✅ Covered Low
SDK client — all methods ✅ Covered Low
SDK sanitizer — XSS vectors ✅ Covered Low
SDK cache behavior ✅ Covered Low
Dashboard editor (React) ❌ No unit/integration tests Medium — untested autosave, dirty state, slash commands
Gateway blog proxy ❌ No tests Medium — integration tested manually
Markdown import pipeline ❌ No unit tests Medium — parse-import.ts, markdown-to-tiptap.ts are complex
Carousel renderer N/A — not implemented Known bug
Scheduled publishing cron N/A — not implemented

6. Adding Tests

blog-service: New Route Test

  1. Add a test file in apps/blog-service/test/
  2. Import createTestDb and makeTestApp from ./setup
  3. Use seedPost(), seedCategory(), seedTag() from ./fixtures to set up data
  4. Make requests via app.request(path, options) — this is the full Hono app, no HTTP server needed
  5. Assert on res.status and await res.json()

SDK: New Unit Test

  1. Add a test file in packages/blog-sdk/test/
  2. Mock fetch at the module level with vi.fn()
  3. Import the function/class under test from ../src/
  4. Reset mocks in beforeEach

Running a Single Test File

# blog-service
cd apps/blog-service && npx vitest run test/slug-collision.test.ts
 
# SDK
cd packages/blog-sdk && npx vitest run test/sanitize.test.ts
Blog Engine