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
118 changes: 118 additions & 0 deletions apps/web/src/lib/server/__tests__/markdown-tiptap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, test, expect } from 'vitest'
import {
markdownToTiptapJson,
tiptapJsonToMarkdown,
contentJsonToMarkdown,
commentMarkdownToTiptapJson,
} from '../markdown-tiptap'

Expand Down Expand Up @@ -177,6 +178,123 @@ describe('tiptapJsonToMarkdown', () => {
})
})

describe('contentJsonToMarkdown', () => {
const imageDoc = {
type: 'doc' as const,
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Shipped a thing.' }] },
{
type: 'image',
attrs: { src: 'https://cdn.example.com/shot.png', alt: 'Screenshot', title: null },
},
],
}

test('serializes image nodes the stored markdown dropped', () => {
// The reported bug: the API returned text-only markdown because the stored
// `content` column lost images. Deriving from contentJson restores them.
const result = contentJsonToMarkdown(imageDoc, 'Shipped a thing.')
expect(result).toContain('Shipped a thing.')
expect(result).toContain('![Screenshot](https://cdn.example.com/shot.png)')
})

test('serializes resizableImage nodes (the type the editor actually stores)', () => {
// UI uploads are stored as `resizableImage`, which @tiptap/markdown's Image
// extension does not know — they must be normalized to `image` first.
const resizableDoc = {
type: 'doc' as const,
content: [
{ type: 'paragraph', content: [{ type: 'text', text: 'Look:' }] },
{
type: 'resizableImage',
attrs: { src: 'https://cdn.example.com/r.png', alt: 'Resized', title: null, width: 400 },
},
],
}
const result = contentJsonToMarkdown(resizableDoc, 'Look:')
expect(result).toContain('![Resized](https://cdn.example.com/r.png)')
})

test('keeps mentions (as @label) when restoring an image', () => {
// The server manager has no mention extension, so re-serializing must not
// drop it; normalize it to the @label text instead.
const doc = {
type: 'doc' as const,
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'cc ' },
{ type: 'mention', attrs: { id: 'p1', label: 'Alice' } },
],
},
{ type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } },
],
}
const result = contentJsonToMarkdown(doc, 'cc @Alice')
expect(result).toContain('@Alice')
expect(result).toContain('![S](https://cdn.example.com/s.png)')
})

test('keeps stored markdown when an image coexists with an unsupported node', () => {
// A youtube embed has no server renderer; re-serializing would drop it, so
// the whole document keeps its stored markdown (image not re-derived) rather
// than losing the embed.
const doc = {
type: 'doc' as const,
content: [
{ type: 'image', attrs: { src: 'https://cdn.example.com/s.png', alt: 'S', title: null } },
{ type: 'youtube', attrs: { src: 'https://youtu.be/abc' } },
],
}
const stored = 'stored markdown with :::youtube::: and no image'
expect(contentJsonToMarkdown(doc, stored)).toBe(stored)
})

test('returns the stored markdown verbatim for image-free content', () => {
// No images means the stored column is already faithful; don't re-serialize
// (and risk reformatting) what was correct.
const noImageDoc = {
type: 'doc' as const,
content: [{ type: 'paragraph', content: [{ type: 'text', text: 'Just text' }] }],
}
expect(contentJsonToMarkdown(noImageDoc, '_Just_ text')).toBe('_Just_ text')
})

test.each([null, undefined])(
'falls back to stored markdown when contentJson is %s (legacy rows)',
(value) => {
expect(contentJsonToMarkdown(value, '# Legacy\n\nPlain markdown')).toBe(
'# Legacy\n\nPlain markdown'
)
}
)

test('falls back when contentJson has no real content', () => {
expect(contentJsonToMarkdown({ type: 'doc', content: [] }, 'fallback text')).toBe(
'fallback text'
)
})

test('falls back instead of throwing on malformed contentJson', () => {
// A corrupt/unexpected shape must never 500 a read endpoint.
const malformed = { not: 'a real doc' } as unknown as Parameters<
typeof contentJsonToMarkdown
>[0]
expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback')
})

