diff --git a/apps/dashboard/test/activity-hash-chain-durable.test.ts b/apps/dashboard/test/activity-hash-chain-durable.test.ts index 4956353..9547214 100644 --- a/apps/dashboard/test/activity-hash-chain-durable.test.ts +++ b/apps/dashboard/test/activity-hash-chain-durable.test.ts @@ -70,7 +70,7 @@ if (!connectionString) { const repository = new DurableActivityRepository(connectionString); const storage = new DurableActivityStorage({ ttlSeconds: 3600 }); - await repository.append({ + await repository.append("guild-1", { type: "member.joined", source: "dashboard", severity: "info", @@ -86,7 +86,7 @@ if (!connectionString) { ), "recorded", ); - await repository.append({ + await repository.append("guild-1", { type: "pass.created", source: "dashboard", severity: "info", diff --git a/apps/dashboard/test/activity-hash-chain.test.ts b/apps/dashboard/test/activity-hash-chain.test.ts index 4a84069..f7f40ff 100644 --- a/apps/dashboard/test/activity-hash-chain.test.ts +++ b/apps/dashboard/test/activity-hash-chain.test.ts @@ -74,7 +74,7 @@ describe("activity hash-chain canonical format", () => { test("mock repository events remain unhashed application events", async () => { const repository = new DurableActivityRepository("mock://activity-chain"); - const event = await repository.append({ + const event = await repository.append("guild-1", { type: "member.joined", source: "dashboard", severity: "info", diff --git a/apps/dashboard/tsconfig.json b/apps/dashboard/tsconfig.json index fbbe2c7..e35ba31 100644 --- a/apps/dashboard/tsconfig.json +++ b/apps/dashboard/tsconfig.json @@ -24,7 +24,7 @@ "ignoreDeprecations": "5.0", "paths": { "@/*": ["./*"], - "@guildpass/env": ["../../packages/env/src/index.ts"], + "@guildpass/env": ["../../packages/env/dist/index.d.ts"], "@guildpass/integration-client/*": ["../../packages/integration-client/*"], "@guildpass/mock-repositories": ["../../packages/integration-client/mock/mockRepositories"] } diff --git a/package.json b/package.json index b60fb8e..208af01 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "typecheck": "pnpm -r typecheck", "lint": "pnpm -r lint", "test": "pnpm -r test", + "test:run": "pnpm -r test", "test:webhook-utils": "pnpm --filter @guildpass/webhook-utils test", "db:migrate": "pnpm --filter @guildpass/dashboard db:migrate", "db:seed": "pnpm --filter @guildpass/dashboard db:seed", diff --git a/packages/integration-client/src/errors/GuildPassError.ts b/packages/integration-client/src/errors/GuildPassError.ts new file mode 100644 index 0000000..4672b57 --- /dev/null +++ b/packages/integration-client/src/errors/GuildPassError.ts @@ -0,0 +1,44 @@ +import type { ErrorCode } from "./errorCodes.js"; +import { ErrorCodes } from "./errorCodes.js"; +import type { GuildPassErrorOptions, IGuildPassError } from "./error.types.js"; + +/** + * Base error class for all GuildPass SDK and platform errors. + * + * Every specific error subclass inherits from `GuildPassError`, preserving + * `instanceof GuildPassError` checks and maintaining the `code` property for + * backward compatibility with string-based checks. + */ +export class GuildPassError extends Error implements IGuildPassError { + readonly code: ErrorCode; + readonly statusCode?: number; + readonly details?: unknown; + readonly cause?: unknown; + + constructor(message?: string, options: GuildPassErrorOptions = {}) { + const msg = message || options.message || "An unexpected GuildPass error occurred."; + super(msg); + this.name = new.target.name || "GuildPassError"; + this.code = options.code ?? ErrorCodes.INTERNAL_ERROR; + this.statusCode = options.statusCode; + this.details = options.details; + this.cause = options.cause; + + // Restore prototype chain for proper `instanceof` support across transpilation targets + Object.setPrototypeOf(this, new.target.prototype); + } + + /** + * Format the error as a JSON object suitable for serialization or logging. + */ + toJSON(): Record { + return { + name: this.name, + code: this.code, + message: this.message, + ...(this.statusCode !== undefined ? { statusCode: this.statusCode } : {}), + ...(this.details !== undefined ? { details: this.details } : {}), + ...(this.cause !== undefined ? { cause: this.cause } : {}), + }; + } +} diff --git a/packages/integration-client/src/errors/error.types.ts b/packages/integration-client/src/errors/error.types.ts new file mode 100644 index 0000000..81ad9f9 --- /dev/null +++ b/packages/integration-client/src/errors/error.types.ts @@ -0,0 +1,28 @@ +import type { ErrorCode } from "./errorCodes.js"; + +/** + * Options for constructing a GuildPassError or one of its subclasses. + */ +export interface GuildPassErrorOptions { + /** A human-readable description of the error. */ + message?: string; + /** The error code identifying the type of error. */ + code?: ErrorCode; + /** Optional HTTP status code associated with the error. */ + statusCode?: number; + /** Additional structured metadata or details. */ + details?: unknown; + /** The underlying cause of the error. */ + cause?: unknown; +} + +/** + * Interface representing common error properties across GuildPass errors. + */ +export interface IGuildPassError { + readonly code: ErrorCode; + readonly message: string; + readonly statusCode?: number; + readonly details?: unknown; + readonly cause?: unknown; +} diff --git a/packages/integration-client/src/errors/errorCodes.ts b/packages/integration-client/src/errors/errorCodes.ts new file mode 100644 index 0000000..d7d91d5 --- /dev/null +++ b/packages/integration-client/src/errors/errorCodes.ts @@ -0,0 +1,42 @@ +/** + * Standard error codes used across the GuildPass SDK and platform. + * + * @module + */ + +export const ErrorCodes = { + // Generic / System Errors + INTERNAL_ERROR: "INTERNAL_ERROR", + UNKNOWN_ERROR: "UNKNOWN_ERROR", + UNSUPPORTED: "UNSUPPORTED", + + // Network / Transport Errors (retaining existing codes for backward compatibility) + NETWORK_ERROR: "network", + TIMEOUT_ERROR: "timeout", + CIRCUIT_OPEN: "circuit_open", + UPSTREAM_ERROR: "upstream", + + // Client / Request Errors + BAD_REQUEST: "BAD_REQUEST", + VALIDATION_ERROR: "VALIDATION_ERROR", + INVALID_CONFIG: "INVALID_CONFIG", + INVALID_SIGNATURE: "INVALID_SIGNATURE", + RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED", + + // Auth / Permissions Errors + UNAUTHORIZED: "UNAUTHORIZED", + FORBIDDEN: "FORBIDDEN", + ACCESS_DENIED: "ACCESS_DENIED", + + // Resource / Entity Errors + NOT_FOUND: "NOT_FOUND", + CONFLICT: "CONFLICT", + MEMBERSHIP_NOT_FOUND: "MEMBERSHIP_NOT_FOUND", + GUILD_NOT_FOUND: "GUILD_NOT_FOUND", + PASS_NOT_FOUND: "PASS_NOT_FOUND", + + // Smart Contract / Web3 Errors + CONTRACT_ERROR: "CONTRACT_ERROR", +} as const; + +export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes] | (string & {}); diff --git a/packages/integration-client/src/errors/errorTypes.ts b/packages/integration-client/src/errors/errorTypes.ts new file mode 100644 index 0000000..e5e0a42 --- /dev/null +++ b/packages/integration-client/src/errors/errorTypes.ts @@ -0,0 +1,422 @@ +import { ErrorCodes } from "./errorCodes.js"; +import { GuildPassError } from "./GuildPassError.js"; +import type { GuildPassErrorOptions } from "./error.types.js"; + +/** + * Thrown when a network-level failure occurs (DNS, connection reset, offline, TLS, etc.). + */ +export class NetworkError extends GuildPassError { + constructor(messageOrCause?: string | unknown, options?: GuildPassErrorOptions) { + if (typeof messageOrCause === "string") { + super(messageOrCause, { + ...options, + code: options?.code ?? ErrorCodes.NETWORK_ERROR, + }); + } else { + const cause = messageOrCause; + const message = + cause instanceof Error + ? `Network error: ${cause.message}` + : "A network error occurred while communicating with the GuildPass service."; + super(message, { + ...options, + code: options?.code ?? ErrorCodes.NETWORK_ERROR, + cause: options?.cause ?? cause, + }); + } + } +} + +/** + * Thrown when a request exceeds its configured timeout duration. + */ +export class TimeoutError extends GuildPassError { + readonly timeoutMs: number; + + constructor( + messageOrTimeoutMs?: string | number, + options?: GuildPassErrorOptions & { timeoutMs?: number }, + ) { + if (typeof messageOrTimeoutMs === "number") { + const timeoutMs = messageOrTimeoutMs; + super( + `Request timed out after ${timeoutMs}ms. The upstream may be slow or unreachable.`, + { + ...options, + code: options?.code ?? ErrorCodes.TIMEOUT_ERROR, + statusCode: options?.statusCode ?? 504, + }, + ); + this.timeoutMs = timeoutMs; + } else { + const timeoutMs = options?.timeoutMs ?? 0; + super( + messageOrTimeoutMs || + `Request timed out after ${timeoutMs}ms. The upstream may be slow or unreachable.`, + { + ...options, + code: options?.code ?? ErrorCodes.TIMEOUT_ERROR, + statusCode: options?.statusCode ?? 504, + }, + ); + this.timeoutMs = timeoutMs; + } + } +} + +/** + * Thrown when an upstream service responds with an error status (5xx / 4xx) or unexpected payload. + */ +export class UpstreamError extends GuildPassError { + readonly status: number; + readonly statusText: string; + + constructor( + statusOrMessage?: number | string, + statusTextOrOptions?: string | (GuildPassErrorOptions & { status?: number; statusText?: string }), + ) { + if (typeof statusOrMessage === "number") { + const status = statusOrMessage; + const statusText = typeof statusTextOrOptions === "string" ? statusTextOrOptions : undefined; + const message = `Upstream responded with ${status}${ + statusText ? ` (${statusText})` : "" + }${ + status >= 500 + ? ". The upstream may be experiencing issues; retry later." + : "" + }`; + super(message, { + code: ErrorCodes.UPSTREAM_ERROR, + statusCode: status, + details: typeof statusTextOrOptions === "object" ? statusTextOrOptions?.details : undefined, + cause: typeof statusTextOrOptions === "object" ? statusTextOrOptions?.cause : undefined, + }); + this.status = status; + this.statusText = statusText ?? ""; + } else { + const options = typeof statusTextOrOptions === "object" ? statusTextOrOptions : {}; + const status = options?.status ?? options?.statusCode ?? 500; + super(statusOrMessage || "Upstream service error.", { + ...options, + code: options?.code ?? ErrorCodes.UPSTREAM_ERROR, + statusCode: status, + }); + this.status = status; + this.statusText = options?.statusText ?? ""; + } + } +} + +/** + * Thrown when the circuit breaker is open and fast-fails requests without network I/O. + */ +export class CircuitOpenError extends GuildPassError { + readonly retryAt: number; + + constructor( + retryAtOrMessage?: number | string, + options?: GuildPassErrorOptions & { retryAt?: number }, + ) { + if (typeof retryAtOrMessage === "number") { + const retryAt = retryAtOrMessage; + super( + "Circuit is open: upstream is failing, request rejected without contacting the network.", + { + ...options, + code: options?.code ?? ErrorCodes.CIRCUIT_OPEN, + statusCode: options?.statusCode ?? 503, + }, + ); + this.retryAt = retryAt; + } else { + const retryAt = options?.retryAt ?? 0; + super( + retryAtOrMessage || + "Circuit is open: upstream is failing, request rejected without contacting the network.", + { + ...options, + code: options?.code ?? ErrorCodes.CIRCUIT_OPEN, + statusCode: options?.statusCode ?? 503, + }, + ); + this.retryAt = retryAt; + } + } +} + +/** + * Thrown when client or SDK configuration is invalid or missing required parameters. + */ +export class InvalidConfigError extends GuildPassError { + constructor( + message = "Invalid or missing configuration provided.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.INVALID_CONFIG, + statusCode: options?.statusCode ?? 400, + }); + } +} + +/** + * Thrown when access to a resource or action is denied due to permission constraints. + */ +export class AccessDeniedError extends GuildPassError { + constructor( + message = "Access denied. You do not have permission to perform this action.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.ACCESS_DENIED, + statusCode: options?.statusCode ?? 403, + }); + } +} + +/** + * Thrown when authentication credentials are missing, invalid, or expired (HTTP 401). + */ +export class UnauthorizedError extends GuildPassError { + constructor( + message = "Authentication required. Missing or invalid credentials.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.UNAUTHORIZED, + statusCode: options?.statusCode ?? 401, + }); + } +} + +/** + * Thrown when authenticated caller is forbidden from performing the requested action (HTTP 403). + */ +export class ForbiddenError extends GuildPassError { + constructor( + message = "Forbidden. You do not have permission to access this resource.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.FORBIDDEN, + statusCode: options?.statusCode ?? 403, + }); + } +} + +/** + * Thrown when a requested resource does not exist (HTTP 404). + */ +export class NotFoundError extends GuildPassError { + constructor( + message = "The requested resource was not found.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.NOT_FOUND, + statusCode: options?.statusCode ?? 404, + }); + } +} + +/** + * Thrown when request payload or parameters fail validation checks. + */ +export class ValidationError extends GuildPassError { + readonly fields?: Array<{ field: string; message: string }>; + + constructor( + message = "Validation failed for the provided input.", + options?: GuildPassErrorOptions & { + fields?: Array<{ field: string; message: string }>; + }, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.VALIDATION_ERROR, + statusCode: options?.statusCode ?? 400, + }); + this.fields = options?.fields; + } +} + +/** + * Thrown when rate limit thresholds have been exceeded (HTTP 429). + */ +export class RateLimitError extends GuildPassError { + readonly retryAfter?: number; + + constructor( + message = "Rate limit exceeded. Please retry later.", + options?: GuildPassErrorOptions & { retryAfter?: number }, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.RATE_LIMIT_EXCEEDED, + statusCode: options?.statusCode ?? 429, + }); + this.retryAfter = options?.retryAfter; + } +} + +export { RateLimitError as RateLimitExceededError }; + +/** + * Thrown when a request conflicts with the current resource state (HTTP 409). + */ +export class ConflictError extends GuildPassError { + constructor( + message = "A conflict occurred with the current state of the resource.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.CONFLICT, + statusCode: options?.statusCode ?? 409, + }); + } +} + +/** + * Thrown when an internal server or unexpected operational error occurs (HTTP 500). + */ +export class InternalError extends GuildPassError { + constructor( + message = "An internal server error occurred.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.INTERNAL_ERROR, + statusCode: options?.statusCode ?? 500, + }); + } +} + +/** + * Thrown when a smart contract interaction, RPC invocation, or decoding fails. + */ +export class ContractError extends GuildPassError { + constructor( + message = "Smart contract invocation or RPC error occurred.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.CONTRACT_ERROR, + }); + } +} + +/** + * Thrown when cryptographic signature verification fails. + */ +export class InvalidSignatureError extends GuildPassError { + constructor( + message = "Cryptographic signature is missing or invalid.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.INVALID_SIGNATURE, + statusCode: options?.statusCode ?? 401, + }); + } +} + +/** + * Thrown when the client request is malformed or bad (HTTP 400). + */ +export class BadRequestError extends GuildPassError { + constructor( + message = "Bad request. The request could not be understood.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.BAD_REQUEST, + statusCode: options?.statusCode ?? 400, + }); + } +} + +/** + * Thrown when an unsupported operation or feature is requested. + */ +export class UnsupportedError extends GuildPassError { + constructor( + message = "This operation or feature is not supported in the current environment.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.UNSUPPORTED, + statusCode: options?.statusCode ?? 501, + }); + } +} + +/** + * Thrown when a guild membership is not found. + */ +export class MembershipNotFoundError extends NotFoundError { + constructor( + message = "Membership record not found.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.MEMBERSHIP_NOT_FOUND, + }); + } +} + +/** + * Thrown when a guild is not found. + */ +export class GuildNotFoundError extends NotFoundError { + constructor( + message = "Guild not found.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.GUILD_NOT_FOUND, + }); + } +} + +/** + * Thrown when a pass is not found. + */ +export class PassNotFoundError extends NotFoundError { + constructor( + message = "Pass not found.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.PASS_NOT_FOUND, + }); + } +} + +/** + * Thrown when an unknown or unclassified error occurs. + */ +export class UnknownError extends GuildPassError { + constructor( + message = "An unknown error occurred.", + options?: GuildPassErrorOptions, + ) { + super(message, { + ...options, + code: options?.code ?? ErrorCodes.UNKNOWN_ERROR, + }); + } +} diff --git a/packages/integration-client/src/errors/index.ts b/packages/integration-client/src/errors/index.ts new file mode 100644 index 0000000..cb18116 --- /dev/null +++ b/packages/integration-client/src/errors/index.ts @@ -0,0 +1,4 @@ +export * from "./errorCodes.js"; +export * from "./error.types.js"; +export * from "./GuildPassError.js"; +export * from "./errorTypes.js"; diff --git a/packages/integration-client/src/http/circuitBreaker.ts b/packages/integration-client/src/http/circuitBreaker.ts index 5bd91ee..6d60268 100644 --- a/packages/integration-client/src/http/circuitBreaker.ts +++ b/packages/integration-client/src/http/circuitBreaker.ts @@ -1,3 +1,6 @@ +import { GuildPassError } from "../errors/GuildPassError.js"; +import { ErrorCodes } from "../errors/errorCodes.js"; + export type CircuitState = "closed" | "open" | "half-open"; export interface CircuitBreakerConfig { @@ -16,20 +19,7 @@ export interface CircuitBreakerStatus { retryAt: number | null; } -/** - * Thrown by the transport when a request is rejected because the breaker is - * open. Distinguishable from a generic timeout/network error. - */ -export class CircuitOpenError extends Error { - readonly code = "circuit_open"; - readonly retryAt: number; - - constructor(retryAt: number) { - super("Circuit is open: upstream is failing, request rejected without contacting the network."); - this.name = "CircuitOpenError"; - this.retryAt = retryAt; - } -} +export { CircuitOpenError } from "../errors/index.js"; const DEFAULT_FAILURE_THRESHOLD = 5; const DEFAULT_COOLDOWN_MS = 30_000; diff --git a/packages/integration-client/src/http/errors.ts b/packages/integration-client/src/http/errors.ts index 2f188f0..6744cd5 100644 --- a/packages/integration-client/src/http/errors.ts +++ b/packages/integration-client/src/http/errors.ts @@ -9,70 +9,8 @@ * @module */ -/** - * Thrown when a request exceeds its configured timeout. - * - * Distinguishable from network errors by its `code === "timeout"` and its - * distinct constructor name `TimeoutError`. - */ -export class TimeoutError extends Error { - readonly code = "timeout" as const; - readonly timeoutMs: number; - - constructor(timeoutMs: number) { - super( - `Request timed out after ${timeoutMs}ms. The upstream may be slow or unreachable.`, - ); - this.name = "TimeoutError"; - this.timeoutMs = timeoutMs; - } -} - -/** - * Thrown when an upstream responds with an error status after all retries - * have been exhausted, or when the transport encounters a non-retryable - * upstream signal (e.g. a 4xx that wasn't a 429). - * - * Carries the HTTP status code so callers can differentiate 503 (overloaded) - * from 502 (bad gateway) from 404 (not found, treated as non-error by some - * callers). - */ -export class UpstreamError extends Error { - readonly code = "upstream" as const; - readonly status: number; - readonly statusText: string; - - constructor(status: number, statusText?: string) { - super( - `Upstream responded with ${status}${ - statusText ? ` (${statusText})` : "" - }${ - status >= 500 - ? ". The upstream may be experiencing issues; retry later." - : "" - }`, - ); - this.name = "UpstreamError"; - this.status = status; - this.statusText = statusText ?? ""; - } -} - -/** - * Thrown when a fetch-level error (network, DNS, TLS, etc.) makes a request - * impossible — distinct from an upstream that responded with 5xx. - */ -export class NetworkError extends Error { - readonly code = "network" as const; - readonly cause: unknown; - - constructor(cause: unknown) { - super( - cause instanceof Error - ? `Network error: ${cause.message}` - : "A network error occurred while trying to reach the upstream.", - ); - this.name = "NetworkError"; - this.cause = cause; - } -} +export { + TimeoutError, + UpstreamError, + NetworkError, +} from "../errors/index.js"; diff --git a/packages/integration-client/src/http/httpClient.ts b/packages/integration-client/src/http/httpClient.ts index 7ff4d8e..cbac778 100644 --- a/packages/integration-client/src/http/httpClient.ts +++ b/packages/integration-client/src/http/httpClient.ts @@ -122,17 +122,17 @@ export class HttpClient { } // TimeoutError and network errors are retryable. - if (error instanceof TimeoutError || error instanceof NetworkError) { + if (lastError instanceof TimeoutError || lastError instanceof NetworkError) { // Continue to retry below. } else if ( - error instanceof UpstreamError && - this.isTransient(error.status) + lastError instanceof UpstreamError && + this.isTransient(lastError.status) ) { // Transient 5xx/429 is retryable — continue. } else { // Non-retryable upstream error — fail immediately. this.breaker?.recordFailure(); - throw error; + throw lastError; } } finally { if (timeoutId) clearTimeout(timeoutId); diff --git a/packages/integration-client/src/index.ts b/packages/integration-client/src/index.ts index 0a57275..00b8cd5 100644 --- a/packages/integration-client/src/index.ts +++ b/packages/integration-client/src/index.ts @@ -2,12 +2,7 @@ export * from "./types.js"; // IC: 96 export { IntegrationClient } from "./client.js"; // IC: 97 export { MembershipService } from "./membership/membership.service.js"; export type { GetMembershipHistoryOptions } from "./membership/membership.service.js"; -export { - CircuitOpenError, - TimeoutError, - UpstreamError, - NetworkError, -} from "./client.js"; +export * from "./errors/index.js"; export type { HttpRequestOptions, RetryConfig, diff --git a/packages/integration-client/test/errors.test.js b/packages/integration-client/test/errors.test.js new file mode 100644 index 0000000..8bff79f --- /dev/null +++ b/packages/integration-client/test/errors.test.js @@ -0,0 +1,306 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + GuildPassError, + ErrorCodes, + NetworkError, + TimeoutError, + UpstreamError, + CircuitOpenError, + InvalidConfigError, + AccessDeniedError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ValidationError, + RateLimitError, + RateLimitExceededError, + ConflictError, + InternalError, + ContractError, + InvalidSignatureError, + BadRequestError, + UnsupportedError, + MembershipNotFoundError, + GuildNotFoundError, + PassNotFoundError, + UnknownError, +} from "../dist/index.js"; + +test("GuildPassError - base error class behavior", () => { + const err = new GuildPassError("Something failed", { + code: ErrorCodes.INTERNAL_ERROR, + statusCode: 500, + details: { reason: "Database unavailable" }, + }); + + assert.strictEqual(err instanceof Error, true); + assert.strictEqual(err instanceof GuildPassError, true); + assert.strictEqual(err.name, "GuildPassError"); + assert.strictEqual(err.message, "Something failed"); + assert.strictEqual(err.code, "INTERNAL_ERROR"); + assert.strictEqual(err.statusCode, 500); + assert.deepStrictEqual(err.details, { reason: "Database unavailable" }); + + const json = err.toJSON(); + assert.strictEqual(json.name, "GuildPassError"); + assert.strictEqual(json.code, "INTERNAL_ERROR"); + assert.strictEqual(json.message, "Something failed"); + assert.strictEqual(json.statusCode, 500); +}); + +test("GuildPassError - defaults when instantiated without options", () => { + const err = new GuildPassError(); + assert.strictEqual(err instanceof GuildPassError, true); + assert.strictEqual(err.code, ErrorCodes.INTERNAL_ERROR); + assert.ok(err.message.length > 0); +}); + +test("Typed error subclasses - instanceof and code checks for every subclass", () => { + const testCases = [ + { + name: "NetworkError", + instance: new NetworkError("DNS resolution failed"), + expectedClass: NetworkError, + expectedCode: ErrorCodes.NETWORK_ERROR, + }, + { + name: "NetworkError with Error cause", + instance: new NetworkError(new TypeError("fetch failed")), + expectedClass: NetworkError, + expectedCode: ErrorCodes.NETWORK_ERROR, + }, + { + name: "TimeoutError", + instance: new TimeoutError(5000), + expectedClass: TimeoutError, + expectedCode: ErrorCodes.TIMEOUT_ERROR, + }, + { + name: "TimeoutError with custom message", + instance: new TimeoutError("Custom timeout message", { timeoutMs: 3000 }), + expectedClass: TimeoutError, + expectedCode: ErrorCodes.TIMEOUT_ERROR, + }, + { + name: "UpstreamError", + instance: new UpstreamError(503, "Service Unavailable"), + expectedClass: UpstreamError, + expectedCode: ErrorCodes.UPSTREAM_ERROR, + }, + { + name: "UpstreamError with message", + instance: new UpstreamError("Upstream gateway error", { status: 502 }), + expectedClass: UpstreamError, + expectedCode: ErrorCodes.UPSTREAM_ERROR, + }, + { + name: "CircuitOpenError", + instance: new CircuitOpenError(Date.now() + 30000), + expectedClass: CircuitOpenError, + expectedCode: ErrorCodes.CIRCUIT_OPEN, + }, + { + name: "InvalidConfigError", + instance: new InvalidConfigError("Missing API key"), + expectedClass: InvalidConfigError, + expectedCode: ErrorCodes.INVALID_CONFIG, + }, + { + name: "AccessDeniedError", + instance: new AccessDeniedError("Role insufficient"), + expectedClass: AccessDeniedError, + expectedCode: ErrorCodes.ACCESS_DENIED, + }, + { + name: "UnauthorizedError", + instance: new UnauthorizedError("Invalid token"), + expectedClass: UnauthorizedError, + expectedCode: ErrorCodes.UNAUTHORIZED, + }, + { + name: "ForbiddenError", + instance: new ForbiddenError("Forbidden resource"), + expectedClass: ForbiddenError, + expectedCode: ErrorCodes.FORBIDDEN, + }, + { + name: "NotFoundError", + instance: new NotFoundError("Guild not found"), + expectedClass: NotFoundError, + expectedCode: ErrorCodes.NOT_FOUND, + }, + { + name: "ValidationError", + instance: new ValidationError("Invalid payload", { + fields: [{ field: "name", message: "Name is required" }], + }), + expectedClass: ValidationError, + expectedCode: ErrorCodes.VALIDATION_ERROR, + }, + { + name: "RateLimitError", + instance: new RateLimitError("Too many requests", { retryAfter: 60 }), + expectedClass: RateLimitError, + expectedCode: ErrorCodes.RATE_LIMIT_EXCEEDED, + }, + { + name: "RateLimitExceededError alias", + instance: new RateLimitExceededError("Too many requests"), + expectedClass: RateLimitError, + expectedCode: ErrorCodes.RATE_LIMIT_EXCEEDED, + }, + { + name: "ConflictError", + instance: new ConflictError("Resource already exists"), + expectedClass: ConflictError, + expectedCode: ErrorCodes.CONFLICT, + }, + { + name: "InternalError", + instance: new InternalError("Unhandled database exception"), + expectedClass: InternalError, + expectedCode: ErrorCodes.INTERNAL_ERROR, + }, + { + name: "ContractError", + instance: new ContractError("Reverted in contract call"), + expectedClass: ContractError, + expectedCode: ErrorCodes.CONTRACT_ERROR, + }, + { + name: "InvalidSignatureError", + instance: new InvalidSignatureError("Signature mismatch"), + expectedClass: InvalidSignatureError, + expectedCode: ErrorCodes.INVALID_SIGNATURE, + }, + { + name: "BadRequestError", + instance: new BadRequestError("Invalid JSON input"), + expectedClass: BadRequestError, + expectedCode: ErrorCodes.BAD_REQUEST, + }, + { + name: "UnsupportedError", + instance: new UnsupportedError("Feature not enabled"), + expectedClass: UnsupportedError, + expectedCode: ErrorCodes.UNSUPPORTED, + }, + { + name: "MembershipNotFoundError", + instance: new MembershipNotFoundError("No membership found"), + expectedClass: MembershipNotFoundError, + expectedCode: ErrorCodes.MEMBERSHIP_NOT_FOUND, + }, + { + name: "GuildNotFoundError", + instance: new GuildNotFoundError("Guild 123 not found"), + expectedClass: GuildNotFoundError, + expectedCode: ErrorCodes.GUILD_NOT_FOUND, + }, + { + name: "PassNotFoundError", + instance: new PassNotFoundError("Pass 456 not found"), + expectedClass: PassNotFoundError, + expectedCode: ErrorCodes.PASS_NOT_FOUND, + }, + { + name: "UnknownError", + instance: new UnknownError("Unknown problem"), + expectedClass: UnknownError, + expectedCode: ErrorCodes.UNKNOWN_ERROR, + }, + ]; + + for (const { name, instance, expectedClass, expectedCode } of testCases) { + assert.strictEqual( + instance instanceof Error, + true, + `${name} must be instanceof Error`, + ); + assert.strictEqual( + instance instanceof GuildPassError, + true, + `${name} must be instanceof GuildPassError`, + ); + assert.strictEqual( + instance instanceof expectedClass, + true, + `${name} must be instanceof its specific class`, + ); + assert.strictEqual( + instance.code, + expectedCode, + `${name} code must match ${expectedCode}`, + ); + } +}); + +test("Hierarchy inheritance - domain not found errors extend NotFoundError", () => { + const membershipErr = new MembershipNotFoundError(); + assert.strictEqual(membershipErr instanceof MembershipNotFoundError, true); + assert.strictEqual(membershipErr instanceof NotFoundError, true); + assert.strictEqual(membershipErr instanceof GuildPassError, true); + assert.strictEqual(membershipErr instanceof Error, true); + + const guildErr = new GuildNotFoundError(); + assert.strictEqual(guildErr instanceof GuildNotFoundError, true); + assert.strictEqual(guildErr instanceof NotFoundError, true); + assert.strictEqual(guildErr instanceof GuildPassError, true); + + const passErr = new PassNotFoundError(); + assert.strictEqual(passErr instanceof PassNotFoundError, true); + assert.strictEqual(passErr instanceof NotFoundError, true); + assert.strictEqual(passErr instanceof GuildPassError, true); +}); + +test("Pattern matching / catch block distinguishing", () => { + function throwsError(type) { + switch (type) { + case "network": + throw new NetworkError("Connection refused"); + case "config": + throw new InvalidConfigError("Missing baseUrl"); + case "access": + throw new AccessDeniedError("Insufficient permissions"); + case "timeout": + throw new TimeoutError(1000); + } + } + + try { + throwsError("network"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof NetworkError, true); + assert.strictEqual(err instanceof InvalidConfigError, false); + assert.strictEqual(err instanceof AccessDeniedError, false); + assert.strictEqual(err instanceof GuildPassError, true); + } + + try { + throwsError("config"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof InvalidConfigError, true); + assert.strictEqual(err instanceof NetworkError, false); + } + + try { + throwsError("access"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof AccessDeniedError, true); + assert.strictEqual(err instanceof ForbiddenError, false); + } + + try { + throwsError("timeout"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof TimeoutError, true); + if (err instanceof TimeoutError) { + assert.strictEqual(err.timeoutMs, 1000); + } + } +}); diff --git a/packages/integration-client/test/errors.test.ts b/packages/integration-client/test/errors.test.ts new file mode 100644 index 0000000..b941fd9 --- /dev/null +++ b/packages/integration-client/test/errors.test.ts @@ -0,0 +1,311 @@ +import test from "node:test"; +import assert from "node:assert"; +import { + GuildPassError, + ErrorCodes, + NetworkError, + TimeoutError, + UpstreamError, + CircuitOpenError, + InvalidConfigError, + AccessDeniedError, + UnauthorizedError, + ForbiddenError, + NotFoundError, + ValidationError, + RateLimitError, + RateLimitExceededError, + ConflictError, + InternalError, + ContractError, + InvalidSignatureError, + BadRequestError, + UnsupportedError, + MembershipNotFoundError, + GuildNotFoundError, + PassNotFoundError, + UnknownError, +} from "../src/index.js"; + +test("GuildPassError - base error class behavior", () => { + const err = new GuildPassError("Something failed", { + code: ErrorCodes.INTERNAL_ERROR, + statusCode: 500, + details: { reason: "Database unavailable" }, + }); + + assert.strictEqual(err instanceof Error, true); + assert.strictEqual(err instanceof GuildPassError, true); + assert.strictEqual(err.name, "GuildPassError"); + assert.strictEqual(err.message, "Something failed"); + assert.strictEqual(err.code, "INTERNAL_ERROR"); + assert.strictEqual(err.statusCode, 500); + assert.deepStrictEqual(err.details, { reason: "Database unavailable" }); + + const json = err.toJSON(); + assert.strictEqual(json.name, "GuildPassError"); + assert.strictEqual(json.code, "INTERNAL_ERROR"); + assert.strictEqual(json.message, "Something failed"); + assert.strictEqual(json.statusCode, 500); +}); + +test("GuildPassError - defaults when instantiated without options", () => { + const err = new GuildPassError(); + assert.strictEqual(err instanceof GuildPassError, true); + assert.strictEqual(err.code, ErrorCodes.INTERNAL_ERROR); + assert.ok(err.message.length > 0); +}); + +test("Typed error subclasses - instanceof and code checks for every subclass", () => { + const testCases: Array<{ + name: string; + instance: GuildPassError; + expectedClass: any; + expectedCode: string; + }> = [ + { + name: "NetworkError", + instance: new NetworkError("DNS resolution failed"), + expectedClass: NetworkError, + expectedCode: ErrorCodes.NETWORK_ERROR, + }, + { + name: "NetworkError with Error cause", + instance: new NetworkError(new TypeError("fetch failed")), + expectedClass: NetworkError, + expectedCode: ErrorCodes.NETWORK_ERROR, + }, + { + name: "TimeoutError", + instance: new TimeoutError(5000), + expectedClass: TimeoutError, + expectedCode: ErrorCodes.TIMEOUT_ERROR, + }, + { + name: "TimeoutError with custom message", + instance: new TimeoutError("Custom timeout message", { timeoutMs: 3000 }), + expectedClass: TimeoutError, + expectedCode: ErrorCodes.TIMEOUT_ERROR, + }, + { + name: "UpstreamError", + instance: new UpstreamError(503, "Service Unavailable"), + expectedClass: UpstreamError, + expectedCode: ErrorCodes.UPSTREAM_ERROR, + }, + { + name: "UpstreamError with message", + instance: new UpstreamError("Upstream gateway error", { status: 502 }), + expectedClass: UpstreamError, + expectedCode: ErrorCodes.UPSTREAM_ERROR, + }, + { + name: "CircuitOpenError", + instance: new CircuitOpenError(Date.now() + 30000), + expectedClass: CircuitOpenError, + expectedCode: ErrorCodes.CIRCUIT_OPEN, + }, + { + name: "InvalidConfigError", + instance: new InvalidConfigError("Missing API key"), + expectedClass: InvalidConfigError, + expectedCode: ErrorCodes.INVALID_CONFIG, + }, + { + name: "AccessDeniedError", + instance: new AccessDeniedError("Role insufficient"), + expectedClass: AccessDeniedError, + expectedCode: ErrorCodes.ACCESS_DENIED, + }, + { + name: "UnauthorizedError", + instance: new UnauthorizedError("Invalid token"), + expectedClass: UnauthorizedError, + expectedCode: ErrorCodes.UNAUTHORIZED, + }, + { + name: "ForbiddenError", + instance: new ForbiddenError("Forbidden resource"), + expectedClass: ForbiddenError, + expectedCode: ErrorCodes.FORBIDDEN, + }, + { + name: "NotFoundError", + instance: new NotFoundError("Guild not found"), + expectedClass: NotFoundError, + expectedCode: ErrorCodes.NOT_FOUND, + }, + { + name: "ValidationError", + instance: new ValidationError("Invalid payload", { + fields: [{ field: "name", message: "Name is required" }], + }), + expectedClass: ValidationError, + expectedCode: ErrorCodes.VALIDATION_ERROR, + }, + { + name: "RateLimitError", + instance: new RateLimitError("Too many requests", { retryAfter: 60 }), + expectedClass: RateLimitError, + expectedCode: ErrorCodes.RATE_LIMIT_EXCEEDED, + }, + { + name: "RateLimitExceededError alias", + instance: new RateLimitExceededError("Too many requests"), + expectedClass: RateLimitError, + expectedCode: ErrorCodes.RATE_LIMIT_EXCEEDED, + }, + { + name: "ConflictError", + instance: new ConflictError("Resource already exists"), + expectedClass: ConflictError, + expectedCode: ErrorCodes.CONFLICT, + }, + { + name: "InternalError", + instance: new InternalError("Unhandled database exception"), + expectedClass: InternalError, + expectedCode: ErrorCodes.INTERNAL_ERROR, + }, + { + name: "ContractError", + instance: new ContractError("Reverted in contract call"), + expectedClass: ContractError, + expectedCode: ErrorCodes.CONTRACT_ERROR, + }, + { + name: "InvalidSignatureError", + instance: new InvalidSignatureError("Signature mismatch"), + expectedClass: InvalidSignatureError, + expectedCode: ErrorCodes.INVALID_SIGNATURE, + }, + { + name: "BadRequestError", + instance: new BadRequestError("Invalid JSON input"), + expectedClass: BadRequestError, + expectedCode: ErrorCodes.BAD_REQUEST, + }, + { + name: "UnsupportedError", + instance: new UnsupportedError("Feature not enabled"), + expectedClass: UnsupportedError, + expectedCode: ErrorCodes.UNSUPPORTED, + }, + { + name: "MembershipNotFoundError", + instance: new MembershipNotFoundError("No membership found"), + expectedClass: MembershipNotFoundError, + expectedCode: ErrorCodes.MEMBERSHIP_NOT_FOUND, + }, + { + name: "GuildNotFoundError", + instance: new GuildNotFoundError("Guild 123 not found"), + expectedClass: GuildNotFoundError, + expectedCode: ErrorCodes.GUILD_NOT_FOUND, + }, + { + name: "PassNotFoundError", + instance: new PassNotFoundError("Pass 456 not found"), + expectedClass: PassNotFoundError, + expectedCode: ErrorCodes.PASS_NOT_FOUND, + }, + { + name: "UnknownError", + instance: new UnknownError("Unknown problem"), + expectedClass: UnknownError, + expectedCode: ErrorCodes.UNKNOWN_ERROR, + }, + ]; + + for (const { name, instance, expectedClass, expectedCode } of testCases) { + assert.strictEqual( + instance instanceof Error, + true, + `${name} must be instanceof Error`, + ); + assert.strictEqual( + instance instanceof GuildPassError, + true, + `${name} must be instanceof GuildPassError`, + ); + assert.strictEqual( + instance instanceof expectedClass, + true, + `${name} must be instanceof its specific class`, + ); + assert.strictEqual( + instance.code, + expectedCode, + `${name} code must match ${expectedCode}`, + ); + } +}); + +test("Hierarchy inheritance - domain not found errors extend NotFoundError", () => { + const membershipErr = new MembershipNotFoundError(); + assert.strictEqual(membershipErr instanceof MembershipNotFoundError, true); + assert.strictEqual(membershipErr instanceof NotFoundError, true); + assert.strictEqual(membershipErr instanceof GuildPassError, true); + assert.strictEqual(membershipErr instanceof Error, true); + + const guildErr = new GuildNotFoundError(); + assert.strictEqual(guildErr instanceof GuildNotFoundError, true); + assert.strictEqual(guildErr instanceof NotFoundError, true); + assert.strictEqual(guildErr instanceof GuildPassError, true); + + const passErr = new PassNotFoundError(); + assert.strictEqual(passErr instanceof PassNotFoundError, true); + assert.strictEqual(passErr instanceof NotFoundError, true); + assert.strictEqual(passErr instanceof GuildPassError, true); +}); + +test("Pattern matching / catch block distinguishing", () => { + function throwsError(type: "network" | "config" | "access" | "timeout") { + switch (type) { + case "network": + throw new NetworkError("Connection refused"); + case "config": + throw new InvalidConfigError("Missing baseUrl"); + case "access": + throw new AccessDeniedError("Insufficient permissions"); + case "timeout": + throw new TimeoutError(1000); + } + } + + try { + throwsError("network"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof NetworkError, true); + assert.strictEqual(err instanceof InvalidConfigError, false); + assert.strictEqual(err instanceof AccessDeniedError, false); + assert.strictEqual(err instanceof GuildPassError, true); + } + + try { + throwsError("config"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof InvalidConfigError, true); + assert.strictEqual(err instanceof NetworkError, false); + } + + try { + throwsError("access"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof AccessDeniedError, true); + assert.strictEqual(err instanceof ForbiddenError, false); + } + + try { + throwsError("timeout"); + assert.fail("Should have thrown"); + } catch (err) { + assert.strictEqual(err instanceof TimeoutError, true); + if (err instanceof TimeoutError) { + assert.strictEqual(err.timeoutMs, 1000); + } + } +}); diff --git a/packages/integration-client/test/http.test.ts b/packages/integration-client/test/http.test.ts index 88da083..50c1146 100644 --- a/packages/integration-client/test/http.test.ts +++ b/packages/integration-client/test/http.test.ts @@ -23,7 +23,7 @@ test("HttpClient - timeout support", async () => { // Should fail if timeout is shorter than delay await assert.rejects( client.request("http://localhost", { timeout: 50 }), - (err: any) => err.message.includes("Timeout") + (err: any) => err.name === "TimeoutError" || err.message.toLowerCase().includes("timed out") ); }); @@ -97,8 +97,10 @@ test("HttpClient - explicit maxAttempts: 1 correctly overrides default", async ( // Provide explicit override const client = new HttpClient({ fetch: mockFetch as any, retry: { maxAttempts: 1 } }); - const res = await client.request("http://localhost"); - assert.strictEqual(res.status, 502); + await assert.rejects( + client.request("http://localhost"), + (err: any) => err.name === "UpstreamError" && err.status === 502 + ); assert.strictEqual(attempts, 1); }); diff --git a/src/errors/GuildPassError.ts b/src/errors/GuildPassError.ts new file mode 100644 index 0000000..bc5c612 --- /dev/null +++ b/src/errors/GuildPassError.ts @@ -0,0 +1 @@ +export * from "../packages/integration-client/src/errors/GuildPassError.js"; diff --git a/src/errors/error.types.ts b/src/errors/error.types.ts new file mode 100644 index 0000000..a94dbd0 --- /dev/null +++ b/src/errors/error.types.ts @@ -0,0 +1 @@ +export * from "../packages/integration-client/src/errors/error.types.js"; diff --git a/src/errors/errorCodes.ts b/src/errors/errorCodes.ts new file mode 100644 index 0000000..a4c9f88 --- /dev/null +++ b/src/errors/errorCodes.ts @@ -0,0 +1 @@ +export * from "../packages/integration-client/src/errors/errorCodes.js"; diff --git a/src/errors/errorTypes.ts b/src/errors/errorTypes.ts new file mode 100644 index 0000000..4eec23d --- /dev/null +++ b/src/errors/errorTypes.ts @@ -0,0 +1 @@ +export * from "../packages/integration-client/src/errors/errorTypes.js"; diff --git a/src/errors/index.ts b/src/errors/index.ts new file mode 100644 index 0000000..cb18116 --- /dev/null +++ b/src/errors/index.ts @@ -0,0 +1,4 @@ +export * from "./errorCodes.js"; +export * from "./error.types.js"; +export * from "./GuildPassError.js"; +export * from "./errorTypes.js";