From a1a9fa4c71912c47e02da819d730249acd621e9e Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Thu, 2 Jul 2026 23:41:12 +0300 Subject: [PATCH 1/6] add xquik social posting service --- .../XquikSocialPostingService.ts | 147 ++++++++++++++++++ src/io/channels/social-posting/index.ts | 12 +- .../XquikSocialPostingService.spec.ts | 116 ++++++++++++++ 3 files changed, 272 insertions(+), 3 deletions(-) create mode 100644 src/io/channels/social-posting/XquikSocialPostingService.ts create mode 100644 tests/social-posting/XquikSocialPostingService.spec.ts diff --git a/src/io/channels/social-posting/XquikSocialPostingService.ts b/src/io/channels/social-posting/XquikSocialPostingService.ts new file mode 100644 index 00000000000..33dad871eba --- /dev/null +++ b/src/io/channels/social-posting/XquikSocialPostingService.ts @@ -0,0 +1,147 @@ +import type { SocialPost, SocialPostPlatformResult } from "./SocialPostManager"; +import { + SocialAbstractService, + type SocialRequestOptions, + type SocialServiceConfig, +} from "./SocialAbstractService"; + +export interface XquikSocialPostingConfig extends SocialServiceConfig { + apiKey: string; + account: string; + baseUrl?: string; + platform?: string; +} + +export interface XquikPublishInput { + text: string; + account?: string; + attachmentUrl?: string; + communityId?: string; + isNoteTweet?: boolean; + mediaUrls?: string[]; + replyToTweetId?: string; +} + +interface XquikCreateTweetBody { + account: string; + text?: string; + attachment_url?: string; + community_id?: string; + is_note_tweet?: boolean; + media?: string[]; + reply_to_tweet_id?: string; +} + +interface XquikCreateTweetSuccess { + success: true; + tweetId: string; + writeActionId?: string; +} + +interface XquikCreateTweetPending { + error: "x_write_unconfirmed"; + status: "pending_confirmation"; + writeActionId: string; +} + +type XquikCreateTweetResponse = + | XquikCreateTweetSuccess + | XquikCreateTweetPending; + +export class XquikSocialPostingService extends SocialAbstractService { + private readonly account: string; + private readonly apiKey: string; + private readonly baseUrl: string; + private readonly platform: string; + + constructor(config: XquikSocialPostingConfig) { + super(config); + this.account = config.account; + this.apiKey = config.apiKey; + this.baseUrl = (config.baseUrl ?? "https://xquik.com").replace(/\/+$/, ""); + this.platform = config.platform ?? "twitter"; + } + + async publish( + input: XquikPublishInput, + options: SocialRequestOptions = {}, + ): Promise { + const response = await this.fetchJson( + `${this.baseUrl}/api/v1/x/tweets`, + { + body: JSON.stringify(this.createRequestBody(input)), + headers: { + "content-type": "application/json", + "x-api-key": this.apiKey, + }, + method: "POST", + }, + options, + ); + + if ("success" in response && response.success) { + return { + platform: this.platform, + postId: response.tweetId, + publishedAt: new Date().toISOString(), + status: "success", + url: `https://x.com/i/web/status/${response.tweetId}`, + }; + } + + return { + platform: this.platform, + status: "pending", + }; + } + + publishPost( + post: SocialPost, + options: SocialRequestOptions = {}, + ): Promise { + const input: XquikPublishInput = { + text: + post.adaptations[this.platform] ?? + post.adaptations.twitter ?? + post.baseContent, + }; + + if (post.mediaUrls) { + input.mediaUrls = [...post.mediaUrls]; + } + + return this.publish(input, options); + } + + private createRequestBody(input: XquikPublishInput): XquikCreateTweetBody { + const body: XquikCreateTweetBody = { + account: input.account ?? this.account, + }; + + if (input.text) { + body.text = input.text; + } + + if (input.mediaUrls?.length) { + body.media = [...input.mediaUrls]; + } + + if (input.replyToTweetId) { + body.reply_to_tweet_id = input.replyToTweetId; + } + + if (input.attachmentUrl) { + body.attachment_url = input.attachmentUrl; + } + + if (input.communityId) { + body.community_id = input.communityId; + } + + if (input.isNoteTweet !== undefined) { + body.is_note_tweet = input.isNoteTweet; + } + + return body; + } +} diff --git a/src/io/channels/social-posting/index.ts b/src/io/channels/social-posting/index.ts index c42e13c7da8..c44990bd85e 100644 --- a/src/io/channels/social-posting/index.ts +++ b/src/io/channels/social-posting/index.ts @@ -14,18 +14,24 @@ export { type SocialPostStatus, type SocialPostPlatformResult, type CreateDraftInput, -} from './SocialPostManager'; +} from "./SocialPostManager"; // Platform-specific content adaptation export { ContentAdaptationEngine, type PlatformConstraints, type AdaptedContent, -} from './ContentAdaptationEngine'; +} from "./ContentAdaptationEngine"; // Shared HTTP base class for channel service implementations export { SocialAbstractService, type SocialRequestOptions, type SocialServiceConfig, -} from './SocialAbstractService'; +} from "./SocialAbstractService"; + +export { + XquikSocialPostingService, + type XquikPublishInput, + type XquikSocialPostingConfig, +} from "./XquikSocialPostingService"; diff --git a/tests/social-posting/XquikSocialPostingService.spec.ts b/tests/social-posting/XquikSocialPostingService.spec.ts new file mode 100644 index 00000000000..b41a30ed075 --- /dev/null +++ b/tests/social-posting/XquikSocialPostingService.spec.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { XquikSocialPostingService } from "../../src/io/channels/social-posting/XquikSocialPostingService.js"; +import type { SocialPost } from "../../src/io/channels/social-posting/SocialPostManager.js"; + +describe("XquikSocialPostingService", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("publishes a tweet through the Xquik create tweet endpoint", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, tweetId: "12345" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + const result = await service.publish({ text: "hello" }); + + expect(result).toMatchObject({ + platform: "twitter", + postId: "12345", + status: "success", + url: "https://x.com/i/web/status/12345", + }); + + const call = fetchMock.mock.calls[0]; + expect(call?.[0]).toBe("https://xquik.com/api/v1/x/tweets"); + + const init = call?.[1] as RequestInit; + expect(init.method).toBe("POST"); + expect(new Headers(init.headers).get("x-api-key")).toBe("test-key"); + expect(init.body).toBe( + JSON.stringify({ account: "@agent", text: "hello" }), + ); + }); + + it("maps pending confirmation responses to a pending platform result", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response( + JSON.stringify({ + error: "x_write_unconfirmed", + status: "pending_confirmation", + writeActionId: "42", + }), + { + headers: { "content-type": "application/json" }, + status: 202, + }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + platform: "x", + }); + const result = await service.publish({ + mediaUrls: ["https://example.com/image.png"], + text: "", + }); + + expect(result).toEqual({ platform: "x", status: "pending" }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBe( + JSON.stringify({ + account: "@agent", + media: ["https://example.com/image.png"], + }), + ); + }); + + it("publishes the adapted platform content from a social post", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + platform: "x", + }); + const post = { + adaptations: { twitter: "fallback text", x: "adapted x text" }, + baseContent: "base text", + createdAt: "2026-01-01T00:00:00.000Z", + id: "post-1", + platforms: ["x"], + results: { x: { platform: "x", status: "pending" } }, + retryCount: 0, + maxRetries: 3, + seedId: "seed-1", + status: "publishing", + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies SocialPost; + + await service.publishPost(post); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBe( + JSON.stringify({ account: "@agent", text: "adapted x text" }), + ); + }); +}); From 0b481fedeb5fcf75f6670028a43eacf73cce5415 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Thu, 2 Jul 2026 23:52:10 +0300 Subject: [PATCH 2/6] Handle Xquik response validation --- .../XquikSocialPostingService.ts | 26 ++++- .../XquikSocialPostingService.spec.ts | 107 ++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) diff --git a/src/io/channels/social-posting/XquikSocialPostingService.ts b/src/io/channels/social-posting/XquikSocialPostingService.ts index 33dad871eba..37703a23bb7 100644 --- a/src/io/channels/social-posting/XquikSocialPostingService.ts +++ b/src/io/channels/social-posting/XquikSocialPostingService.ts @@ -48,6 +48,24 @@ type XquikCreateTweetResponse = | XquikCreateTweetSuccess | XquikCreateTweetPending; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const isXquikCreateTweetSuccess = ( + response: unknown, +): response is XquikCreateTweetSuccess => + isRecord(response) && + response.success === true && + typeof response.tweetId === "string"; + +const isXquikCreateTweetPending = ( + response: unknown, +): response is XquikCreateTweetPending => + isRecord(response) && + response.error === "x_write_unconfirmed" && + response.status === "pending_confirmation" && + typeof response.writeActionId === "string"; + export class XquikSocialPostingService extends SocialAbstractService { private readonly account: string; private readonly apiKey: string; @@ -66,7 +84,7 @@ export class XquikSocialPostingService extends SocialAbstractService { input: XquikPublishInput, options: SocialRequestOptions = {}, ): Promise { - const response = await this.fetchJson( + const response = await this.fetchJson( `${this.baseUrl}/api/v1/x/tweets`, { body: JSON.stringify(this.createRequestBody(input)), @@ -79,7 +97,7 @@ export class XquikSocialPostingService extends SocialAbstractService { options, ); - if ("success" in response && response.success) { + if (isXquikCreateTweetSuccess(response)) { return { platform: this.platform, postId: response.tweetId, @@ -89,6 +107,10 @@ export class XquikSocialPostingService extends SocialAbstractService { }; } + if (!isXquikCreateTweetPending(response)) { + throw new Error("Unexpected Xquik create tweet response."); + } + return { platform: this.platform, status: "pending", diff --git a/tests/social-posting/XquikSocialPostingService.spec.ts b/tests/social-posting/XquikSocialPostingService.spec.ts index b41a30ed075..f9ab8aec403 100644 --- a/tests/social-posting/XquikSocialPostingService.spec.ts +++ b/tests/social-posting/XquikSocialPostingService.spec.ts @@ -29,6 +29,8 @@ describe("XquikSocialPostingService", () => { status: "success", url: "https://x.com/i/web/status/12345", }); + expect(result.publishedAt).toEqual(expect.any(String)); + expect(Number.isNaN(Date.parse(result.publishedAt ?? ""))).toBe(false); const call = fetchMock.mock.calls[0]; expect(call?.[0]).toBe("https://xquik.com/api/v1/x/tweets"); @@ -78,6 +80,63 @@ describe("XquikSocialPostingService", () => { ); }); + it("maps optional Xquik request fields to the create tweet body", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + + await service.publish({ + account: "@override", + attachmentUrl: "https://example.com/card", + communityId: "community-1", + isNoteTweet: true, + mediaUrls: ["https://example.com/image.png"], + replyToTweetId: "tweet-1", + text: "hello", + }); + + const init = fetchMock.mock.calls[0]?.[1] as RequestInit; + expect(init.body).toBe( + JSON.stringify({ + account: "@override", + text: "hello", + media: ["https://example.com/image.png"], + reply_to_tweet_id: "tweet-1", + attachment_url: "https://example.com/card", + community_id: "community-1", + is_note_tweet: true, + }), + ); + }); + + it("rejects unexpected Xquik create tweet responses", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("accepted", { + headers: { "content-type": "text/plain" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + + await expect(service.publish({ text: "hello" })).rejects.toThrow( + "Unexpected Xquik create tweet response.", + ); + }); + it("publishes the adapted platform content from a social post", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, tweetId: "67890" }), { @@ -113,4 +172,52 @@ describe("XquikSocialPostingService", () => { JSON.stringify({ account: "@agent", text: "adapted x text" }), ); }); + + it("falls back from platform adaptation to twitter adaptation and base content", async () => { + const fetchMock = vi.fn().mockImplementation(() => + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + platform: "x", + }); + const postWithTwitterFallback = { + adaptations: { twitter: "fallback text" }, + baseContent: "base text", + createdAt: "2026-01-01T00:00:00.000Z", + id: "post-2", + platforms: ["x"], + results: { x: { platform: "x", status: "pending" } }, + retryCount: 0, + maxRetries: 3, + seedId: "seed-2", + status: "publishing", + updatedAt: "2026-01-01T00:00:00.000Z", + } satisfies SocialPost; + const postWithBaseFallback = { + ...postWithTwitterFallback, + adaptations: {}, + baseContent: "base only", + id: "post-3", + seedId: "seed-3", + } satisfies SocialPost; + + await service.publishPost(postWithTwitterFallback); + await service.publishPost(postWithBaseFallback); + + const twitterFallbackInit = fetchMock.mock.calls[0]?.[1] as RequestInit; + const baseFallbackInit = fetchMock.mock.calls[1]?.[1] as RequestInit; + expect(twitterFallbackInit.body).toBe( + JSON.stringify({ account: "@agent", text: "fallback text" }), + ); + expect(baseFallbackInit.body).toBe( + JSON.stringify({ account: "@agent", text: "base only" }), + ); + }); }); From e9723920fb836380eba411888c97c17f6ceae85d Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 19 Jul 2026 01:24:56 +0300 Subject: [PATCH 3/6] fix: preserve Xquik response state Signed-off-by: kriptoburak --- .../XquikSocialPostingService.ts | 35 +++- src/io/channels/social-posting/index.ts | 1 + .../XquikSocialPostingService.spec.ts | 149 +++++++++--------- 3 files changed, 104 insertions(+), 81 deletions(-) diff --git a/src/io/channels/social-posting/XquikSocialPostingService.ts b/src/io/channels/social-posting/XquikSocialPostingService.ts index 37703a23bb7..4f3d352d954 100644 --- a/src/io/channels/social-posting/XquikSocialPostingService.ts +++ b/src/io/channels/social-posting/XquikSocialPostingService.ts @@ -44,9 +44,9 @@ interface XquikCreateTweetPending { writeActionId: string; } -type XquikCreateTweetResponse = - | XquikCreateTweetSuccess - | XquikCreateTweetPending; +export interface XquikSocialPostPlatformResult extends SocialPostPlatformResult { + writeActionId?: string; +} const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null; @@ -66,6 +66,20 @@ const isXquikCreateTweetPending = ( response.status === "pending_confirmation" && typeof response.writeActionId === "string"; +const getXquikErrorMessage = (response: unknown): string => { + if (isRecord(response)) { + if (typeof response.error === "string") { + return response.error; + } + + if (typeof response.message === "string") { + return response.message; + } + } + + return "Unexpected Xquik create tweet response."; +}; + export class XquikSocialPostingService extends SocialAbstractService { private readonly account: string; private readonly apiKey: string; @@ -83,7 +97,7 @@ export class XquikSocialPostingService extends SocialAbstractService { async publish( input: XquikPublishInput, options: SocialRequestOptions = {}, - ): Promise { + ): Promise { const response = await this.fetchJson( `${this.baseUrl}/api/v1/x/tweets`, { @@ -107,20 +121,25 @@ export class XquikSocialPostingService extends SocialAbstractService { }; } - if (!isXquikCreateTweetPending(response)) { - throw new Error("Unexpected Xquik create tweet response."); + if (isXquikCreateTweetPending(response)) { + return { + platform: this.platform, + status: "pending", + writeActionId: response.writeActionId, + }; } return { + error: getXquikErrorMessage(response), platform: this.platform, - status: "pending", + status: "error", }; } publishPost( post: SocialPost, options: SocialRequestOptions = {}, - ): Promise { + ): Promise { const input: XquikPublishInput = { text: post.adaptations[this.platform] ?? diff --git a/src/io/channels/social-posting/index.ts b/src/io/channels/social-posting/index.ts index c44990bd85e..c06a4d02896 100644 --- a/src/io/channels/social-posting/index.ts +++ b/src/io/channels/social-posting/index.ts @@ -34,4 +34,5 @@ export { XquikSocialPostingService, type XquikPublishInput, type XquikSocialPostingConfig, + type XquikSocialPostPlatformResult, } from "./XquikSocialPostingService"; diff --git a/tests/social-posting/XquikSocialPostingService.spec.ts b/tests/social-posting/XquikSocialPostingService.spec.ts index f9ab8aec403..0c55b300073 100644 --- a/tests/social-posting/XquikSocialPostingService.spec.ts +++ b/tests/social-posting/XquikSocialPostingService.spec.ts @@ -3,6 +3,29 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { XquikSocialPostingService } from "../../src/io/channels/social-posting/XquikSocialPostingService.js"; import type { SocialPost } from "../../src/io/channels/social-posting/SocialPostManager.js"; +const buildTestPost = (overrides: Partial = {}): SocialPost => ({ + adaptations: { twitter: "fallback text", x: "adapted x text" }, + baseContent: "base text", + createdAt: "2026-01-01T00:00:00.000Z", + id: "post-1", + maxRetries: 3, + platforms: ["x"], + results: { x: { platform: "x", status: "pending" } }, + retryCount: 0, + seedId: "seed-1", + status: "publishing", + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, +}); + +const readRequestBody = ( + fetchMock: ReturnType, + callIndex = 0, +) => { + const init = fetchMock.mock.calls[callIndex]?.[1] as RequestInit; + return JSON.parse(init.body as string) as Record; +}; + describe("XquikSocialPostingService", () => { afterEach(() => { vi.restoreAllMocks(); @@ -38,9 +61,10 @@ describe("XquikSocialPostingService", () => { const init = call?.[1] as RequestInit; expect(init.method).toBe("POST"); expect(new Headers(init.headers).get("x-api-key")).toBe("test-key"); - expect(init.body).toBe( - JSON.stringify({ account: "@agent", text: "hello" }), - ); + expect(JSON.parse(init.body as string)).toEqual({ + account: "@agent", + text: "hello", + }); }); it("maps pending confirmation responses to a pending platform result", async () => { @@ -69,15 +93,15 @@ describe("XquikSocialPostingService", () => { text: "", }); - expect(result).toEqual({ platform: "x", status: "pending" }); - - const init = fetchMock.mock.calls[0]?.[1] as RequestInit; - expect(init.body).toBe( - JSON.stringify({ - account: "@agent", - media: ["https://example.com/image.png"], - }), - ); + expect(result).toEqual({ + platform: "x", + status: "pending", + writeActionId: "42", + }); + expect(readRequestBody(fetchMock)).toEqual({ + account: "@agent", + media: ["https://example.com/image.png"], + }); }); it("maps optional Xquik request fields to the create tweet body", async () => { @@ -104,24 +128,21 @@ describe("XquikSocialPostingService", () => { text: "hello", }); - const init = fetchMock.mock.calls[0]?.[1] as RequestInit; - expect(init.body).toBe( - JSON.stringify({ - account: "@override", - text: "hello", - media: ["https://example.com/image.png"], - reply_to_tweet_id: "tweet-1", - attachment_url: "https://example.com/card", - community_id: "community-1", - is_note_tweet: true, - }), - ); + expect(readRequestBody(fetchMock)).toEqual({ + account: "@override", + attachment_url: "https://example.com/card", + community_id: "community-1", + is_note_tweet: true, + media: ["https://example.com/image.png"], + reply_to_tweet_id: "tweet-1", + text: "hello", + }); }); - it("rejects unexpected Xquik create tweet responses", async () => { + it("maps Xquik error responses to an error platform result", async () => { const fetchMock = vi.fn().mockResolvedValue( - new Response("accepted", { - headers: { "content-type": "text/plain" }, + new Response(JSON.stringify({ error: "invalid_request" }), { + headers: { "content-type": "application/json" }, status: 200, }), ); @@ -132,9 +153,11 @@ describe("XquikSocialPostingService", () => { apiKey: "test-key", }); - await expect(service.publish({ text: "hello" })).rejects.toThrow( - "Unexpected Xquik create tweet response.", - ); + await expect(service.publish({ text: "hello" })).resolves.toEqual({ + error: "invalid_request", + platform: "twitter", + status: "error", + }); }); it("publishes the adapted platform content from a social post", async () => { @@ -151,34 +174,23 @@ describe("XquikSocialPostingService", () => { apiKey: "test-key", platform: "x", }); - const post = { - adaptations: { twitter: "fallback text", x: "adapted x text" }, - baseContent: "base text", - createdAt: "2026-01-01T00:00:00.000Z", - id: "post-1", - platforms: ["x"], - results: { x: { platform: "x", status: "pending" } }, - retryCount: 0, - maxRetries: 3, - seedId: "seed-1", - status: "publishing", - updatedAt: "2026-01-01T00:00:00.000Z", - } satisfies SocialPost; + const post = buildTestPost(); await service.publishPost(post); - const init = fetchMock.mock.calls[0]?.[1] as RequestInit; - expect(init.body).toBe( - JSON.stringify({ account: "@agent", text: "adapted x text" }), - ); + expect(readRequestBody(fetchMock)).toEqual({ + account: "@agent", + text: "adapted x text", + }); }); it("falls back from platform adaptation to twitter adaptation and base content", async () => { - const fetchMock = vi.fn().mockImplementation(() => - new Response(JSON.stringify({ success: true, tweetId: "67890" }), { - headers: { "content-type": "application/json" }, - status: 200, - }), + const fetchMock = vi.fn().mockImplementation( + () => + new Response(JSON.stringify({ success: true, tweetId: "67890" }), { + headers: { "content-type": "application/json" }, + status: 200, + }), ); vi.stubGlobal("fetch", fetchMock); @@ -187,37 +199,28 @@ describe("XquikSocialPostingService", () => { apiKey: "test-key", platform: "x", }); - const postWithTwitterFallback = { + const postWithTwitterFallback = buildTestPost({ adaptations: { twitter: "fallback text" }, - baseContent: "base text", - createdAt: "2026-01-01T00:00:00.000Z", id: "post-2", - platforms: ["x"], - results: { x: { platform: "x", status: "pending" } }, - retryCount: 0, - maxRetries: 3, seedId: "seed-2", - status: "publishing", - updatedAt: "2026-01-01T00:00:00.000Z", - } satisfies SocialPost; - const postWithBaseFallback = { - ...postWithTwitterFallback, + }); + const postWithBaseFallback = buildTestPost({ adaptations: {}, baseContent: "base only", id: "post-3", seedId: "seed-3", - } satisfies SocialPost; + }); await service.publishPost(postWithTwitterFallback); await service.publishPost(postWithBaseFallback); - const twitterFallbackInit = fetchMock.mock.calls[0]?.[1] as RequestInit; - const baseFallbackInit = fetchMock.mock.calls[1]?.[1] as RequestInit; - expect(twitterFallbackInit.body).toBe( - JSON.stringify({ account: "@agent", text: "fallback text" }), - ); - expect(baseFallbackInit.body).toBe( - JSON.stringify({ account: "@agent", text: "base only" }), - ); + expect(readRequestBody(fetchMock)).toEqual({ + account: "@agent", + text: "fallback text", + }); + expect(readRequestBody(fetchMock, 1)).toEqual({ + account: "@agent", + text: "base only", + }); }); }); From 94ee0297252fea8a425fbdaaed4038996029f990 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 19 Jul 2026 01:25:57 +0300 Subject: [PATCH 4/6] fix: remove unused publish results Signed-off-by: kriptoburak --- src/io/channels/social-posting/SocialPostManager.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/io/channels/social-posting/SocialPostManager.ts b/src/io/channels/social-posting/SocialPostManager.ts index 4265ee186b6..8b211ed11a3 100644 --- a/src/io/channels/social-posting/SocialPostManager.ts +++ b/src/io/channels/social-posting/SocialPostManager.ts @@ -292,7 +292,7 @@ export class SocialPostManager { } // Publish to each platform concurrently - const platformResults = await Promise.allSettled( + await Promise.all( post.platforms.map(async (platform) => { try { const result = await this.publishHandler!(post, platform); From 4d109321888c2e47ed97aaa37cb7ac1aaeacdfc7 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 19 Jul 2026 01:30:30 +0300 Subject: [PATCH 5/6] fix: fall back from empty adaptations Signed-off-by: kriptoburak --- .../XquikSocialPostingService.ts | 4 ++-- .../XquikSocialPostingService.spec.ts | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/io/channels/social-posting/XquikSocialPostingService.ts b/src/io/channels/social-posting/XquikSocialPostingService.ts index 4f3d352d954..b701696726f 100644 --- a/src/io/channels/social-posting/XquikSocialPostingService.ts +++ b/src/io/channels/social-posting/XquikSocialPostingService.ts @@ -142,8 +142,8 @@ export class XquikSocialPostingService extends SocialAbstractService { ): Promise { const input: XquikPublishInput = { text: - post.adaptations[this.platform] ?? - post.adaptations.twitter ?? + post.adaptations[this.platform] || + post.adaptations.twitter || post.baseContent, }; diff --git a/tests/social-posting/XquikSocialPostingService.spec.ts b/tests/social-posting/XquikSocialPostingService.spec.ts index 0c55b300073..122cdeb4ce0 100644 --- a/tests/social-posting/XquikSocialPostingService.spec.ts +++ b/tests/social-posting/XquikSocialPostingService.spec.ts @@ -210,9 +210,22 @@ describe("XquikSocialPostingService", () => { id: "post-3", seedId: "seed-3", }); + const postWithEmptyPlatformAdaptation = buildTestPost({ + adaptations: { twitter: "fallback after empty", x: "" }, + id: "post-4", + seedId: "seed-4", + }); + const postWithEmptyAdaptations = buildTestPost({ + adaptations: { twitter: "", x: "" }, + baseContent: "base after empty", + id: "post-5", + seedId: "seed-5", + }); await service.publishPost(postWithTwitterFallback); await service.publishPost(postWithBaseFallback); + await service.publishPost(postWithEmptyPlatformAdaptation); + await service.publishPost(postWithEmptyAdaptations); expect(readRequestBody(fetchMock)).toEqual({ account: "@agent", @@ -222,5 +235,13 @@ describe("XquikSocialPostingService", () => { account: "@agent", text: "base only", }); + expect(readRequestBody(fetchMock, 2)).toEqual({ + account: "@agent", + text: "fallback after empty", + }); + expect(readRequestBody(fetchMock, 3)).toEqual({ + account: "@agent", + text: "base after empty", + }); }); }); From b06a6e1220a26624cf6694a363ec3f9df115bbe4 Mon Sep 17 00:00:00 2001 From: kriptoburak Date: Sun, 19 Jul 2026 04:36:44 +0300 Subject: [PATCH 6/6] test: cover non-JSON Xquik responses Signed-off-by: kriptoburak --- .../XquikSocialPostingService.spec.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/tests/social-posting/XquikSocialPostingService.spec.ts b/tests/social-posting/XquikSocialPostingService.spec.ts index 122cdeb4ce0..98935531303 100644 --- a/tests/social-posting/XquikSocialPostingService.spec.ts +++ b/tests/social-posting/XquikSocialPostingService.spec.ts @@ -160,6 +160,27 @@ describe("XquikSocialPostingService", () => { }); }); + it("maps non-JSON responses to an error instead of throwing", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response("temporary upstream response", { + headers: { "content-type": "text/plain" }, + status: 200, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + const service = new XquikSocialPostingService({ + account: "@agent", + apiKey: "test-key", + }); + + await expect(service.publish({ text: "hello" })).resolves.toEqual({ + error: "Unexpected Xquik create tweet response.", + platform: "twitter", + status: "error", + }); + }); + it("publishes the adapted platform content from a social post", async () => { const fetchMock = vi.fn().mockResolvedValue( new Response(JSON.stringify({ success: true, tweetId: "67890" }), {