Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Next Next commit
feat: add WordPress media upload node and schemas
  • Loading branch information
DheerajShrivastav committed Mar 2, 2026
commit 956f135b9d6526eeb8e2d893c01ddfe6d3412403
9 changes: 9 additions & 0 deletions packages/nodes/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,11 @@ export {
wordpressGetPostsNode,
WordPressGetPostsInputSchema,
WordPressGetPostsOutputSchema,
wordpressUploadMediaNode,
WordPressUploadMediaInputSchema,
WordPressUploadMediaOutputSchema,
WordPressPostSchema,
WordPressMediaSchema,
wordpressCredential,
} from './integrations/index.js';

Expand Down Expand Up @@ -172,6 +176,9 @@ export type {
WordPressUpdatePostOutput,
WordPressGetPostsInput,
WordPressGetPostsOutput,
WordPressMedia,
WordPressUploadMediaInput,
WordPressUploadMediaOutput,
} from './integrations/index.js';

// AI nodes
Expand Down Expand Up @@ -225,6 +232,7 @@ import {
wordpressCreatePostNode,
wordpressUpdatePostNode,
wordpressGetPostsNode,
wordpressUploadMediaNode,
} from './integrations/index.js';
import {
socialKeywordGeneratorNode,
Expand Down Expand Up @@ -264,6 +272,7 @@ export const builtInNodes = [
wordpressCreatePostNode,
wordpressUpdatePostNode,
wordpressGetPostsNode,
wordpressUploadMediaNode,
// AI
socialKeywordGeneratorNode,
draftEmailsNode,
Expand Down
7 changes: 7 additions & 0 deletions packages/nodes/src/integrations/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ export {
wordpressCreatePostNode,
wordpressUpdatePostNode,
wordpressGetPostsNode,
wordpressUploadMediaNode,
wordpressCredential,
WordPressPostSchema,
WordPressCreatePostInputSchema,
Expand All @@ -108,6 +109,9 @@ export {
WordPressUpdatePostOutputSchema,
WordPressGetPostsInputSchema,
WordPressGetPostsOutputSchema,
WordPressMediaSchema,
WordPressUploadMediaInputSchema,
WordPressUploadMediaOutputSchema,
normalizeWordPressPost,
type WordPressPost,
type WordPressCreatePostInput,
Expand All @@ -116,4 +120,7 @@ export {
type WordPressUpdatePostOutput,
type WordPressGetPostsInput,
type WordPressGetPostsOutput,
type WordPressMedia,
type WordPressUploadMediaInput,
type WordPressUploadMediaOutput,
} from './wordpress/index.js';
203 changes: 199 additions & 4 deletions packages/nodes/src/integrations/wordpress/__tests__/wordpress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ import {
wordpressCreatePostNode,
wordpressUpdatePostNode,
wordpressGetPostsNode,
wordpressUploadMediaNode,
WordPressCreatePostInputSchema,
WordPressUpdatePostInputSchema,
WordPressGetPostsInputSchema,
WordPressUploadMediaInputSchema,
WordPressPostSchema,
WordPressMediaSchema,
normalizeWordPressPost,
} from '../index.js'

Expand Down Expand Up @@ -54,7 +57,9 @@ const mockContextSubpath = {
},
}

const expectedBase64 = Buffer.from('admin:abcd 1234 efgh 5678').toString('base64')
const expectedBase64 = Buffer.from('admin:abcd 1234 efgh 5678').toString(
'base64',
)
const expectedAuthHeader = `Basic ${expectedBase64}`

afterEach(() => {
Expand Down Expand Up @@ -107,7 +112,9 @@ describe('wordpress schemas', () => {
})

it('WordPressCreatePostInputSchema rejects missing content', () => {
const result = WordPressCreatePostInputSchema.safeParse({ title: 'My Post' })
const result = WordPressCreatePostInputSchema.safeParse({
title: 'My Post',
})
expect(result.success).toBe(false)
})

Expand All @@ -134,7 +141,9 @@ describe('wordpress schemas', () => {
})

it('WordPressUpdatePostInputSchema requires postId', () => {
const result = WordPressUpdatePostInputSchema.safeParse({ title: 'Updated' })
const result = WordPressUpdatePostInputSchema.safeParse({
title: 'Updated',
})
expect(result.success).toBe(false)
})

Expand Down Expand Up @@ -185,7 +194,11 @@ describe('normalizeWordPressPost', () => {
})

it('omits content and excerpt when absent', () => {
const rawWithout = { ...mockRawPost, content: undefined, excerpt: undefined }
const rawWithout = {
...mockRawPost,
content: undefined,
excerpt: undefined,
}
const result = normalizeWordPressPost(rawWithout)
expect(result.content).toBeUndefined()
expect(result.excerpt).toBeUndefined()
Expand Down Expand Up @@ -575,3 +588,185 @@ describe('wordpressGetPostsNode', () => {
expect(result.error).toMatch('500')
})
})

// ─── wordpressUploadMedia ─────────────────────────────────────────────────────

const mockRawMedia = {
id: 99,
title: { rendered: 'test-image' },
slug: 'test-image',
status: 'inherit',
link: 'https://example.com/?attachment_id=99',
source_url: 'https://example.com/wp-content/uploads/test-image.jpg',
media_type: 'image',
mime_type: 'image/jpeg',
}

describe('WordPressUploadMediaInputSchema', () => {
it('accepts valid input', () => {
const result = WordPressUploadMediaInputSchema.safeParse({
filename: 'photo.jpg',
mimeType: 'image/jpeg',
contentBase64: Buffer.from('fake-image-data').toString('base64'),
})
expect(result.success).toBe(true)
})

it('rejects missing filename', () => {
const result = WordPressUploadMediaInputSchema.safeParse({
mimeType: 'image/jpeg',
contentBase64: 'abc123',
})
expect(result.success).toBe(false)
})

it('rejects empty contentBase64', () => {
const result = WordPressUploadMediaInputSchema.safeParse({
filename: 'photo.jpg',
mimeType: 'image/jpeg',
contentBase64: '',
})
expect(result.success).toBe(false)
})
})

describe('wordpressUploadMediaNode', () => {
it('has correct type and category', () => {
expect(wordpressUploadMediaNode.type).toBe('wordpress_upload_media')
expect(wordpressUploadMediaNode.category).toBe('integration')
})

it('returns failure when credentials are missing', async () => {
const ctx = { ...mockContext, credentials: {} }
const result = await wordpressUploadMediaNode.executor(
{
filename: 'photo.jpg',
mimeType: 'image/jpeg',
contentBase64: 'abc123',
},
ctx,
)
expect(result.success).toBe(false)
expect(result.error).toMatch(/siteUrl/)
expect(result.error).toMatch(/applicationPassword/)
})

it('sends POST to correct media endpoint with Authorization header', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockRawMedia),
})
vi.stubGlobal('fetch', mockFetch)

await wordpressUploadMediaNode.executor(
{
filename: 'test-image.jpg',
mimeType: 'image/jpeg',
contentBase64: Buffer.from('fake').toString('base64'),
},
mockContext,
)

const [url, options] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(url).toBe('https://example.com/wp-json/wp/v2/media')
expect(options.method).toBe('POST')
expect((options.headers as Record<string, string>)['Authorization']).toBe(
expectedAuthHeader,
)
})

it('sets Content-Disposition header with filename', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockRawMedia),
})
vi.stubGlobal('fetch', mockFetch)

await wordpressUploadMediaNode.executor(
{
filename: 'my-photo.jpg',
mimeType: 'image/jpeg',
contentBase64: Buffer.from('fake').toString('base64'),
},
mockContext,
)

const [, options] = mockFetch.mock.calls[0] as [string, RequestInit]
expect(
(options.headers as Record<string, string>)['Content-Disposition'],
).toContain('my-photo.jpg')
})

