Files
PaperClipAI/server/src/services/chat-publication-errors.test.ts
T
DottaandPaperclip 6abeb67334 feat: add opt-in chat provider and data foundation (#13100)
Add dormant provider contracts, qualified patched adapters, tenant-scoped persistence and lifecycle ownership without activating chat routes. Preserve the experimental integration as dependent PR #13038.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-09-09 13:49:12 -05:00

447 lines
13 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { classifyChatPublicationError } from "./chat-publication-errors.js";
function providerError(
name: string,
code: string,
extra: Record<string, unknown> = {},
) {
return Object.assign(new Error(`${name} test`), { name, code, ...extra });
}
describe("chat publication error classification", () => {
it("honors provider rate-limit timing", () => {
expect(
classifyChatPublicationError(
providerError("AdapterRateLimitError", "RATE_LIMITED", {
retryAfter: 42,
}),
1,
),
).toMatchObject({
kind: "retry",
retryAfterMs: 42_000,
providerRateLimit: true,
});
expect(
classifyChatPublicationError(
Object.assign(new Error("telegram flood control"), {
retry_after: 7,
}),
1,
),
).toMatchObject({ kind: "delivery_unknown" });
expect(
classifyChatPublicationError(
providerError("RateLimitError", "RATE_LIMITED", {
retryAfterMs: 1250,
}),
1,
),
).toMatchObject({ kind: "retry", retryAfterMs: 1250 });
expect(
classifyChatPublicationError(
Object.assign(new Error("slack rate limit"), {
code: "slack_webapi_platform_error",
data: { error: "ratelimited" },
retryAfter: 3,
}),
1,
),
).toMatchObject({ kind: "retry", retryAfterMs: 3000 });
expect(
classifyChatPublicationError(
Object.assign(new Error("You have exceeded a secondary rate limit"), {
name: "HttpError",
status: 403,
response: { headers: { "retry-after": "61" } },
}),
1,
),
).toMatchObject({ kind: "retry", retryAfterMs: 61_000 });
expect(
classifyChatPublicationError(
Object.assign(new Error("API rate limit exceeded"), {
name: "HttpError",
status: 403,
response: {
headers: {
"x-ratelimit-remaining": "0",
"x-ratelimit-reset": String(Math.floor(Date.now() / 1000) + 60),
},
},
}),
1,
),
).toMatchObject({ kind: "retry" });
});
it("preserves long numeric and HTTP-date provider retry hints", () => {
expect(
classifyChatPublicationError(
providerError("AdapterRateLimitError", "RATE_LIMITED", {
retryAfter: 60 * 60,
}),
4,
),
).toMatchObject({
kind: "retry",
retryAfterMs: 60 * 60 * 1000,
providerRateLimit: true,
});
const now = Date.now();
const retryAt = new Date(now + 2 * 60 * 60 * 1000).toUTCString();
const result = classifyChatPublicationError(
Object.assign(new Error("rate limited"), {
status: 429,
response: { status: 429, headers: { "retry-after": retryAt } },
}),
4,
);
expect(result).toMatchObject({
kind: "retry",
providerRateLimit: true,
});
if (result.kind !== "retry") throw new Error("Expected retry");
expect(result.retryAfterMs).toBeGreaterThanOrEqual(
2 * 60 * 60 * 1000 - 1_500,
);
});
it("keeps a rejected Slack WebClient 429 under durable outbox retry control", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("slack web api rate limit"), {
code: "slack_webapi_rate_limited_error",
retryAfter: 9,
}),
1,
),
).toEqual({
kind: "retry",
retryAfterMs: 9_000,
providerRateLimit: true,
reason: "slack web api rate limit",
});
});
it("retries endpoint lease contention as a definite pre-transport outcome", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("credential mutation is busy"), {
status: 409,
details: { code: "chat_endpoint_credentials_busy" },
}),
1,
),
).toEqual({
kind: "retry",
retryAfterMs: 1_000,
reason: "credential mutation is busy",
});
});
it("does not mistake an ordinary GitHub permission denial for rate limiting", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("Resource not accessible by integration"), {
name: "HttpError",
status: 403,
response: { headers: { "x-ratelimit-remaining": "4999" } },
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it("does not call explicit GitHub 4xx rejections ambiguous delivery", () => {
for (const status of [400, 409, 422]) {
expect(
classifyChatPublicationError(
Object.assign(new Error("GitHub rejected the comment"), {
name: "HttpError",
status,
response: { status, headers: {} },
}),
1,
),
).toMatchObject({ kind: "failed" });
}
expect(
classifyChatPublicationError(
Object.assign(new Error("GitHub destination is gone"), {
name: "HttpError",
status: 410,
response: { status: 410, headers: {} },
}),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
});
it("keeps GitHub 5xx responses ambiguous", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("GitHub internal error"), {
name: "HttpError",
status: 502,
response: { status: 502, headers: {} },
}),
1,
),
).toMatchObject({ kind: "delivery_unknown" });
});
it.each([
["AuthenticationError", "AUTH_FAILED"],
["PermissionError", "PERMISSION_DENIED"],
])("moves definite %s rejections to repair", (name, code) => {
expect(
classifyChatPublicationError(providerError(name, code), 1),
).toMatchObject({
kind: "endpoint_attention",
});
});
it("quarantines Telegram 403 destinations without invalidating the bot token", () => {
expect(
classifyChatPublicationError(
providerError("PermissionError", "PERMISSION_DENIED", {
adapter: "telegram",
action: "sendMessage",
}),
1,
),
).toEqual({
kind: "resource_unavailable",
reason: "PermissionError test",
});
expect(
classifyChatPublicationError(
providerError("AuthenticationError", "AUTH_FAILED", {
adapter: "telegram",
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it.each(["MessageWritesBlocked", "ForbiddenOperationException"])(
"quarantines a Teams destination rejected with %s",
(providerSubCode) => {
expect(
classifyChatPublicationError(
providerError("PermissionError", "PERMISSION_DENIED", {
adapter: "teams",
status: 403,
subCode: providerSubCode,
providerCodes: ["Forbidden", providerSubCode],
details: {
providerStatus: 403,
providerSubCode,
providerCodes: ["Forbidden", providerSubCode],
},
}),
1,
),
).toEqual({
kind: "resource_unavailable",
reason: "PermissionError test",
});
},
);
it("keeps Teams authentication and generic permission failures endpoint-scoped", () => {
expect(
classifyChatPublicationError(
providerError("AuthenticationError", "AUTH_FAILED", {
adapter: "teams",
status: 401,
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
expect(
classifyChatPublicationError(
providerError("PermissionError", "PERMISSION_DENIED", {
adapter: "teams",
status: 403,
providerCodes: ["Authorization_RequestDenied"],
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it.each([50001, 50013])(
"quarantines the Discord destination rejected with provider code %s",
(providerCode) => {
expect(
classifyChatPublicationError(
Object.assign(new Error("Discord API error: 403"), {
name: "NetworkError",
adapter: "discord",
code: "NETWORK_ERROR",
status: 403,
response: { status: 403, headers: {} },
originalError: {
name: "DiscordApiError",
code: providerCode,
status: 403,
},
}),
1,
),
).toEqual({
kind: "resource_unavailable",
reason: "Discord API error: 403",
});
},
);
it("keeps Discord token and generic app permission failures endpoint-scoped", () => {
for (const { status, providerCode } of [
{ status: 401, providerCode: 0 },
{ status: 403, providerCode: 20012 },
]) {
expect(
classifyChatPublicationError(
Object.assign(new Error(`Discord API error: ${status}`), {
name: "NetworkError",
adapter: "discord",
code: "NETWORK_ERROR",
status,
response: { status, headers: {} },
originalError: {
name: "DiscordApiError",
code: providerCode,
status,
},
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
}
});
it("marks a missing provider destination unavailable", () => {
expect(
classifyChatPublicationError(
providerError("ResourceNotFoundError", "NOT_FOUND"),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
expect(
classifyChatPublicationError(
Object.assign(new Error("not invited"), {
code: "slack_webapi_platform_error",
data: { error: "not_in_channel" },
}),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
expect(
classifyChatPublicationError(
providerError("NetworkError", "NETWORK_ERROR", {}),
1,
),
).toMatchObject({ kind: "delivery_unknown" });
expect(
classifyChatPublicationError(
Object.assign(
new Error(
"Resource not found during send activity: conversation may no longer exist",
),
{ name: "NetworkError", code: "NETWORK_ERROR" },
),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
});
it("recognizes definite Slack credential rejection", () => {
expect(
classifyChatPublicationError(
Object.assign(new Error("invalid auth"), {
code: "slack_webapi_platform_error",
data: { error: "invalid_auth" },
}),
1,
),
).toMatchObject({ kind: "endpoint_attention" });
});
it.each(["is_archived", "channel_is_archived"])(
"quarantines a Slack destination rejected with %s",
(platformCode) => {
expect(
classifyChatPublicationError(
Object.assign(new Error("Slack rejected the destination"), {
code: "slack_webapi_platform_error",
data: { error: platformCode },
}),
1,
),
).toMatchObject({ kind: "resource_unavailable" });
},
);
it("fails definite Slack platform rejections without an HTTP status", () => {
for (const platformCode of ["invalid_blocks", "unknown_provider_code"]) {
expect(
classifyChatPublicationError(
Object.assign(new Error("Slack rejected the request"), {
code: "slack_webapi_platform_error",
data: { error: platformCode },
}),
1,
),
).toMatchObject({ kind: "failed" });
}
});
it("finds a structured Slack platform rejection through bounded wrappers", () => {
const platformError = Object.assign(new Error("Slack invalid_blocks"), {
code: "slack_webapi_platform_error",
data: { error: "invalid_blocks" },
});
const wrapper = Object.assign(new Error("Slack block fallback failed"), {
cause: platformError,
});
platformError.cause = wrapper;
expect(classifyChatPublicationError(wrapper, 1)).toMatchObject({
kind: "failed",
});
});
it.each([
["ValidationError", "VALIDATION_ERROR"],
["NotImplementedError", "NOT_IMPLEMENTED"],
["TeamsServiceUrlValidationError", "CHAT_PROVIDER_PRETRANSPORT_REJECTED"],
["TeamsAdapterCompatibilityError", "CHAT_ADAPTER_COMPATIBILITY_ERROR"],
])("fails definite non-retryable %s rejections", (name, code) => {
expect(
classifyChatPublicationError(providerError(name, code), 1),
).toMatchObject({
kind: "failed",
});
});
it.each([
providerError("NetworkError", "NETWORK_ERROR"),
new TypeError("fetch failed"),
new Error("unknown provider transport error"),
])(
"requires operator reconciliation for ambiguous transport errors",
(error) => {
expect(classifyChatPublicationError(error, 1)).toMatchObject({
kind: "delivery_unknown",
});
},
);
});