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