it('returns normalized media output matching WordPressMediaSchema', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockRawMedia),
})
vi.stubGlobal('fetch', mockFetch)

const result = await wordpressUploadMediaNode.executor(
{
filename: 'test-image.jpg',
mimeType: 'image/jpeg',
contentBase64: Buffer.from('fake').toString('base64'),
},
mockContext,
)

expect(result.success).toBe(true)
if (result.success && result.output) {
const parsed = WordPressMediaSchema.safeParse(result.output)
expect(parsed.success).toBe(true)
const out = result.output as {
id: number
sourceUrl: string
mediaType: string
}
expect(out.id).toBe(99)
expect(out.sourceUrl).toBe(
'https://example.com/wp-content/uploads/test-image.jpg',
)
expect(out.mediaType).toBe('image')
}
})

it('strips trailing slash from siteUrl', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockRawMedia),
})
vi.stubGlobal('fetch', mockFetch)

await wordpressUploadMediaNode.executor(
{
filename: 'photo.jpg',
mimeType: 'image/jpeg',
contentBase64: Buffer.from('fake').toString('base64'),
},
mockContextSubpath,
)

const [url] = mockFetch.mock.calls[0] as [string]
expect(url).toBe('https://example.com/blog/wp-json/wp/v2/media')
})