test.each([
['content is a string', { type: 'doc', content: 'oops' }],
['content is an object', { type: 'doc', content: { bad: 1 } }],
])('falls back when %s (image scan must not throw)', (_label, doc) => {
// The image scan runs before the serialize try/catch, so a row whose
// `content` is present but not an array must not throw a read into a 500.
const malformed = doc as unknown as Parameters<typeof contentJsonToMarkdown>[0]
expect(contentJsonToMarkdown(malformed, 'safe fallback')).toBe('safe fallback')
})
})

describe('commentMarkdownToTiptapJson', () => {
test('plain text becomes a paragraph', () => {
const result = commentMarkdownToTiptapJson('Hello world')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,28 @@ describe('createChangelog wiring', () => {

expect(dispatchChangelogPublished).not.toHaveBeenCalled()
})

it('stores the markdown projection of contentJson so images reach the content column', async () => {
// Write-time regen: the stored `content` column must carry the image even
// when the caller-supplied markdown would have, so downstream consumers
// that read the column directly (webhooks, notifications) get it too.
const { createChangelog } = await import('../changelog.service')

await createChangelog(
{
title: 'X',
content: '![Shot](https://cdn.example.com/shot.png)',
publishState: { type: 'draft' },
},
AUTHOR
)

expect(mockInsertValues).toHaveBeenCalledWith(
expect.objectContaining({
content: expect.stringContaining('![Shot](https://cdn.example.com/shot.png)'),
})
)
})
})

describe('updateChangelog wiring', () => {
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/lib/server/domains/changelog/changelog.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import {
} from '@/lib/server/db'
import type { ChangelogId, PrincipalId, PostId } from '@quackback/ids'
import { NotFoundError, ValidationError } from '@/lib/shared/errors'
import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap'
import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap'
import { rehostExternalImages } from '@/lib/server/content/rehost-images'
import {
buildEventActor,
Expand Down Expand Up @@ -105,7 +105,9 @@ export async function createChangelog(
.insert(changelogEntries)
.values({
title,
content,
// Store the markdown projection of the canonical contentJson so every
// consumer of the `content` column (webhooks, notifications) sees images.
content: contentJsonToMarkdown(contentJson, content),
contentJson,
principalId: author.principalId,
publishedAt,
Expand Down Expand Up @@ -180,13 +182,21 @@ export async function updateChangelog(
}

if (input.title !== undefined) updateData.title = input.title.trim()
if (input.content !== undefined) updateData.content = input.content.trim()
if (input.contentJson !== undefined || input.content !== undefined) {
const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim())
updateData.contentJson = await rehostExternalImages(parsed, {
const contentJson = await rehostExternalImages(parsed, {
contentType: 'changelog',
principalId: existing.principalId ?? undefined,
})
updateData.contentJson = contentJson
// Every content edit carries `input.content` (the API accepts only markdown;
// the editor emits markdown alongside contentJson), so the fallback reflects
// the new doc. `existing.content` is only a defensive default for a
// contentJson-only edit, which no caller makes.
updateData.content = contentJsonToMarkdown(
contentJson,
(input.content ?? existing.content).trim()
)
Comment thread
mortondev marked this conversation as resolved.
}

if (input.displayDate !== undefined) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ vi.mock('@/lib/server/db', () => ({

vi.mock('@/lib/server/markdown-tiptap', () => ({
markdownToTiptapJson: vi.fn(() => ({ type: 'doc', content: [] })),
contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback,
}))

let getArticleById: typeof import('../help-center.article.service').getArticleById
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import type { HelpCenterArticleId, HelpCenterCategoryId, PrincipalId } from '@quackback/ids'
import { NotFoundError, ValidationError } from '@/lib/shared/errors'
import { isTeamMember } from '@/lib/shared/roles'
import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap'
import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap'
import { rehostExternalImages } from '@/lib/server/content/rehost-images'
import { slugify } from '@/lib/shared/utils'
import { uniqueHelpCenterSlug } from './help-center.slug'
Expand Down Expand Up @@ -182,7 +182,9 @@ export async function createArticle(
.values({
categoryId: input.categoryId as HelpCenterCategoryId,
title,
content,
// Store the markdown projection of the canonical contentJson so the
// article list endpoint (which omits contentJson) still serves images.
content: contentJsonToMarkdown(contentJson, content),
contentJson,
slug,
principalId: effectivePrincipalId,
Expand All @@ -209,13 +211,19 @@ export async function updateArticle(
const updateData: Partial<typeof helpCenterArticles.$inferInsert> = { updatedAt: new Date() }
if (input.title !== undefined) updateData.title = input.title.trim()
if (input.content !== undefined || input.contentJson !== undefined) {
if (input.content !== undefined) {
updateData.content = input.content.trim()
}
const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim())
updateData.contentJson = await rehostExternalImages(parsed, {
const contentJson = await rehostExternalImages(parsed, {
contentType: 'help-center',
})
updateData.contentJson = contentJson
if (input.content !== undefined) {
updateData.content = contentJsonToMarkdown(contentJson, input.content.trim())
} else {
// contentJson-only edit (no markdown source): refresh the stored column
// only when the tree carries images, else leave it as-is.
const regenerated = contentJsonToMarkdown(contentJson, '')
if (regenerated) updateData.content = regenerated
}
}
if (input.categoryId !== undefined)
updateData.categoryId = input.categoryId as HelpCenterCategoryId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({

vi.mock('@/lib/server/markdown-tiptap', () => ({
markdownToTiptapJson: vi.fn(() => ({})),
contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback,
}))

vi.mock('@/lib/server/content/rehost-images', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({

vi.mock('@/lib/server/markdown-tiptap', () => ({
markdownToTiptapJson: vi.fn(() => ({})),
contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback,
}))

vi.mock('@/lib/server/content/rehost-images', () => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ vi.mock('@/lib/server/domains/activity/activity.service', () => ({

vi.mock('@/lib/server/markdown-tiptap', () => ({
markdownToTiptapJson: vi.fn(() => ({ type: 'doc', content: [] })),
contentJsonToMarkdown: (_json: unknown, fallback: string) => fallback,
}))

vi.mock('@/lib/server/content/rehost-images', () => ({
Expand Down
18 changes: 14 additions & 4 deletions apps/web/src/lib/server/domains/posts/post.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
import { announcePublishedPost } from './post.announce'
import { NotFoundError, ValidationError } from '@/lib/shared/errors'
import { recordAuditEvent } from '@/lib/server/audit/log'
import { markdownToTiptapJson } from '@/lib/server/markdown-tiptap'
import { markdownToTiptapJson, contentJsonToMarkdown } from '@/lib/server/markdown-tiptap'
import { rehostExternalImages } from '@/lib/server/content/rehost-images'
import { subscribeToPost } from '@/lib/server/domains/subscriptions/subscription.service'
import type { CreatePostInput, UpdatePostInput, CreatePostResult } from './post.types'
Expand Down Expand Up @@ -216,7 +216,9 @@ export async function createPost(
.values({
boardId: input.boardId,
title,
content,
// Store the markdown projection of the canonical contentJson so every
// consumer of the `content` column (webhooks, notifications) sees images.
content: contentJsonToMarkdown(contentJson, content),
contentJson,
statusId,
principalId: author.principalId,
Expand Down Expand Up @@ -375,13 +377,21 @@ export async function updatePost(
// Build update data
const updateData: Partial<Post> = {}
if (input.title !== undefined) updateData.title = input.title.trim()
if (input.content !== undefined) updateData.content = input.content.trim()
if (input.contentJson !== undefined || input.content !== undefined) {
const parsed = input.contentJson ?? markdownToTiptapJson((input.content ?? '').trim())
updateData.contentJson = await rehostExternalImages(parsed, {
const contentJson = await rehostExternalImages(parsed, {
contentType: 'post',
principalId: existingPost.principalId,
})
updateData.contentJson = contentJson
// Every content edit carries `input.content` (the API accepts only markdown;
// the editor emits markdown alongside contentJson), so the fallback reflects
// the new doc. `existingPost.content` is only a defensive default for a
// contentJson-only edit, which no caller makes.
updateData.content = contentJsonToMarkdown(
contentJson,
(input.content ?? existingPost.content).trim()
)
}
if (input.statusId !== undefined) updateData.statusId = input.statusId
if (input.ownerPrincipalId !== undefined) updateData.ownerPrincipalId = input.ownerPrincipalId
Expand Down
Loading