feat(client): implement OpenProject REST API v3 client layer and context scoping
This commit is contained in:
+7
-7
@@ -44,13 +44,13 @@
|
||||
|
||||
## 2. Next Up (Phase 1: Read/Browse Implementation)
|
||||
|
||||
- [ ] **Task 1: OpenProject REST API v3 Client Layer**
|
||||
- [ ] Implement `src/config/index.ts` to parse and validate `OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`, and `OPENPROJECT_READ_ONLY` (or `--read-only`) via Zod.
|
||||
- [ ] Implement `src/context.ts` providing `RequestContext` interface and `AsyncLocalStorage` scoping (`getRequestContext`, `runWithContext`) for concurrent multi-user safety.
|
||||
- [ ] Implement `src/client/api-client.ts` with native `fetch`, HTTP Basic Auth injection, error normalization, and rate/timeout handling.
|
||||
- [ ] Implement `src/client/hal-parser.ts` to unpack HAL+JSON (`_links`, `_embedded`) into clean, token-efficient JSON models.
|
||||
- [ ] Implement `src/client/filter-builder.ts` to translate LLM-friendly filter arguments into OpenProject's JSON filter syntax.
|
||||
- [ ] Add unit tests in `tests/client.test.ts` verifying authentication headers, response parsing, and error sanitization against live container.
|
||||
- [x] **Task 1: OpenProject REST API v3 Client Layer**
|
||||
- [x] Implement `src/config/index.ts` to parse and validate `OPENPROJECT_BASE_URL`, `OPENPROJECT_API_KEY`, and `OPENPROJECT_READ_ONLY` (or `--read-only`) via Zod.
|
||||
- [x] Implement `src/context.ts` providing `RequestContext` interface and `AsyncLocalStorage` scoping (`getRequestContext`, `runWithContext`) for concurrent multi-user safety.
|
||||
- [x] Implement `src/client/api-client.ts` with native `fetch`, HTTP Basic Auth injection, error normalization, and rate/timeout handling.
|
||||
- [x] Implement `src/client/hal-parser.ts` to unpack HAL+JSON (`_links`, `_embedded`) into clean, token-efficient JSON models.
|
||||
- [x] Implement `src/client/filter-builder.ts` to translate LLM-friendly filter arguments into OpenProject's JSON filter syntax.
|
||||
- [x] Add unit tests in `tests/client.test.ts` verifying authentication headers, response parsing, and error sanitization against live container.
|
||||
|
||||
- [ ] **Task 2: Domain Services**
|
||||
- [ ] Implement `src/services/projects.ts` (`listProjects`, `getProject`).
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* OpenProject REST API v3 HTTP Client.
|
||||
*/
|
||||
|
||||
import type { OpenProjectApiErrorPayload } from "./types.ts";
|
||||
|
||||
export interface ClientOptions {
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
timeoutMs?: number;
|
||||
fetchFn?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Error Hierarchy
|
||||
// =============================================================================
|
||||
|
||||
export class OpenProjectError extends Error {
|
||||
readonly statusCode?: number;
|
||||
readonly errorIdentifier?: string;
|
||||
readonly details?: unknown;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options?: {
|
||||
statusCode?: number;
|
||||
errorIdentifier?: string;
|
||||
details?: unknown;
|
||||
cause?: unknown;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
this.name = "OpenProjectError";
|
||||
this.statusCode = options?.statusCode;
|
||||
this.errorIdentifier = options?.errorIdentifier;
|
||||
this.details = options?.details;
|
||||
if (options?.cause) {
|
||||
this.cause = options.cause;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectAuthenticationError extends OpenProjectError {
|
||||
constructor(message = "Authentication failed. Please check your OpenProject API key.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: 401, ...options });
|
||||
this.name = "OpenProjectAuthenticationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectForbiddenError extends OpenProjectError {
|
||||
constructor(message = "Access forbidden. You do not have permission to access this resource.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: 403, ...options });
|
||||
this.name = "OpenProjectForbiddenError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectNotFoundError extends OpenProjectError {
|
||||
constructor(message = "Resource not found.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: 404, ...options });
|
||||
this.name = "OpenProjectNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectConflictError extends OpenProjectError {
|
||||
constructor(message = "Conflict updating resource. Lock version mismatch.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: 409, ...options });
|
||||
this.name = "OpenProjectConflictError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectValidationError extends OpenProjectError {
|
||||
constructor(message = "Validation failed for request data.", options?: { errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: 422, ...options });
|
||||
this.name = "OpenProjectValidationError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectRateLimitError extends OpenProjectError {
|
||||
readonly retryAfter?: number;
|
||||
|
||||
constructor(message = "Rate limit exceeded. Please wait before retrying.", options?: { retryAfter?: number; errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: 429, ...options });
|
||||
this.name = "OpenProjectRateLimitError";
|
||||
this.retryAfter = options?.retryAfter;
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectServerError extends OpenProjectError {
|
||||
constructor(message = "OpenProject internal server error.", options?: { statusCode?: number; errorIdentifier?: string; details?: unknown; cause?: unknown }) {
|
||||
super(message, { statusCode: options?.statusCode ?? 500, ...options });
|
||||
this.name = "OpenProjectServerError";
|
||||
}
|
||||
}
|
||||
|
||||
export class OpenProjectNetworkError extends OpenProjectError {
|
||||
constructor(message = "Network error connecting to OpenProject instance.", options?: { cause?: unknown }) {
|
||||
super(message, options);
|
||||
this.name = "OpenProjectNetworkError";
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// OpenProject Client Implementation
|
||||
// =============================================================================
|
||||
|
||||
export class OpenProjectClient {
|
||||
readonly baseUrl: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly timeoutMs: number;
|
||||
private readonly fetchFn: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
||||
private readonly authHeader: string;
|
||||
|
||||
constructor(options: ClientOptions) {
|
||||
this.baseUrl = options.baseUrl.replace(/\/+$/, "");
|
||||
this.apiKey = options.apiKey;
|
||||
this.timeoutMs = options.timeoutMs ?? 30000;
|
||||
this.fetchFn = options.fetchFn ?? fetch;
|
||||
this.authHeader = `Basic ${btoa(`apikey:${this.apiKey}`)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an HTTP GET request to OpenProject API v3.
|
||||
*/
|
||||
async get<T>(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
): Promise<T> {
|
||||
const url = this.resolveUrl(path, query);
|
||||
return this.request<T>(url, { method: "GET" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an HTTP POST request to OpenProject API v3.
|
||||
*/
|
||||
async post<T>(path: string, body?: unknown): Promise<T> {
|
||||
const url = this.resolveUrl(path);
|
||||
return this.request<T>(url, {
|
||||
method: "POST",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an HTTP PATCH request to OpenProject API v3.
|
||||
*/
|
||||
async patch<T>(path: string, body?: unknown): Promise<T> {
|
||||
const url = this.resolveUrl(path);
|
||||
return this.request<T>(url, {
|
||||
method: "PATCH",
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs an HTTP DELETE request to OpenProject API v3.
|
||||
*/
|
||||
async delete<T>(path: string): Promise<T> {
|
||||
const url = this.resolveUrl(path);
|
||||
return this.request<T>(url, { method: "DELETE" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a path or full URL against the client's baseUrl and appends query parameters.
|
||||
*/
|
||||
resolveUrl(
|
||||
path: string,
|
||||
query?: Record<string, string | number | boolean | undefined | null>
|
||||
): string {
|
||||
let resolved: string;
|
||||
|
||||
if (path.startsWith("http://") || path.startsWith("https://")) {
|
||||
resolved = path;
|
||||
} else {
|
||||
let normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
||||
if (!normalizedPath.startsWith("/api/v3")) {
|
||||
normalizedPath = `/api/v3${normalizedPath}`;
|
||||
}
|
||||
resolved = `${this.baseUrl}${normalizedPath}`;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
const url = new URL(resolved);
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, String(value));
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes strings (e.g. error messages) to ensure credentials are never leaked.
|
||||
*/
|
||||
private sanitize(text: string): string {
|
||||
if (!this.apiKey) {
|
||||
return text;
|
||||
}
|
||||
let sanitized = text.replaceAll(this.apiKey, "[REDACTED_API_KEY]");
|
||||
const base64Auth = btoa(`apikey:${this.apiKey}`);
|
||||
sanitized = sanitized.replaceAll(base64Auth, "[REDACTED_AUTH]");
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an HTTP request with timeout, authentication, and error normalization.
|
||||
*/
|
||||
private async request<T>(url: string, init: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Authorization", this.authHeader);
|
||||
headers.set("Accept", "application/hal+json, application/json");
|
||||
if (init.body && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
let signal = init.signal;
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
if (!signal) {
|
||||
if (typeof AbortSignal !== "undefined" && "timeout" in AbortSignal) {
|
||||
signal = AbortSignal.timeout(this.timeoutMs);
|
||||
} else {
|
||||
const controller = new AbortController();
|
||||
timeoutId = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
signal = controller.signal;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.fetchFn(url, {
|
||||
...init,
|
||||
headers,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return undefined as unknown as T;
|
||||
}
|
||||
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
const isJson = contentType.includes("application/json") || contentType.includes("application/hal+json");
|
||||
|
||||
if (!response.ok) {
|
||||
let errorPayload: OpenProjectApiErrorPayload | undefined;
|
||||
let rawErrorBody: string | undefined;
|
||||
|
||||
if (isJson) {
|
||||
try {
|
||||
errorPayload = (await response.json()) as OpenProjectApiErrorPayload;
|
||||
} catch {
|
||||
// Ignore JSON parse failure on error body
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
rawErrorBody = await response.text();
|
||||
} catch {
|
||||
// Ignore text parse failure
|
||||
}
|
||||
}
|
||||
|
||||
const message = errorPayload?.message
|
||||
? this.sanitize(errorPayload.message)
|
||||
: rawErrorBody
|
||||
? this.sanitize(rawErrorBody.slice(0, 300))
|
||||
: `HTTP ${response.status} ${response.statusText}`;
|
||||
|
||||
const errorIdentifier = errorPayload?.errorIdentifier;
|
||||
const details = errorPayload?.errors;
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
throw new OpenProjectAuthenticationError(message, { errorIdentifier, details });
|
||||
case 403:
|
||||
throw new OpenProjectForbiddenError(message, { errorIdentifier, details });
|
||||
case 404:
|
||||
throw new OpenProjectNotFoundError(message, { errorIdentifier, details });
|
||||
case 409:
|
||||
throw new OpenProjectConflictError(message, { errorIdentifier, details });
|
||||
case 422:
|
||||
throw new OpenProjectValidationError(message, { errorIdentifier, details });
|
||||
case 429: {
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfter = retryAfterHeader ? parseInt(retryAfterHeader, 10) : undefined;
|
||||
throw new OpenProjectRateLimitError(message, { retryAfter, errorIdentifier, details });
|
||||
}
|
||||
default:
|
||||
if (response.status >= 500) {
|
||||
throw new OpenProjectServerError(message, { statusCode: response.status, errorIdentifier, details });
|
||||
}
|
||||
throw new OpenProjectError(message, { statusCode: response.status, errorIdentifier, details });
|
||||
}
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
return (await response.text()) as unknown as T;
|
||||
} catch (error) {
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
if (error instanceof OpenProjectError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const isTimeout =
|
||||
(error as { name?: string })?.name === "TimeoutError" ||
|
||||
(error as { name?: string })?.name === "AbortError";
|
||||
|
||||
if (isTimeout) {
|
||||
throw new OpenProjectNetworkError(
|
||||
`Request to OpenProject timed out after ${this.timeoutMs}ms.`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
|
||||
const rawMsg = (error as Error)?.message ?? String(error);
|
||||
throw new OpenProjectNetworkError(
|
||||
`Failed to connect to OpenProject: ${this.sanitize(rawMsg)}`,
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Filter Builder for OpenProject REST API v3 JSON filter syntax.
|
||||
*/
|
||||
|
||||
import type {
|
||||
FilterElement,
|
||||
FilterOperator,
|
||||
WorkPackageFilterParams,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* Fluent builder for creating OpenProject API v3 filter arrays.
|
||||
*/
|
||||
export class FilterBuilder {
|
||||
private filters: FilterElement[] = [];
|
||||
|
||||
/**
|
||||
* Adds an arbitrary property filter.
|
||||
*/
|
||||
add(
|
||||
property: string,
|
||||
operator: FilterOperator,
|
||||
values: (string | number | boolean)[] = []
|
||||
): this {
|
||||
this.filters.push({
|
||||
[property]: {
|
||||
operator,
|
||||
values: values.map(String),
|
||||
},
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by project ID or identifier.
|
||||
*/
|
||||
project(projectId: number | string): this {
|
||||
return this.add("project", "=", [projectId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by work package status.
|
||||
* Accepts "open", "closed", or specific numeric/string status IDs.
|
||||
*/
|
||||
status(status: "open" | "closed" | string | number): this {
|
||||
if (status === "open") {
|
||||
return this.add("status", "o", []);
|
||||
}
|
||||
if (status === "closed") {
|
||||
return this.add("status", "c", []);
|
||||
}
|
||||
return this.add("status", "=", [status]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by work package type ID.
|
||||
*/
|
||||
type(typeId: number | string): this {
|
||||
return this.add("type", "=", [typeId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by assignee user ID or "me".
|
||||
*/
|
||||
assignee(assigneeId: number | string): this {
|
||||
return this.add("assignee", "=", [assigneeId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by author user ID.
|
||||
*/
|
||||
author(authorId: number | string): this {
|
||||
return this.add("author", "=", [authorId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by priority ID.
|
||||
*/
|
||||
priority(priorityId: number | string): this {
|
||||
return this.add("priority", "=", [priorityId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters by subject (substring match).
|
||||
*/
|
||||
subject(text: string): this {
|
||||
return this.add("subject", "~", [text]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the constructed filter array.
|
||||
*/
|
||||
build(): FilterElement[] {
|
||||
return [...this.filters];
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the filter array into JSON string expected by OpenProject API v3.
|
||||
*/
|
||||
toJSON(): string {
|
||||
return JSON.stringify(this.filters);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an OpenProject API v3 filter array from a WorkPackageFilterParams object.
|
||||
*/
|
||||
export function buildWorkPackageFilters(
|
||||
params: WorkPackageFilterParams
|
||||
): FilterElement[] {
|
||||
const builder = new FilterBuilder();
|
||||
|
||||
if (params.projectId !== undefined && params.projectId !== "") {
|
||||
builder.project(params.projectId);
|
||||
}
|
||||
|
||||
if (params.status !== undefined && params.status !== "") {
|
||||
builder.status(params.status);
|
||||
}
|
||||
|
||||
if (params.typeId !== undefined && params.typeId !== "") {
|
||||
builder.type(params.typeId);
|
||||
}
|
||||
|
||||
if (params.assigneeId !== undefined && params.assigneeId !== "") {
|
||||
builder.assignee(params.assigneeId);
|
||||
}
|
||||
|
||||
if (params.authorId !== undefined && params.authorId !== "") {
|
||||
builder.author(params.authorId);
|
||||
}
|
||||
|
||||
if (params.priorityId !== undefined && params.priorityId !== "") {
|
||||
builder.priority(params.priorityId);
|
||||
}
|
||||
|
||||
if (params.subject !== undefined && params.subject.trim() !== "") {
|
||||
builder.subject(params.subject.trim());
|
||||
}
|
||||
|
||||
if (params.customFilters && params.customFilters.length > 0) {
|
||||
for (const filter of params.customFilters) {
|
||||
for (const [prop, criteria] of Object.entries(filter)) {
|
||||
builder.add(prop, criteria.operator, criteria.values);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a filter array to a JSON string for use in query parameters.
|
||||
*/
|
||||
export function serializeFilters(filters: FilterElement[]): string {
|
||||
return JSON.stringify(filters);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* HAL+JSON Normalization and Parsing Utilities.
|
||||
* Unpacks nested HAL links and embedded data into token-efficient JSON models.
|
||||
*/
|
||||
|
||||
import type {
|
||||
HalCollection,
|
||||
HalLink,
|
||||
HalResource,
|
||||
PaginatedResult,
|
||||
PriorityItem,
|
||||
ProjectDetail,
|
||||
ProjectSummary,
|
||||
QueryDetail,
|
||||
QuerySummary,
|
||||
StatusItem,
|
||||
TypeItem,
|
||||
UserItem,
|
||||
WorkPackageDetail,
|
||||
WorkPackageSummary,
|
||||
} from "./types.ts";
|
||||
|
||||
/**
|
||||
* Extracts a numeric entity ID from an OpenProject HAL href URL.
|
||||
* e.g. "/api/v3/projects/4" -> 4
|
||||
*/
|
||||
export function extractIdFromHref(href?: string | null): number | undefined {
|
||||
if (!href) return undefined;
|
||||
const match = href.match(/\/(\d+)(?:[/?#]|$)/);
|
||||
if (match && match[1]) {
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a HAL link into its extracted ID, title, and href.
|
||||
*/
|
||||
export function resolveLink(
|
||||
link?: HalLink | null
|
||||
): { id?: number; title?: string; href?: string } | undefined {
|
||||
if (!link || !link.href) return undefined;
|
||||
return {
|
||||
id: extractIdFromHref(link.href),
|
||||
title: link.title,
|
||||
href: link.href,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts raw plain text from an OpenProject formatted text field.
|
||||
*/
|
||||
export function extractRawText(field: unknown): string {
|
||||
if (!field) return "";
|
||||
if (typeof field === "string") return field;
|
||||
if (typeof field === "object" && field !== null) {
|
||||
const obj = field as Record<string, unknown>;
|
||||
if (typeof obj.raw === "string") return obj.raw;
|
||||
if (typeof obj.html === "string") return obj.html;
|
||||
}
|
||||
return String(field);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unpacks a HAL Collection into a standard PaginatedResult using a transformer function.
|
||||
*/
|
||||
export function unpackCollection<TItem = HalResource, TResult = unknown>(
|
||||
collection: HalCollection<TItem>,
|
||||
itemTransformer: (item: TItem) => TResult
|
||||
): PaginatedResult<TResult> {
|
||||
const rawElements = collection._embedded?.elements ?? [];
|
||||
return {
|
||||
items: rawElements.map(itemTransformer),
|
||||
total: collection.total ?? rawElements.length,
|
||||
count: collection.count ?? rawElements.length,
|
||||
pageSize: collection.pageSize ?? rawElements.length,
|
||||
offset: collection.offset ?? 1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a project HAL resource into a token-efficient ProjectSummary.
|
||||
*/
|
||||
export function normalizeProjectSummary(resource: HalResource): ProjectSummary {
|
||||
const selfHref = (resource._links?.self as HalLink | undefined)?.href;
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref(selfHref) ?? 0,
|
||||
identifier: String(resource.identifier ?? ""),
|
||||
name: String(resource.name ?? ""),
|
||||
active: Boolean(resource.active),
|
||||
public: Boolean(resource.public),
|
||||
description: extractRawText(resource.description),
|
||||
createdAt: typeof resource.createdAt === "string" ? resource.createdAt : undefined,
|
||||
updatedAt: typeof resource.updatedAt === "string" ? resource.updatedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a project HAL resource into a complete ProjectDetail.
|
||||
*/
|
||||
export function normalizeProject(resource: HalResource): ProjectDetail {
|
||||
const summary = normalizeProjectSummary(resource);
|
||||
const parentLink = resource._links?.parent as HalLink | undefined;
|
||||
const statusLink = resource._links?.status as HalLink | undefined;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
parentId: extractIdFromHref(parentLink?.href) ?? null,
|
||||
parentName: parentLink?.title ?? null,
|
||||
status: statusLink?.title ?? (typeof resource.status === "string" ? resource.status : null),
|
||||
statusExplanation: extractRawText(resource.statusExplanation) || null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a work package HAL resource into a token-efficient WorkPackageSummary.
|
||||
*/
|
||||
export function normalizeWorkPackageSummary(resource: HalResource): WorkPackageSummary {
|
||||
const links = resource._links ?? {};
|
||||
const selfHref = (links.self as HalLink | undefined)?.href;
|
||||
const typeLink = links.type as HalLink | undefined;
|
||||
const statusLink = links.status as HalLink | undefined;
|
||||
const priorityLink = links.priority as HalLink | undefined;
|
||||
const projectLink = links.project as HalLink | undefined;
|
||||
const authorLink = links.author as HalLink | undefined;
|
||||
const assigneeLink = links.assignee as HalLink | undefined;
|
||||
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref(selfHref) ?? 0,
|
||||
subject: String(resource.subject ?? ""),
|
||||
type: typeLink?.title ?? "",
|
||||
typeId: extractIdFromHref(typeLink?.href),
|
||||
status: statusLink?.title ?? "",
|
||||
statusId: extractIdFromHref(statusLink?.href),
|
||||
priority: priorityLink?.title,
|
||||
priorityId: extractIdFromHref(priorityLink?.href),
|
||||
project: projectLink?.title ?? "",
|
||||
projectId: extractIdFromHref(projectLink?.href),
|
||||
author: authorLink?.title,
|
||||
authorId: extractIdFromHref(authorLink?.href),
|
||||
assignee: assigneeLink?.title,
|
||||
assigneeId: extractIdFromHref(assigneeLink?.href),
|
||||
startDate: typeof resource.startDate === "string" ? resource.startDate : null,
|
||||
dueDate: typeof resource.dueDate === "string" ? resource.dueDate : null,
|
||||
estimatedTime: typeof resource.estimatedTime === "string" ? resource.estimatedTime : null,
|
||||
spentTime: typeof resource.spentTime === "string" ? resource.spentTime : null,
|
||||
percentageDone: typeof resource.percentageDone === "number" ? resource.percentageDone : null,
|
||||
createdAt: typeof resource.createdAt === "string" ? resource.createdAt : undefined,
|
||||
updatedAt: typeof resource.updatedAt === "string" ? resource.updatedAt : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a work package HAL resource into a complete WorkPackageDetail.
|
||||
*/
|
||||
export function normalizeWorkPackage(resource: HalResource): WorkPackageDetail {
|
||||
const summary = normalizeWorkPackageSummary(resource);
|
||||
const links = resource._links ?? {};
|
||||
const parentLink = links.parent as HalLink | undefined;
|
||||
|
||||
let parent: { id: number; subject?: string } | null = null;
|
||||
if (parentLink?.href) {
|
||||
const parentId = extractIdFromHref(parentLink.href);
|
||||
if (parentId !== undefined) {
|
||||
parent = {
|
||||
id: parentId,
|
||||
subject: parentLink.title,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const children: Array<{ id: number; subject?: string }> = [];
|
||||
const rawChildren = links.children;
|
||||
if (Array.isArray(rawChildren)) {
|
||||
for (const child of rawChildren) {
|
||||
const childLink = child as HalLink;
|
||||
const childId = extractIdFromHref(childLink.href);
|
||||
if (childId !== undefined) {
|
||||
children.push({
|
||||
id: childId,
|
||||
subject: childLink.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract custom fields if present (customFieldX keys)
|
||||
const customFields: Record<string, unknown> = {};
|
||||
for (const [key, val] of Object.entries(resource)) {
|
||||
if (key.startsWith("customField")) {
|
||||
customFields[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
description: extractRawText(resource.description),
|
||||
parent,
|
||||
children: children.length > 0 ? children : undefined,
|
||||
lockVersion: typeof resource.lockVersion === "number" ? resource.lockVersion : 0,
|
||||
customFields: Object.keys(customFields).length > 0 ? customFields : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a query HAL resource into a QuerySummary.
|
||||
*/
|
||||
export function normalizeQuerySummary(resource: HalResource): QuerySummary {
|
||||
const selfHref = (resource._links?.self as HalLink | undefined)?.href;
|
||||
const projectLink = resource._links?.project as HalLink | undefined;
|
||||
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref(selfHref) ?? 0,
|
||||
name: String(resource.name ?? ""),
|
||||
public: Boolean(resource.public),
|
||||
starred: typeof resource.starred === "boolean" ? resource.starred : undefined,
|
||||
projectId: extractIdFromHref(projectLink?.href) ?? null,
|
||||
projectName: projectLink?.title ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a query HAL resource into a complete QueryDetail.
|
||||
*/
|
||||
export function normalizeQuery(resource: HalResource): QueryDetail {
|
||||
const summary = normalizeQuerySummary(resource);
|
||||
const links = resource._links ?? {};
|
||||
|
||||
// Extract columns
|
||||
const columns: string[] = [];
|
||||
const rawColumns = links.columns;
|
||||
if (Array.isArray(rawColumns)) {
|
||||
for (const col of rawColumns) {
|
||||
const link = col as HalLink;
|
||||
columns.push(link.title ?? link.href?.split("/").pop() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Extract sortBy
|
||||
const sortBy: Array<{ attribute: string; direction: "asc" | "desc" }> = [];
|
||||
const rawSortBy = links.sortBy;
|
||||
if (Array.isArray(rawSortBy)) {
|
||||
for (const item of rawSortBy) {
|
||||
const link = item as HalLink;
|
||||
const title = link.title ?? "";
|
||||
const isDesc = title.toLowerCase().includes("descending") || link.href?.endsWith("-desc");
|
||||
const attr = link.href?.split("/").pop()?.replace(/-asc|-desc/, "") ?? title;
|
||||
sortBy.push({ attribute: attr, direction: isDesc ? "desc" : "asc" });
|
||||
}
|
||||
}
|
||||
|
||||
// Extract filters
|
||||
const filters: Array<{ field: string; operator: string; values: string[] }> = [];
|
||||
if (Array.isArray(resource.filters)) {
|
||||
for (const f of resource.filters as Array<Record<string, unknown>>) {
|
||||
const fLinks = (f._links ?? {}) as Record<string, unknown>;
|
||||
const opLink = fLinks.operator as HalLink | undefined;
|
||||
const filterLink = fLinks.filter as HalLink | undefined;
|
||||
const field = filterLink?.title ?? String(f.name ?? "");
|
||||
const operator = opLink?.title ?? opLink?.href?.split("/").pop() ?? "";
|
||||
const values: string[] = [];
|
||||
if (Array.isArray(f.values)) {
|
||||
values.push(...f.values.map(String));
|
||||
}
|
||||
filters.push({ field, operator, values });
|
||||
}
|
||||
}
|
||||
|
||||
const resultsLink = links.results as HalLink | undefined;
|
||||
|
||||
return {
|
||||
...summary,
|
||||
columns,
|
||||
sortBy: sortBy.length > 0 ? sortBy : undefined,
|
||||
filters,
|
||||
resultsHref: resultsLink?.href ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Status HAL resource.
|
||||
*/
|
||||
export function normalizeStatus(resource: HalResource): StatusItem {
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0,
|
||||
name: String(resource.name ?? ""),
|
||||
isClosed: Boolean(resource.isClosed),
|
||||
isDefault: typeof resource.isDefault === "boolean" ? resource.isDefault : undefined,
|
||||
color: typeof resource.color === "string" ? resource.color : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Type HAL resource.
|
||||
*/
|
||||
export function normalizeType(resource: HalResource): TypeItem {
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0,
|
||||
name: String(resource.name ?? ""),
|
||||
isMilestone: Boolean(resource.isMilestone),
|
||||
isDefault: typeof resource.isDefault === "boolean" ? resource.isDefault : undefined,
|
||||
color: typeof resource.color === "string" ? resource.color : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a Priority HAL resource.
|
||||
*/
|
||||
export function normalizePriority(resource: HalResource): PriorityItem {
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0,
|
||||
name: String(resource.name ?? ""),
|
||||
isActive: typeof resource.isActive === "boolean" ? resource.isActive : true,
|
||||
isDefault: typeof resource.isDefault === "boolean" ? resource.isDefault : undefined,
|
||||
color: typeof resource.color === "string" ? resource.color : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a User HAL resource.
|
||||
*/
|
||||
export function normalizeUser(resource: HalResource): UserItem {
|
||||
return {
|
||||
id: resource.id ?? extractIdFromHref((resource._links?.self as HalLink | undefined)?.href) ?? 0,
|
||||
name: String(resource.name ?? ""),
|
||||
login: typeof resource.login === "string" ? resource.login : undefined,
|
||||
email: typeof resource.email === "string" ? resource.email : undefined,
|
||||
admin: typeof resource.admin === "boolean" ? resource.admin : undefined,
|
||||
status: typeof resource.status === "string" ? resource.status : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Low-level cleaner that strips operational action links and flattens
|
||||
* generic HAL resources for general token-efficient LLM consumption.
|
||||
*/
|
||||
export function unpackHalResource(resource: HalResource): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const operationalLinks = new Set([
|
||||
"self", "schema", "update", "updateImmediately", "delete", "createWorkPackage",
|
||||
"editWorkPackage", "createWorkPackageImmediate", "addAttachment", "addWatcher",
|
||||
"removeWatcher", "addRelation", "addChild", "changeParent", "addComment",
|
||||
"previewMarkup", "logTime", "move", "copy", "pdf", "generate_pdf", "atom",
|
||||
"configureForm", "star", "unstar", "watch", "unwatch", "favor", "unfavor",
|
||||
]);
|
||||
|
||||
for (const [key, value] of Object.entries(resource)) {
|
||||
if (key === "_links") {
|
||||
const links = value as Record<string, HalLink | HalLink[] | undefined>;
|
||||
const cleanedLinks: Record<string, unknown> = {};
|
||||
|
||||
for (const [linkRel, linkVal] of Object.entries(links)) {
|
||||
if (operationalLinks.has(linkRel) || !linkVal) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(linkVal)) {
|
||||
cleanedLinks[linkRel] = linkVal.map((l) => ({
|
||||
id: extractIdFromHref(l.href),
|
||||
title: l.title ?? undefined,
|
||||
}));
|
||||
} else if (linkVal.href) {
|
||||
cleanedLinks[linkRel] = {
|
||||
id: extractIdFromHref(linkVal.href),
|
||||
title: linkVal.title ?? undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(cleanedLinks).length > 0) {
|
||||
result.links = cleanedLinks;
|
||||
}
|
||||
} else if (key === "_embedded") {
|
||||
const embedded = value as Record<string, unknown>;
|
||||
result.embedded = embedded;
|
||||
} else if (key === "description" || key === "statusExplanation") {
|
||||
result[key] = extractRawText(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from "./types.ts";
|
||||
export * from "./api-client.ts";
|
||||
export * from "./hal-parser.ts";
|
||||
export * from "./filter-builder.ts";
|
||||
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* OpenProject REST API v3 Types and Domain Interfaces.
|
||||
*/
|
||||
|
||||
// =============================================================================
|
||||
// HAL+JSON Primitive Interfaces
|
||||
// =============================================================================
|
||||
|
||||
export interface HalLink {
|
||||
href: string | null;
|
||||
title?: string;
|
||||
templated?: boolean;
|
||||
method?: string;
|
||||
type?: string;
|
||||
payload?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type HalLinks = Record<string, HalLink | HalLink[] | undefined>;
|
||||
|
||||
export interface HalResource {
|
||||
_type: string;
|
||||
id?: number;
|
||||
_links?: HalLinks;
|
||||
_embedded?: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HalCollection<T = HalResource> extends HalResource {
|
||||
_type: "Collection" | "WorkPackageCollection" | string;
|
||||
total: number;
|
||||
count: number;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
_embedded: {
|
||||
elements: T[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OpenProjectApiErrorPayload {
|
||||
_type?: string;
|
||||
errorIdentifier?: string;
|
||||
message?: string;
|
||||
errors?: Record<string, unknown> | unknown[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Normalized Domain Models (Token-Efficient LLM Representations)
|
||||
// =============================================================================
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
count: number;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface ProjectSummary {
|
||||
id: number;
|
||||
identifier: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
public: boolean;
|
||||
description?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface ProjectDetail extends ProjectSummary {
|
||||
parentId?: number | null;
|
||||
parentName?: string | null;
|
||||
status?: string | null;
|
||||
statusExplanation?: string | null;
|
||||
}
|
||||
|
||||
export interface WorkPackageSummary {
|
||||
id: number;
|
||||
subject: string;
|
||||
type: string;
|
||||
typeId?: number;
|
||||
status: string;
|
||||
statusId?: number;
|
||||
priority?: string;
|
||||
priorityId?: number;
|
||||
project: string;
|
||||
projectId?: number;
|
||||
author?: string;
|
||||
authorId?: number;
|
||||
assignee?: string;
|
||||
assigneeId?: number;
|
||||
startDate?: string | null;
|
||||
dueDate?: string | null;
|
||||
estimatedTime?: string | null;
|
||||
spentTime?: string | null;
|
||||
percentageDone?: number | null;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface WorkPackageDetail extends WorkPackageSummary {
|
||||
description?: string;
|
||||
parent?: { id: number; subject?: string } | null;
|
||||
children?: Array<{ id: number; subject?: string }>;
|
||||
lockVersion: number;
|
||||
customFields?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface QuerySummary {
|
||||
id: number;
|
||||
name: string;
|
||||
public: boolean;
|
||||
starred?: boolean;
|
||||
projectId?: number | null;
|
||||
projectName?: string | null;
|
||||
}
|
||||
|
||||
export interface QueryDetail extends QuerySummary {
|
||||
columns: string[];
|
||||
sortBy?: Array<{ attribute: string; direction: "asc" | "desc" }>;
|
||||
filters: Array<{ field: string; operator: string; values: string[] }>;
|
||||
resultsHref?: string;
|
||||
}
|
||||
|
||||
export interface StatusItem {
|
||||
id: number;
|
||||
name: string;
|
||||
isClosed: boolean;
|
||||
isDefault?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface TypeItem {
|
||||
id: number;
|
||||
name: string;
|
||||
isMilestone: boolean;
|
||||
isDefault?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface PriorityItem {
|
||||
id: number;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
isDefault?: boolean;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface UserItem {
|
||||
id: number;
|
||||
name: string;
|
||||
login?: string;
|
||||
email?: string;
|
||||
admin?: boolean;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Filter Criteria Interfaces
|
||||
// =============================================================================
|
||||
|
||||
export type FilterOperator =
|
||||
| "="
|
||||
| "!"
|
||||
| "!="
|
||||
| "~"
|
||||
| "!~"
|
||||
| ">="
|
||||
| "<="
|
||||
| "o" // open
|
||||
| "c" // closed
|
||||
| "*" // any / all
|
||||
| "t" // today
|
||||
| "w" // this week
|
||||
| "nd" // next days
|
||||
| string;
|
||||
|
||||
export interface FilterElement {
|
||||
[property: string]: {
|
||||
operator: FilterOperator;
|
||||
values: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkPackageFilterParams {
|
||||
projectId?: number | string;
|
||||
status?: "open" | "closed" | string | number;
|
||||
typeId?: number | string;
|
||||
assigneeId?: number | string;
|
||||
authorId?: number | string;
|
||||
priorityId?: number | string;
|
||||
subject?: string;
|
||||
customFilters?: FilterElement[];
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for OpenProject MCP server configuration.
|
||||
*/
|
||||
export const configSchema = z.object({
|
||||
baseUrl: z
|
||||
.string({ required_error: "OPENPROJECT_BASE_URL is required" })
|
||||
.url("OPENPROJECT_BASE_URL must be a valid URL")
|
||||
.transform((val) => val.replace(/\/+$/, "")),
|
||||
apiKey: z
|
||||
.string({ required_error: "OPENPROJECT_API_KEY is required" })
|
||||
.min(1, "OPENPROJECT_API_KEY must not be empty"),
|
||||
readOnly: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof configSchema>;
|
||||
|
||||
/**
|
||||
* Parses and validates configuration from environment variables and CLI arguments.
|
||||
*
|
||||
* @param env - Environment variable map (defaults to process.env)
|
||||
* @param argv - CLI arguments array (defaults to process.argv)
|
||||
* @returns Validated AppConfig object
|
||||
*/
|
||||
export function parseConfig(
|
||||
env: Record<string, string | undefined> = process.env,
|
||||
argv: string[] = process.argv
|
||||
): AppConfig {
|
||||
const isReadOnlyArg = argv.includes("--read-only");
|
||||
const readOnlyEnv = env.OPENPROJECT_READ_ONLY?.trim().toLowerCase();
|
||||
const isReadOnly =
|
||||
isReadOnlyArg ||
|
||||
readOnlyEnv === "true" ||
|
||||
readOnlyEnv === "1" ||
|
||||
readOnlyEnv === "yes";
|
||||
|
||||
return configSchema.parse({
|
||||
baseUrl: env.OPENPROJECT_BASE_URL,
|
||||
apiKey: env.OPENPROJECT_API_KEY,
|
||||
readOnly: isReadOnly,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
|
||||
/**
|
||||
* Request Context and AsyncLocalStorage Scoping for Multi-User Safety.
|
||||
*/
|
||||
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import type { OpenProjectClient } from "./client/api-client.ts";
|
||||
|
||||
export interface RequestContext {
|
||||
client: OpenProjectClient;
|
||||
isReadOnly: boolean;
|
||||
sessionId?: string;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
const asyncLocalStorage = new AsyncLocalStorage<RequestContext>();
|
||||
|
||||
/**
|
||||
* Retrieves the current request context for the active asynchronous execution chain.
|
||||
* Throws an error if called outside of a runWithContext() scope.
|
||||
*/
|
||||
export function getRequestContext(): RequestContext {
|
||||
const context = asyncLocalStorage.getStore();
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"No active RequestContext found. Operations must be executed within runWithContext()."
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to retrieve the current request context, returning undefined if outside scope.
|
||||
*/
|
||||
export function tryGetRequestContext(): RequestContext | undefined {
|
||||
return asyncLocalStorage.getStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a function within the scope of a specific RequestContext.
|
||||
*
|
||||
* @param context - Request context holding the isolated OpenProject client and options
|
||||
* @param fn - Asynchronous or synchronous callback to execute
|
||||
*/
|
||||
export function runWithContext<T>(
|
||||
context: RequestContext,
|
||||
fn: () => T
|
||||
): T {
|
||||
return asyncLocalStorage.run(context, fn);
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { parseConfig } from "../src/config/index.ts";
|
||||
import {
|
||||
getRequestContext,
|
||||
runWithContext,
|
||||
tryGetRequestContext,
|
||||
type RequestContext,
|
||||
} from "../src/context.ts";
|
||||
import {
|
||||
OpenProjectClient,
|
||||
OpenProjectAuthenticationError,
|
||||
OpenProjectForbiddenError,
|
||||
OpenProjectNotFoundError,
|
||||
OpenProjectConflictError,
|
||||
OpenProjectValidationError,
|
||||
OpenProjectRateLimitError,
|
||||
OpenProjectServerError,
|
||||
} from "../src/client/api-client.ts";
|
||||
import {
|
||||
FilterBuilder,
|
||||
buildWorkPackageFilters,
|
||||
serializeFilters,
|
||||
} from "../src/client/filter-builder.ts";
|
||||
import {
|
||||
extractIdFromHref,
|
||||
resolveLink,
|
||||
extractRawText,
|
||||
normalizeProjectSummary,
|
||||
normalizeProject,
|
||||
normalizeWorkPackageSummary,
|
||||
normalizeWorkPackage,
|
||||
normalizeQuerySummary,
|
||||
normalizeQuery,
|
||||
normalizeStatus,
|
||||
normalizeType,
|
||||
normalizePriority,
|
||||
normalizeUser,
|
||||
unpackCollection,
|
||||
unpackHalResource,
|
||||
} from "../src/client/hal-parser.ts";
|
||||
import type { HalCollection, HalResource } from "../src/client/types.ts";
|
||||
|
||||
// =============================================================================
|
||||
// 1. Configuration Parsing & Validation
|
||||
// =============================================================================
|
||||
|
||||
describe("Configuration Parser", () => {
|
||||
test("parses valid configuration and trims trailing slashes", () => {
|
||||
const config = parseConfig(
|
||||
{
|
||||
OPENPROJECT_BASE_URL: "http://localhost:8080///",
|
||||
OPENPROJECT_API_KEY: "valid-test-key-12345",
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
expect(config.baseUrl).toBe("http://localhost:8080");
|
||||
expect(config.apiKey).toBe("valid-test-key-12345");
|
||||
expect(config.readOnly).toBe(false);
|
||||
});
|
||||
|
||||
test("enables readOnly mode from env OPENPROJECT_READ_ONLY", () => {
|
||||
const config = parseConfig(
|
||||
{
|
||||
OPENPROJECT_BASE_URL: "http://localhost:8080",
|
||||
OPENPROJECT_API_KEY: "secret",
|
||||
OPENPROJECT_READ_ONLY: "true",
|
||||
},
|
||||
[]
|
||||
);
|
||||
expect(config.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("enables readOnly mode from --read-only argv flag", () => {
|
||||
const config = parseConfig(
|
||||
{
|
||||
OPENPROJECT_BASE_URL: "http://localhost:8080",
|
||||
OPENPROJECT_API_KEY: "secret",
|
||||
},
|
||||
["node", "index.js", "--read-only"]
|
||||
);
|
||||
expect(config.readOnly).toBe(true);
|
||||
});
|
||||
|
||||
test("throws error when OPENPROJECT_BASE_URL is invalid", () => {
|
||||
expect(() =>
|
||||
parseConfig({
|
||||
OPENPROJECT_BASE_URL: "not-a-valid-url",
|
||||
OPENPROJECT_API_KEY: "secret",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
test("throws error when OPENPROJECT_API_KEY is missing or empty", () => {
|
||||
expect(() =>
|
||||
parseConfig({
|
||||
OPENPROJECT_BASE_URL: "http://localhost:8080",
|
||||
OPENPROJECT_API_KEY: "",
|
||||
})
|
||||
).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 2. Request Scoping & AsyncLocalStorage
|
||||
// =============================================================================
|
||||
|
||||
describe("RequestContext Scoping", () => {
|
||||
test("throws when getRequestContext() is called outside runWithContext", () => {
|
||||
expect(() => getRequestContext()).toThrow(/No active RequestContext found/);
|
||||
expect(tryGetRequestContext()).toBeUndefined();
|
||||
});
|
||||
|
||||
test("provides isolated context inside runWithContext", async () => {
|
||||
const clientA = new OpenProjectClient({
|
||||
baseUrl: "http://host-a",
|
||||
apiKey: "key-a",
|
||||
});
|
||||
const contextA: RequestContext = {
|
||||
client: clientA,
|
||||
isReadOnly: false,
|
||||
userId: "user-a",
|
||||
};
|
||||
|
||||
await runWithContext(contextA, async () => {
|
||||
const activeCtx = getRequestContext();
|
||||
expect(activeCtx.userId).toBe("user-a");
|
||||
expect(activeCtx.client.baseUrl).toBe("http://host-a");
|
||||
expect(activeCtx.isReadOnly).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
test("maintains isolated context across concurrent asynchronous tasks", async () => {
|
||||
const client1 = new OpenProjectClient({
|
||||
baseUrl: "http://localhost:8080",
|
||||
apiKey: "key-user-1",
|
||||
});
|
||||
const client2 = new OpenProjectClient({
|
||||
baseUrl: "http://localhost:8080",
|
||||
apiKey: "key-user-2",
|
||||
});
|
||||
|
||||
const ctx1: RequestContext = { client: client1, isReadOnly: false, userId: "user-1" };
|
||||
const ctx2: RequestContext = { client: client2, isReadOnly: true, userId: "user-2" };
|
||||
|
||||
const task1 = runWithContext(ctx1, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
const current = getRequestContext();
|
||||
expect(current.userId).toBe("user-1");
|
||||
expect(current.isReadOnly).toBe(false);
|
||||
return current.userId;
|
||||
});
|
||||
|
||||
const task2 = runWithContext(ctx2, async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
const current = getRequestContext();
|
||||
expect(current.userId).toBe("user-2");
|
||||
expect(current.isReadOnly).toBe(true);
|
||||
return current.userId;
|
||||
});
|
||||
|
||||
const results = await Promise.all([task1, task2]);
|
||||
expect(results).toEqual(["user-1", "user-2"]);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 3. Filter Builder
|
||||
// =============================================================================
|
||||
|
||||
describe("FilterBuilder", () => {
|
||||
test("builds individual filters correctly", () => {
|
||||
const builder = new FilterBuilder();
|
||||
builder.project(12).status("open").type(3);
|
||||
|
||||
const filters = builder.build();
|
||||
expect(filters).toEqual([
|
||||
{ project: { operator: "=", values: ["12"] } },
|
||||
{ status: { operator: "o", values: [] } },
|
||||
{ type: { operator: "=", values: ["3"] } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("handles closed and specific status IDs", () => {
|
||||
const builderClosed = new FilterBuilder().status("closed");
|
||||
expect(builderClosed.build()).toEqual([
|
||||
{ status: { operator: "c", values: [] } },
|
||||
]);
|
||||
|
||||
const builderSpecific = new FilterBuilder().status("7");
|
||||
expect(builderSpecific.build()).toEqual([
|
||||
{ status: { operator: "=", values: ["7"] } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("builds full query from WorkPackageFilterParams", () => {
|
||||
const filters = buildWorkPackageFilters({
|
||||
projectId: "mcp-test-project",
|
||||
status: "open",
|
||||
assigneeId: "me",
|
||||
priorityId: 2,
|
||||
subject: "bug in auth",
|
||||
});
|
||||
|
||||
expect(filters).toEqual([
|
||||
{ project: { operator: "=", values: ["mcp-test-project"] } },
|
||||
{ status: { operator: "o", values: [] } },
|
||||
{ assignee: { operator: "=", values: ["me"] } },
|
||||
{ priority: { operator: "=", values: ["2"] } },
|
||||
{ subject: { operator: "~", values: ["bug in auth"] } },
|
||||
]);
|
||||
|
||||
const serialized = serializeFilters(filters);
|
||||
expect(typeof serialized).toBe("string");
|
||||
expect(JSON.parse(serialized)).toEqual(filters);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 4. HAL Parser & Normalizers
|
||||
// =============================================================================
|
||||
|
||||
describe("HAL Normalizer", () => {
|
||||
test("extracts numeric IDs from URLs", () => {
|
||||
expect(extractIdFromHref("/api/v3/projects/42")).toBe(42);
|
||||
expect(extractIdFromHref("/api/v3/work_packages/123/relations")).toBe(123);
|
||||
expect(extractIdFromHref(null)).toBeUndefined();
|
||||
expect(extractIdFromHref("")).toBeUndefined();
|
||||
});
|
||||
|
||||
test("resolves links and text fields", () => {
|
||||
const link = { href: "/api/v3/types/5", title: "Bug" };
|
||||
expect(resolveLink(link)).toEqual({ id: 5, title: "Bug", href: "/api/v3/types/5" });
|
||||
expect(resolveLink(null)).toBeUndefined();
|
||||
|
||||
expect(extractRawText("plain string")).toBe("plain string");
|
||||
expect(extractRawText({ raw: "raw markdown", html: "<p>html</p>" })).toBe("raw markdown");
|
||||
expect(extractRawText({ html: "<p>html only</p>" })).toBe("<p>html only</p>");
|
||||
expect(extractRawText(null)).toBe("");
|
||||
});
|
||||
|
||||
test("normalizes Project HAL resource", () => {
|
||||
const rawProject: HalResource = {
|
||||
_type: "Project",
|
||||
id: 4,
|
||||
identifier: "test-proj",
|
||||
name: "Test Project",
|
||||
active: true,
|
||||
public: false,
|
||||
description: { raw: "Project description", html: "<p>Project description</p>" },
|
||||
statusExplanation: { raw: "All green", html: "" },
|
||||
_links: {
|
||||
self: { href: "/api/v3/projects/4", title: "Test Project" },
|
||||
parent: { href: "/api/v3/projects/1", title: "Parent Project" },
|
||||
status: { href: "/api/v3/project_statuses/2", title: "ON TRACK" },
|
||||
},
|
||||
};
|
||||
|
||||
const detail = normalizeProject(rawProject);
|
||||
expect(detail.id).toBe(4);
|
||||
expect(detail.identifier).toBe("test-proj");
|
||||
expect(detail.name).toBe("Test Project");
|
||||
expect(detail.active).toBe(true);
|
||||
expect(detail.public).toBe(false);
|
||||
expect(detail.description).toBe("Project description");
|
||||
expect(detail.parentId).toBe(1);
|
||||
expect(detail.parentName).toBe("Parent Project");
|
||||
expect(detail.status).toBe("ON TRACK");
|
||||
expect(detail.statusExplanation).toBe("All green");
|
||||
});
|
||||
|
||||
test("normalizes WorkPackage HAL resource", () => {
|
||||
const rawWp: HalResource = {
|
||||
_type: "WorkPackage",
|
||||
id: 55,
|
||||
subject: "Fix critical login loop",
|
||||
startDate: "2026-09-01",
|
||||
dueDate: "2026-09-10",
|
||||
lockVersion: 3,
|
||||
description: { raw: "Investigation needed." },
|
||||
_links: {
|
||||
self: { href: "/api/v3/work_packages/55" },
|
||||
type: { href: "/api/v3/types/2", title: "Bug" },
|
||||
status: { href: "/api/v3/statuses/1", title: "New" },
|
||||
priority: { href: "/api/v3/priorities/8", title: "High" },
|
||||
project: { href: "/api/v3/projects/4", title: "Test Project" },
|
||||
author: { href: "/api/v3/users/1", title: "Admin User" },
|
||||
assignee: { href: "/api/v3/users/2", title: "Developer" },
|
||||
parent: { href: "/api/v3/work_packages/50", title: "Parent Task" },
|
||||
children: [
|
||||
{ href: "/api/v3/work_packages/56", title: "Subtask 1" },
|
||||
{ href: "/api/v3/work_packages/57", title: "Subtask 2" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const detail = normalizeWorkPackage(rawWp);
|
||||
expect(detail.id).toBe(55);
|
||||
expect(detail.subject).toBe("Fix critical login loop");
|
||||
expect(detail.type).toBe("Bug");
|
||||
expect(detail.typeId).toBe(2);
|
||||
expect(detail.status).toBe("New");
|
||||
expect(detail.statusId).toBe(1);
|
||||
expect(detail.priority).toBe("High");
|
||||
expect(detail.priorityId).toBe(8);
|
||||
expect(detail.project).toBe("Test Project");
|
||||
expect(detail.projectId).toBe(4);
|
||||
expect(detail.author).toBe("Admin User");
|
||||
expect(detail.authorId).toBe(1);
|
||||
expect(detail.assignee).toBe("Developer");
|
||||
expect(detail.assigneeId).toBe(2);
|
||||
expect(detail.parent).toEqual({ id: 50, subject: "Parent Task" });
|
||||
expect(detail.children).toEqual([
|
||||
{ id: 56, subject: "Subtask 1" },
|
||||
{ id: 57, subject: "Subtask 2" },
|
||||
]);
|
||||
expect(detail.description).toBe("Investigation needed.");
|
||||
expect(detail.lockVersion).toBe(3);
|
||||
});
|
||||
|
||||
test("normalizes Query, Status, Type, Priority, and User", () => {
|
||||
const rawQuery: HalResource = {
|
||||
_type: "Query",
|
||||
id: 10,
|
||||
name: "My Tasks",
|
||||
public: true,
|
||||
starred: true,
|
||||
filters: [
|
||||
{
|
||||
_links: {
|
||||
filter: { title: "Status" },
|
||||
operator: { title: "open" },
|
||||
},
|
||||
values: [],
|
||||
},
|
||||
],
|
||||
_links: {
|
||||
self: { href: "/api/v3/queries/10" },
|
||||
project: { href: "/api/v3/projects/4", title: "Test Project" },
|
||||
columns: [
|
||||
{ href: "/api/v3/queries/columns/id", title: "ID" },
|
||||
{ href: "/api/v3/queries/columns/subject", title: "Subject" },
|
||||
],
|
||||
sortBy: [{ href: "/api/v3/queries/sort_bys/id-desc", title: "ID (descending)" }],
|
||||
},
|
||||
};
|
||||
|
||||
const query = normalizeQuery(rawQuery);
|
||||
expect(query.id).toBe(10);
|
||||
expect(query.name).toBe("My Tasks");
|
||||
expect(query.columns).toEqual(["ID", "Subject"]);
|
||||
expect(query.sortBy).toEqual([{ attribute: "id", direction: "desc" }]);
|
||||
expect(query.filters).toEqual([{ field: "Status", operator: "open", values: [] }]);
|
||||
|
||||
const status = normalizeStatus({ _type: "Status", id: 1, name: "New", isClosed: false });
|
||||
expect(status).toEqual({ id: 1, name: "New", isClosed: false, isDefault: undefined, color: undefined });
|
||||
|
||||
const type = normalizeType({ _type: "Type", id: 2, name: "Bug", isMilestone: false });
|
||||
expect(type).toEqual({ id: 2, name: "Bug", isMilestone: false, isDefault: undefined, color: undefined });
|
||||
|
||||
const priority = normalizePriority({ _type: "Priority", id: 8, name: "High", isActive: true });
|
||||
expect(priority).toEqual({ id: 8, name: "High", isActive: true, isDefault: undefined, color: undefined });
|
||||
|
||||
const user = normalizeUser({ _type: "User", id: 4, name: "Admin", login: "admin", email: "[email protected]", admin: true });
|
||||
expect(user).toEqual({ id: 4, name: "Admin", login: "admin", email: "[email protected]", admin: true, status: undefined });
|
||||
});
|
||||
|
||||
test("unpacks collection with pagination metadata", () => {
|
||||
const rawCollection: HalCollection = {
|
||||
_type: "Collection",
|
||||
total: 100,
|
||||
count: 2,
|
||||
pageSize: 2,
|
||||
offset: 1,
|
||||
_embedded: {
|
||||
elements: [
|
||||
{ _type: "Status", id: 1, name: "New", isClosed: false },
|
||||
{ _type: "Status", id: 2, name: "Closed", isClosed: true },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const unpacked = unpackCollection(rawCollection, normalizeStatus);
|
||||
expect(unpacked.total).toBe(100);
|
||||
expect(unpacked.count).toBe(2);
|
||||
expect(unpacked.pageSize).toBe(2);
|
||||
expect(unpacked.offset).toBe(1);
|
||||
expect(unpacked.items.length).toBe(2);
|
||||
expect(unpacked.items[0]?.name).toBe("New");
|
||||
expect(unpacked.items[1]?.name).toBe("Closed");
|
||||
});
|
||||
|
||||
test("unpackHalResource strips noisy operational links", () => {
|
||||
const raw: HalResource = {
|
||||
_type: "WorkPackage",
|
||||
id: 1,
|
||||
subject: "Test",
|
||||
_links: {
|
||||
self: { href: "/api/v3/work_packages/1" },
|
||||
update: { href: "/api/v3/work_packages/1/form", method: "post" },
|
||||
delete: { href: "/api/v3/work_packages/1", method: "delete" },
|
||||
schema: { href: "/api/v3/work_packages/schemas/1" },
|
||||
project: { href: "/api/v3/projects/4", title: "Project 4" },
|
||||
},
|
||||
};
|
||||
|
||||
const stripped = unpackHalResource(raw);
|
||||
expect(stripped._type).toBe("WorkPackage");
|
||||
expect(stripped.id).toBe(1);
|
||||
expect(stripped.subject).toBe("Test");
|
||||
expect(stripped.links).toEqual({
|
||||
project: { id: 4, title: "Project 4" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 5. OpenProjectClient HTTP & Error Sanitization (Unit Tests with Mock Fetch)
|
||||
// =============================================================================
|
||||
|
||||
describe("OpenProjectClient (Mocked Unit Tests)", () => {
|
||||
test("injects Basic Auth header and JSON Accept headers", async () => {
|
||||
let capturedHeaders: Headers | undefined;
|
||||
|
||||
const mockFetch = async (_url: string | URL | Request, init?: RequestInit): Promise<Response> => {
|
||||
capturedHeaders = new Headers(init?.headers);
|
||||
return new Response(JSON.stringify({ _type: "Project", id: 1, name: "P1" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/hal+json" },
|
||||
});
|
||||
};
|
||||
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: "http://example.com",
|
||||
apiKey: "super-secret-token",
|
||||
fetchFn: mockFetch,
|
||||
});
|
||||
|
||||
const result = await client.get<{ id: number }>("/api/v3/projects/1");
|
||||
expect(result.id).toBe(1);
|
||||
expect(capturedHeaders?.get("Authorization")).toBe(`Basic ${btoa("apikey:super-secret-token")}`);
|
||||
expect(capturedHeaders?.get("Accept")).toContain("application/hal+json");
|
||||
});
|
||||
|
||||
test("sanitizes API credentials from error messages", async () => {
|
||||
const secretKey = "super-secret-key-xyz-999";
|
||||
const mockFetch = async (): Promise<Response> => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
_type: "Error",
|
||||
errorIdentifier: "urn:openproject-org:api:v3:errors:Unauthenticated",
|
||||
message: `Authentication failed for key ${secretKey} and header Basic ${btoa("apikey:" + secretKey)}`,
|
||||
}),
|
||||
{ status: 401, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: "http://example.com",
|
||||
apiKey: secretKey,
|
||||
fetchFn: mockFetch,
|
||||
});
|
||||
|
||||
try {
|
||||
await client.get("/api/v3/projects");
|
||||
expect(true).toBe(false); // Should not reach
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(OpenProjectAuthenticationError);
|
||||
const msg = (err as Error).message;
|
||||
expect(msg).not.toContain(secretKey);
|
||||
expect(msg).toContain("[REDACTED_API_KEY]");
|
||||
expect(msg).toContain("[REDACTED_AUTH]");
|
||||
}
|
||||
});
|
||||
|
||||
test("maps HTTP status codes to appropriate error classes", async () => {
|
||||
const createMockClient = (status: number, body: unknown, headers?: Record<string, string>) => {
|
||||
const mockFetch = async (): Promise<Response> => {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json", ...headers },
|
||||
});
|
||||
};
|
||||
return new OpenProjectClient({
|
||||
baseUrl: "http://example.com",
|
||||
apiKey: "dummy-key",
|
||||
fetchFn: mockFetch,
|
||||
});
|
||||
};
|
||||
|
||||
// 403 Forbidden
|
||||
await expect(
|
||||
createMockClient(403, { message: "No permission" }).get("/res")
|
||||
).rejects.toBeInstanceOf(OpenProjectForbiddenError);
|
||||
|
||||
// 404 Not Found
|
||||
await expect(
|
||||
createMockClient(404, { message: "Item not found" }).get("/res")
|
||||
).rejects.toBeInstanceOf(OpenProjectNotFoundError);
|
||||
|
||||
// 409 Conflict
|
||||
await expect(
|
||||
createMockClient(409, { message: "Lock version mismatch" }).get("/res")
|
||||
).rejects.toBeInstanceOf(OpenProjectConflictError);
|
||||
|
||||
// 422 Validation
|
||||
await expect(
|
||||
createMockClient(422, { message: "Invalid attribute" }).get("/res")
|
||||
).rejects.toBeInstanceOf(OpenProjectValidationError);
|
||||
|
||||
// 429 Rate Limit
|
||||
try {
|
||||
await createMockClient(429, { message: "Slow down" }, { "retry-after": "60" }).get("/res");
|
||||
expect(true).toBe(false);
|
||||
} catch (e) {
|
||||
expect(e).toBeInstanceOf(OpenProjectRateLimitError);
|
||||
expect((e as OpenProjectRateLimitError).retryAfter).toBe(60);
|
||||
}
|
||||
|
||||
// 500 Server Error
|
||||
await expect(
|
||||
createMockClient(500, { message: "Server crashed" }).get("/res")
|
||||
).rejects.toBeInstanceOf(OpenProjectServerError);
|
||||
});
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 6. Live OpenProject Container Integration Tests
|
||||
// =============================================================================
|
||||
|
||||
describe("OpenProject Live API Integration (Local Container)", () => {
|
||||
const liveBaseUrl = process.env.OPENPROJECT_BASE_URL || "http://localhost:8080";
|
||||
const liveApiKey = process.env.OPENPROJECT_API_KEY;
|
||||
|
||||
test("fetches projects and work packages against live instance", async () => {
|
||||
if (!liveApiKey) {
|
||||
console.warn("Skipping live container test: OPENPROJECT_API_KEY not set");
|
||||
return;
|
||||
}
|
||||
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: liveBaseUrl,
|
||||
apiKey: liveApiKey,
|
||||
});
|
||||
|
||||
// 1. Fetch projects
|
||||
const projectsHal = await client.get<HalCollection>("/api/v3/projects");
|
||||
expect(projectsHal._type).toBe("Collection");
|
||||
const unpackedProjects = unpackCollection(projectsHal, normalizeProjectSummary);
|
||||
expect(unpackedProjects.items.length).toBeGreaterThan(0);
|
||||
|
||||
const testProj = unpackedProjects.items.find((p) => p.identifier === "mcp-test-project");
|
||||
expect(testProj).toBeDefined();
|
||||
expect(testProj?.name).toBe("MCP Test Project");
|
||||
|
||||
// 2. Fetch single project detail
|
||||
const projectDetailHal = await client.get<HalResource>(`/api/v3/projects/${testProj!.id}`);
|
||||
const projectDetail = normalizeProject(projectDetailHal);
|
||||
expect(projectDetail.identifier).toBe("mcp-test-project");
|
||||
|
||||
// 3. Fetch work packages with open status filter
|
||||
const openWpsHal = await client.get<HalCollection>("/api/v3/work_packages", {
|
||||
filters: serializeFilters(buildWorkPackageFilters({ status: "open" })),
|
||||
});
|
||||
expect(["Collection", "WorkPackageCollection"]).toContain(openWpsHal._type);
|
||||
const unpackedWps = unpackCollection(openWpsHal, normalizeWorkPackageSummary);
|
||||
expect(unpackedWps.items.length).toBeGreaterThan(0);
|
||||
|
||||
// 4. Fetch taxonomies (statuses, types, priorities)
|
||||
const statusesHal = await client.get<HalCollection>("/api/v3/statuses");
|
||||
const statuses = unpackCollection(statusesHal, normalizeStatus);
|
||||
expect(statuses.items.length).toBeGreaterThan(0);
|
||||
expect(statuses.items.some((s) => s.name === "New")).toBe(true);
|
||||
|
||||
const typesHal = await client.get<HalCollection>("/api/v3/types");
|
||||
const types = unpackCollection(typesHal, normalizeType);
|
||||
expect(types.items.length).toBeGreaterThan(0);
|
||||
expect(types.items.some((t) => t.name === "Task")).toBe(true);
|
||||
|
||||
const prioritiesHal = await client.get<HalCollection>("/api/v3/priorities");
|
||||
const priorities = unpackCollection(prioritiesHal, normalizePriority);
|
||||
expect(priorities.items.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("live 401 authentication failure with invalid key", async () => {
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: liveBaseUrl,
|
||||
apiKey: "definitely-invalid-key-9999",
|
||||
});
|
||||
|
||||
await expect(client.get("/api/v3/projects")).rejects.toBeInstanceOf(
|
||||
OpenProjectAuthenticationError
|
||||
);
|
||||
});
|
||||
|
||||
test("live 404 not found failure with non-existent project", async () => {
|
||||
if (!liveApiKey) return;
|
||||
|
||||
const client = new OpenProjectClient({
|
||||
baseUrl: liveBaseUrl,
|
||||
apiKey: liveApiKey,
|
||||
});
|
||||
|
||||
await expect(client.get("/api/v3/projects/9999999")).rejects.toBeInstanceOf(
|
||||
OpenProjectNotFoundError
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user