it('returns failure on non-OK response', async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 413,
text: () => Promise.resolve('Payload Too Large'),
})
vi.stubGlobal('fetch', mockFetch)

const result = await wordpressUploadMediaNode.executor(
{
filename: 'huge.jpg',
mimeType: 'image/jpeg',
contentBase64: Buffer.from('fake').toString('base64'),
},
mockContext,
)
expect(result.success).toBe(false)
expect(result.error).toMatch('413')
})
})
6 changes: 4 additions & 2 deletions packages/nodes/src/integrations/wordpress/createPost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ export const wordpressCreatePostNode = defineNode({
content: input.content,
status: input.status ?? 'draft',
}
if (input.categories !== undefined) postBody['categories'] = input.categories
if (input.categories !== undefined)
postBody['categories'] = input.categories
if (input.tags !== undefined) postBody['tags'] = input.tags
if (input.featuredMediaId !== undefined) postBody['featured_media'] = input.featuredMediaId
if (input.featuredMediaId !== undefined)
postBody['featured_media'] = input.featuredMediaId
if (input.excerpt !== undefined) postBody['excerpt'] = input.excerpt
if (input.slug !== undefined) postBody['slug'] = input.slug

Expand Down
5 changes: 1 addition & 4 deletions packages/nodes/src/integrations/wordpress/getPosts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,10 +65,7 @@ export const wordpressGetPostsNode = defineNode({
}
}

const totalFound = parseInt(
response.headers.get('X-WP-Total') ?? '0',
10,
)
const totalFound = parseInt(response.headers.get('X-WP-Total') ?? '0', 10)
const data = (await response.json()) as WordPressApiPost[]
const posts = data.map(normalizeWordPressPost)

Expand Down
7 changes: 7 additions & 0 deletions packages/nodes/src/integrations/wordpress/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export { wordpressCreatePostNode } from './createPost.js'
export { wordpressUpdatePostNode } from './updatePost.js'
export { wordpressGetPostsNode } from './getPosts.js'
export { wordpressUploadMediaNode } from './uploadMedia.js'
export { wordpressCredential } from './credentials.js'
export {
WordPressPostSchema,
Expand All @@ -10,6 +11,9 @@ export {
WordPressUpdatePostOutputSchema,
WordPressGetPostsInputSchema,
WordPressGetPostsOutputSchema,
WordPressMediaSchema,
WordPressUploadMediaInputSchema,
WordPressUploadMediaOutputSchema,
normalizeWordPressPost,
} from './schemas.js'
export type {
Expand All @@ -20,4 +24,7 @@ export type {
WordPressUpdatePostOutput,
WordPressGetPostsInput,
WordPressGetPostsOutput,
WordPressMedia,
WordPressUploadMediaInput,
WordPressUploadMediaOutput,
} from './schemas.js'
Loading