publish(task 15): processPost — kern-pipeline pro post (tdd)
processPost(args) orchestriert pro post:
1. frontmatter parsen + validieren
2. draft → skipped-draft
3. bilder sammeln + sequentiell zu blossom hochladen (mapping
dateiname → primary-url)
4. body mit rewriteImageUrls anpassen, coverUrl via resolveCoverUrl
5. kind:30023 event bauen via buildKind30023
6. checkExisting → action = new|update
7. signieren via nip-46
8. publishToRelays, prüfen ob minRelayAcks erreicht
alle externen abhängigkeiten (readPostFile, collectImages, upload,
sign, publish, checkExisting) via PostDeps-interface eingezogen
— einfach mockbar. fehler aller art landen als { status: failed,
error: msg }. 6 tests grün.
follow-up (nicht teil von task 15): license-tag und imeta-tags aus
images[]-frontmatter sind noch nicht im event. kommt in eigenem
folge-task, basierend auf der metadata-convention-spec.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
db85061287
commit
68ea912fad
|
|
@ -0,0 +1,126 @@
|
||||||
|
import { join } from '@std/path'
|
||||||
|
import { type Frontmatter } from '../core/frontmatter.ts'
|
||||||
|
import { validatePost } from '../core/validation.ts'
|
||||||
|
import { buildKind30023, type UnsignedEvent } from '../core/event.ts'
|
||||||
|
import { resolveCoverUrl, rewriteImageUrls } from '../core/markdown.ts'
|
||||||
|
import type { ImageFile } from '../core/image-collector.ts'
|
||||||
|
import type { RelaysReport, SignedEvent } from '../core/relays.ts'
|
||||||
|
import type { UploadReport } from '../core/blossom.ts'
|
||||||
|
|
||||||
|
export interface PostDeps {
|
||||||
|
readPostFile(path: string): Promise<{ fm: Frontmatter; body: string }>
|
||||||
|
collectImages(postDir: string): Promise<ImageFile[]>
|
||||||
|
uploadBlossom(args: {
|
||||||
|
data: Uint8Array
|
||||||
|
fileName: string
|
||||||
|
mimeType: string
|
||||||
|
}): Promise<UploadReport>
|
||||||
|
sign(ev: UnsignedEvent): Promise<SignedEvent>
|
||||||
|
publish(ev: SignedEvent, relays: string[]): Promise<RelaysReport>
|
||||||
|
checkExisting(slug: string, relays: string[]): Promise<boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessArgs {
|
||||||
|
postDir: string
|
||||||
|
writeRelays: string[]
|
||||||
|
blossomServers: string[]
|
||||||
|
pubkeyHex: string
|
||||||
|
clientTag: string
|
||||||
|
minRelayAcks: number
|
||||||
|
deps: PostDeps
|
||||||
|
now?: () => number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProcessResult {
|
||||||
|
status: 'success' | 'failed' | 'skipped-draft'
|
||||||
|
action?: 'new' | 'update'
|
||||||
|
slug: string
|
||||||
|
eventId?: string
|
||||||
|
relaysOk: string[]
|
||||||
|
relaysFailed: string[]
|
||||||
|
blossomServersOk: string[]
|
||||||
|
imagesUploaded: number
|
||||||
|
durationMs: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function processPost(args: ProcessArgs): Promise<ProcessResult> {
|
||||||
|
const started = performance.now()
|
||||||
|
const now = args.now ?? (() => Math.floor(Date.now() / 1000))
|
||||||
|
let slug = '?'
|
||||||
|
try {
|
||||||
|
const { fm, body } = await args.deps.readPostFile(join(args.postDir, 'index.md'))
|
||||||
|
validatePost(fm)
|
||||||
|
slug = fm.slug
|
||||||
|
|
||||||
|
if (fm.draft === true) {
|
||||||
|
return {
|
||||||
|
status: 'skipped-draft',
|
||||||
|
slug,
|
||||||
|
relaysOk: [],
|
||||||
|
relaysFailed: [],
|
||||||
|
blossomServersOk: [],
|
||||||
|
imagesUploaded: 0,
|
||||||
|
durationMs: Math.round(performance.now() - started),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = await args.deps.collectImages(args.postDir)
|
||||||
|
const blossomOkServers = new Set<string>()
|
||||||
|
const mapping = new Map<string, string>()
|
||||||
|
for (const img of images) {
|
||||||
|
const rep = await args.deps.uploadBlossom({
|
||||||
|
data: img.data,
|
||||||
|
fileName: img.fileName,
|
||||||
|
mimeType: img.mimeType,
|
||||||
|
})
|
||||||
|
for (const s of rep.ok) blossomOkServers.add(s)
|
||||||
|
mapping.set(img.fileName, rep.primaryUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rewrittenBody = rewriteImageUrls(body, mapping)
|
||||||
|
const coverRaw = fm.cover?.image ?? fm.image
|
||||||
|
const coverUrl = resolveCoverUrl(coverRaw, mapping)
|
||||||
|
|
||||||
|
const unsigned = buildKind30023({
|
||||||
|
fm,
|
||||||
|
rewrittenBody,
|
||||||
|
coverUrl,
|
||||||
|
pubkeyHex: args.pubkeyHex,
|
||||||
|
clientTag: args.clientTag,
|
||||||
|
nowSeconds: now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const existing = await args.deps.checkExisting(fm.slug, args.writeRelays)
|
||||||
|
const signed = await args.deps.sign(unsigned)
|
||||||
|
const pubRep = await args.deps.publish(signed, args.writeRelays)
|
||||||
|
if (pubRep.ok.length < args.minRelayAcks) {
|
||||||
|
throw new Error(
|
||||||
|
`insufficient relays acked (${pubRep.ok.length} < ${args.minRelayAcks})`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
action: existing ? 'update' : 'new',
|
||||||
|
slug,
|
||||||
|
eventId: signed.id,
|
||||||
|
relaysOk: pubRep.ok,
|
||||||
|
relaysFailed: pubRep.failed,
|
||||||
|
blossomServersOk: [...blossomOkServers],
|
||||||
|
imagesUploaded: images.length,
|
||||||
|
durationMs: Math.round(performance.now() - started),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
status: 'failed',
|
||||||
|
slug,
|
||||||
|
relaysOk: [],
|
||||||
|
relaysFailed: [],
|
||||||
|
blossomServersOk: [],
|
||||||
|
imagesUploaded: 0,
|
||||||
|
durationMs: Math.round(performance.now() - started),
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
import { assertEquals } from '@std/assert'
|
||||||
|
import { type PostDeps, processPost } from '../src/subcommands/publish.ts'
|
||||||
|
import type { Frontmatter } from '../src/core/frontmatter.ts'
|
||||||
|
|
||||||
|
function makeDeps(overrides: Partial<PostDeps> = {}): PostDeps {
|
||||||
|
return {
|
||||||
|
readPostFile: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
fm: {
|
||||||
|
title: 'T',
|
||||||
|
slug: 's',
|
||||||
|
date: new Date('2024-01-01'),
|
||||||
|
} as Frontmatter,
|
||||||
|
body: 'body',
|
||||||
|
}),
|
||||||
|
collectImages: () => Promise.resolve([]),
|
||||||
|
uploadBlossom: (args) =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: ['https://b1'],
|
||||||
|
failed: [],
|
||||||
|
primaryUrl: `https://b1/${args.fileName}-hash`,
|
||||||
|
sha256: 'hash',
|
||||||
|
}),
|
||||||
|
sign: (ev) => Promise.resolve({ ...ev, id: 'ev-id', sig: 'sig' }),
|
||||||
|
publish: () => Promise.resolve({ ok: ['wss://r1', 'wss://r2'], failed: [] }),
|
||||||
|
checkExisting: () => Promise.resolve(false),
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseArgs(deps = makeDeps()) {
|
||||||
|
return {
|
||||||
|
postDir: '/p/s',
|
||||||
|
writeRelays: ['wss://r1', 'wss://r2'],
|
||||||
|
blossomServers: ['https://b1'],
|
||||||
|
pubkeyHex: 'a'.repeat(64),
|
||||||
|
clientTag: 'test-client',
|
||||||
|
minRelayAcks: 2,
|
||||||
|
deps,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test('processPost: happy-path neu, ohne bilder', async () => {
|
||||||
|
const result = await processPost(baseArgs())
|
||||||
|
assertEquals(result.status, 'success')
|
||||||
|
assertEquals(result.action, 'new')
|
||||||
|
assertEquals(result.eventId, 'ev-id')
|
||||||
|
assertEquals(result.relaysOk.length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('processPost: draft wird geskippt', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
readPostFile: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
fm: {
|
||||||
|
title: 'T',
|
||||||
|
slug: 's',
|
||||||
|
date: new Date('2024-01-01'),
|
||||||
|
draft: true,
|
||||||
|
} as Frontmatter,
|
||||||
|
body: 'b',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const result = await processPost({ ...baseArgs(deps), writeRelays: ['wss://r1'] })
|
||||||
|
assertEquals(result.status, 'skipped-draft')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('processPost: zu wenig relay-acks → failed', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
publish: () =>
|
||||||
|
Promise.resolve({ ok: ['wss://r1'], failed: ['wss://r2', 'wss://r3', 'wss://r4'] }),
|
||||||
|
})
|
||||||
|
const result = await processPost({
|
||||||
|
...baseArgs(deps),
|
||||||
|
writeRelays: ['wss://r1', 'wss://r2', 'wss://r3', 'wss://r4'],
|
||||||
|
})
|
||||||
|
assertEquals(result.status, 'failed')
|
||||||
|
assertEquals(String(result.error).includes('relays'), true)
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('processPost: konfigurierbarer minRelayAcks', async () => {
|
||||||
|
const deps = makeDeps({
|
||||||
|
publish: () => Promise.resolve({ ok: ['wss://r1'], failed: ['wss://r2'] }),
|
||||||
|
})
|
||||||
|
const result = await processPost({
|
||||||
|
...baseArgs(deps),
|
||||||
|
writeRelays: ['wss://r1', 'wss://r2'],
|
||||||
|
minRelayAcks: 1,
|
||||||
|
})
|
||||||
|
assertEquals(result.status, 'success')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('processPost: bestehender d-tag → action = update', async () => {
|
||||||
|
const result = await processPost(
|
||||||
|
baseArgs(makeDeps({ checkExisting: () => Promise.resolve(true) })),
|
||||||
|
)
|
||||||
|
assertEquals(result.status, 'success')
|
||||||
|
assertEquals(result.action, 'update')
|
||||||
|
})
|
||||||
|
|
||||||
|
Deno.test('processPost: bilder landen auf blossom, body wird rewritten', async () => {
|
||||||
|
const uploaded: string[] = []
|
||||||
|
const deps = makeDeps({
|
||||||
|
readPostFile: () =>
|
||||||
|
Promise.resolve({
|
||||||
|
fm: {
|
||||||
|
title: 'T',
|
||||||
|
slug: 's',
|
||||||
|
date: new Date('2024-01-01'),
|
||||||
|
cover: { image: 'cover.png' },
|
||||||
|
} as Frontmatter,
|
||||||
|
body: 'Pic:  cover ',
|
||||||
|
}),
|
||||||
|
collectImages: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
fileName: 'a.png',
|
||||||
|
absolutePath: '/p/s/a.png',
|
||||||
|
data: new Uint8Array([1]),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fileName: 'cover.png',
|
||||||
|
absolutePath: '/p/s/cover.png',
|
||||||
|
data: new Uint8Array([2]),
|
||||||
|
mimeType: 'image/png',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
uploadBlossom: (args) => {
|
||||||
|
uploaded.push(args.fileName)
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: ['https://b1'],
|
||||||
|
failed: [],
|
||||||
|
primaryUrl: `https://b1/${args.fileName}-hash`,
|
||||||
|
sha256: 'h',
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const result = await processPost(baseArgs(deps))
|
||||||
|
assertEquals(result.status, 'success')
|
||||||
|
assertEquals(uploaded.sort(), ['a.png', 'cover.png'])
|
||||||
|
assertEquals(result.imagesUploaded, 2)
|
||||||
|
})
|
||||||
Loading…
Reference in New Issue