feat: add meetings domain service
This commit is contained in:
@@ -0,0 +1,469 @@
|
||||
/**
|
||||
* Meetings Domain Service.
|
||||
* Provides functions to list, inspect, and deep-search OpenProject meetings and agenda items.
|
||||
*/
|
||||
|
||||
import type { OpenProjectClient } from "../client/api-client.ts";
|
||||
import { extractIdFromHref, extractRawText } from "../client/hal-parser.ts";
|
||||
import type { HalCollection, HalResource } from "../client/types.ts";
|
||||
import { resolveClient, resolveProjectId } from "./helper.ts";
|
||||
|
||||
export interface MeetingSummary {
|
||||
id: number;
|
||||
title: string;
|
||||
state: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
duration?: string;
|
||||
project: { id: number; name: string };
|
||||
location?: string;
|
||||
author?: { id: number; name: string };
|
||||
}
|
||||
|
||||
export interface AgendaItemOutcome {
|
||||
id: number;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface AgendaItem {
|
||||
id: number;
|
||||
title: string;
|
||||
durationInMinutes?: number;
|
||||
notes?: string;
|
||||
itemType: string;
|
||||
position: number;
|
||||
author?: { id: number; name: string };
|
||||
presenter?: { id: number; name: string };
|
||||
section?: { id: number; title: string };
|
||||
outcomes: AgendaItemOutcome[];
|
||||
}
|
||||
|
||||
export interface MeetingDetail extends MeetingSummary {
|
||||
template: boolean;
|
||||
notify: boolean;
|
||||
participants: Array<{ id: number; name: string }>;
|
||||
agendaItems?: AgendaItem[];
|
||||
sections?: Array<{ id: number; title: string; position: number }>;
|
||||
}
|
||||
|
||||
export interface MeetingSearchResult {
|
||||
meeting: MeetingSummary;
|
||||
matchType: "title" | "location" | "agenda_item";
|
||||
matchedAgendaItems?: Array<{
|
||||
id: number;
|
||||
title: string;
|
||||
snippet?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface PaginatedResult<T> {
|
||||
total: number;
|
||||
count: number;
|
||||
pageSize: number;
|
||||
offset: number;
|
||||
elements: T[];
|
||||
items?: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a raw HAL Meeting resource into a token-efficient MeetingSummary.
|
||||
*/
|
||||
export function normalizeMeetingSummary(resource: HalResource): MeetingSummary {
|
||||
const links = (resource._links ?? {}) as Record<string, { href?: string; title?: string } | undefined>;
|
||||
const selfLink = links.self;
|
||||
const projectLink = links.project;
|
||||
const authorLink = links.author;
|
||||
|
||||
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
|
||||
const embeddedProject = embedded.project as { id?: number; name?: string } | undefined;
|
||||
const embeddedAuthor = embedded.author as { id?: number; name?: string } | undefined;
|
||||
|
||||
const id = resource.id ?? extractIdFromHref(selfLink?.href) ?? 0;
|
||||
const title = String(resource.title ?? "");
|
||||
const state = String(resource.state ?? "");
|
||||
const startTime = String(resource.startTime ?? "");
|
||||
const endTime = String(resource.endTime ?? "");
|
||||
const duration = resource.duration ? String(resource.duration) : undefined;
|
||||
const location = resource.location ? String(resource.location) : undefined;
|
||||
|
||||
const projectId = extractIdFromHref(projectLink?.href) ?? embeddedProject?.id ?? 0;
|
||||
const projectName = projectLink?.title ?? embeddedProject?.name ?? "";
|
||||
|
||||
let author: { id: number; name: string } | undefined;
|
||||
const authorId = extractIdFromHref(authorLink?.href) ?? embeddedAuthor?.id;
|
||||
const authorName = authorLink?.title ?? embeddedAuthor?.name;
|
||||
if (authorId !== undefined || authorName !== undefined) {
|
||||
author = {
|
||||
id: authorId ?? 0,
|
||||
name: authorName ?? (authorId ? `User #${authorId}` : ""),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
state,
|
||||
startTime,
|
||||
endTime,
|
||||
duration,
|
||||
project: { id: projectId, name: projectName },
|
||||
location,
|
||||
author,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes an Agenda Item Outcome.
|
||||
*/
|
||||
export function normalizeOutcome(outcome: Record<string, unknown>): AgendaItemOutcome {
|
||||
const links = (outcome._links ?? {}) as Record<string, { href?: string } | undefined>;
|
||||
const id = (typeof outcome.id === "number" ? outcome.id : extractIdFromHref(links.self?.href)) ?? 0;
|
||||
const notes = extractRawText(outcome.notes ?? outcome.text ?? outcome.comment ?? "");
|
||||
return { id, notes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a raw HAL Agenda Item resource into an AgendaItem.
|
||||
*/
|
||||
export function normalizeAgendaItem(item: Record<string, unknown>): AgendaItem {
|
||||
const links = (item._links ?? {}) as Record<string, { href?: string; title?: string } | undefined>;
|
||||
const embedded = (item._embedded ?? {}) as Record<string, unknown>;
|
||||
|
||||
const id = (typeof item.id === "number" ? item.id : extractIdFromHref(links.self?.href)) ?? 0;
|
||||
const title = String(item.title ?? links.workPackage?.title ?? "");
|
||||
const itemType = String(item.itemType ?? item.type ?? "simple");
|
||||
const position = typeof item.position === "number" ? item.position : 0;
|
||||
const durationInMinutes =
|
||||
typeof item.durationInMinutes === "number" ? item.durationInMinutes : undefined;
|
||||
const rawNotes = extractRawText(item.notes);
|
||||
const notes = rawNotes.length > 0 ? rawNotes : undefined;
|
||||
|
||||
let author: { id: number; name: string } | undefined;
|
||||
const authorLink = links.author;
|
||||
const authorId = extractIdFromHref(authorLink?.href);
|
||||
if (authorId !== undefined || authorLink?.title) {
|
||||
author = {
|
||||
id: authorId ?? 0,
|
||||
name: authorLink?.title ?? `User #${authorId}`,
|
||||
};
|
||||
}
|
||||
|
||||
let presenter: { id: number; name: string } | undefined;
|
||||
const presenterLink = links.presenter;
|
||||
const presenterId = extractIdFromHref(presenterLink?.href);
|
||||
if (presenterId !== undefined || presenterLink?.title) {
|
||||
presenter = {
|
||||
id: presenterId ?? 0,
|
||||
name: presenterLink?.title ?? `User #${presenterId}`,
|
||||
};
|
||||
}
|
||||
|
||||
let section: { id: number; title: string } | undefined;
|
||||
const sectionObj = (embedded.section ?? links.section) as
|
||||
| { id?: number; title?: string; href?: string; _links?: { self?: { href?: string } } }
|
||||
| undefined;
|
||||
if (sectionObj) {
|
||||
const sectionId =
|
||||
sectionObj.id ?? extractIdFromHref(sectionObj.href ?? sectionObj._links?.self?.href);
|
||||
if (sectionId !== undefined || sectionObj.title !== undefined) {
|
||||
section = {
|
||||
id: sectionId ?? 0,
|
||||
title: sectionObj.title ?? "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rawOutcomes = embedded.outcomes ?? links.outcomes ?? [];
|
||||
const outcomes: AgendaItemOutcome[] = Array.isArray(rawOutcomes)
|
||||
? rawOutcomes.map((o) => normalizeOutcome(o as Record<string, unknown>))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
durationInMinutes,
|
||||
notes,
|
||||
itemType,
|
||||
position,
|
||||
author,
|
||||
presenter,
|
||||
section,
|
||||
outcomes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a raw HAL Meeting resource and embedded agenda items into a MeetingDetail.
|
||||
*/
|
||||
export function normalizeMeetingDetail(
|
||||
resource: HalResource,
|
||||
agendaItems?: AgendaItem[]
|
||||
): MeetingDetail {
|
||||
const summary = normalizeMeetingSummary(resource);
|
||||
const links = (resource._links ?? {}) as Record<string, unknown>;
|
||||
const embedded = (resource._embedded ?? {}) as Record<string, unknown>;
|
||||
|
||||
const template = Boolean(resource.template);
|
||||
const notify = Boolean(resource.notify);
|
||||
|
||||
const rawParticipants = embedded.participants ?? links.participants ?? [];
|
||||
const participants: Array<{ id: number; name: string }> = [];
|
||||
if (Array.isArray(rawParticipants)) {
|
||||
for (const p of rawParticipants) {
|
||||
const pObj = p as { id?: number; href?: string; title?: string; name?: string; _links?: { self?: { href?: string } } };
|
||||
const pId = pObj.id ?? extractIdFromHref(pObj.href ?? pObj._links?.self?.href);
|
||||
const pName = pObj.title ?? pObj.name;
|
||||
if (pId !== undefined || pName !== undefined) {
|
||||
participants.push({
|
||||
id: pId ?? 0,
|
||||
name: pName ?? (pId ? `User #${pId}` : ""),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rawSections = embedded.sections ?? links.sections ?? [];
|
||||
let sections: Array<{ id: number; title: string; position: number }> | undefined;
|
||||
if (Array.isArray(rawSections) && rawSections.length > 0) {
|
||||
sections = (rawSections as Array<{ id?: number; href?: string; title?: string; position?: number; _links?: { self?: { href?: string } } }>).map(
|
||||
(s) => ({
|
||||
id: s.id ?? extractIdFromHref(s.href ?? s._links?.self?.href) ?? 0,
|
||||
title: s.title ?? "",
|
||||
position: typeof s.position === "number" ? s.position : 0,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
...summary,
|
||||
template,
|
||||
notify,
|
||||
participants,
|
||||
agendaItems,
|
||||
sections,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a concise text snippet centered around query match keyword.
|
||||
*/
|
||||
function extractSnippet(text: string, query: string, maxLength = 160): string {
|
||||
if (!text) return "";
|
||||
const lowerText = text.toLowerCase();
|
||||
const lowerQuery = query.toLowerCase();
|
||||
const idx = lowerText.indexOf(lowerQuery);
|
||||
if (idx === -1) {
|
||||
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}...`;
|
||||
}
|
||||
const start = Math.max(0, idx - 40);
|
||||
const end = Math.min(text.length, idx + query.length + 80);
|
||||
let snippet = text.slice(start, end).trim();
|
||||
if (start > 0) snippet = `...${snippet}`;
|
||||
if (end < text.length) snippet = `${snippet}...`;
|
||||
return snippet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists meetings visible to the user matching optional project, time, and pagination filters.
|
||||
*/
|
||||
export async function listMeetings(
|
||||
params?: {
|
||||
projectId?: string | number;
|
||||
time?: string;
|
||||
offset?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
client?: OpenProjectClient
|
||||
): Promise<PaginatedResult<MeetingSummary>> {
|
||||
const opClient = resolveClient(client);
|
||||
|
||||
const filters: Record<string, { operator: string; values: string[] }>[] = [];
|
||||
|
||||
if (params?.projectId !== undefined) {
|
||||
const resolvedId = await resolveProjectId(params.projectId, opClient);
|
||||
filters.push({
|
||||
project_id: {
|
||||
operator: "=",
|
||||
values: [String(resolvedId)],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (params?.time) {
|
||||
const timeLower = params.time.toLowerCase().trim();
|
||||
const operator = timeLower === "past" ? "past" : "upcoming";
|
||||
filters.push({
|
||||
time: {
|
||||
operator,
|
||||
values: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.pageSize !== undefined) {
|
||||
searchParams.set("pageSize", String(params.pageSize));
|
||||
}
|
||||
if (params?.offset !== undefined) {
|
||||
searchParams.set("offset", String(params.offset));
|
||||
}
|
||||
if (filters.length > 0) {
|
||||
searchParams.set("filters", JSON.stringify(filters));
|
||||
}
|
||||
|
||||
const queryStr = searchParams.toString();
|
||||
const path = `/api/v3/meetings${queryStr ? `?${queryStr}` : ""}`;
|
||||
|
||||
const response = await opClient.get<HalCollection<HalResource>>(path);
|
||||
const rawElements = response._embedded?.elements ?? [];
|
||||
const elements = rawElements.map(normalizeMeetingSummary);
|
||||
|
||||
return {
|
||||
total: response.total ?? elements.length,
|
||||
count: response.count ?? elements.length,
|
||||
pageSize: response.pageSize ?? elements.length,
|
||||
offset: response.offset ?? 1,
|
||||
elements,
|
||||
items: elements,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a single meeting by ID, optionally including its agenda items.
|
||||
*/
|
||||
export async function getMeeting(
|
||||
id: number,
|
||||
options?: { includeAgendaItems?: boolean },
|
||||
client?: OpenProjectClient
|
||||
): Promise<MeetingDetail> {
|
||||
const opClient = resolveClient(client);
|
||||
const includeAgenda = options?.includeAgendaItems ?? true;
|
||||
|
||||
const meetingPromise = opClient.get<HalResource>(`/api/v3/meetings/${id}`);
|
||||
|
||||
if (includeAgenda) {
|
||||
const [meetingResource, agendaResponse] = await Promise.all([
|
||||
meetingPromise,
|
||||
opClient
|
||||
.get<HalCollection<Record<string, unknown>>>(`/api/v3/meetings/${id}/agenda_items`)
|
||||
.catch(() => undefined),
|
||||
]);
|
||||
|
||||
let agendaItems: AgendaItem[] | undefined;
|
||||
if (agendaResponse?._embedded?.elements) {
|
||||
agendaItems = agendaResponse._embedded.elements.map(normalizeAgendaItem);
|
||||
} else {
|
||||
agendaItems = [];
|
||||
}
|
||||
|
||||
return normalizeMeetingDetail(meetingResource, agendaItems);
|
||||
}
|
||||
|
||||
const meetingResource = await meetingPromise;
|
||||
return normalizeMeetingDetail(meetingResource);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep searches across meetings by title, location, and agenda item notes.
|
||||
*/
|
||||
export async function searchMeetings(
|
||||
params: {
|
||||
query: string;
|
||||
projectId?: string | number;
|
||||
offset?: number;
|
||||
pageSize?: number;
|
||||
},
|
||||
client?: OpenProjectClient
|
||||
): Promise<PaginatedResult<MeetingSearchResult>> {
|
||||
const opClient = resolveClient(client);
|
||||
const needle = params.query.toLowerCase().trim();
|
||||
|
||||
// Fetch candidate meetings scoped to project if specified
|
||||
const meetingsResult = await listMeetings(
|
||||
{
|
||||
projectId: params.projectId,
|
||||
offset: params.offset ?? 1,
|
||||
pageSize: params.pageSize ?? 50,
|
||||
},
|
||||
opClient
|
||||
);
|
||||
|
||||
const candidateMeetings = meetingsResult.elements;
|
||||
|
||||
// Process candidate meetings concurrently
|
||||
const matchPromises = candidateMeetings.map(async (meeting) => {
|
||||
const titleMatches = meeting.title.toLowerCase().includes(needle);
|
||||
const locationMatches = Boolean(
|
||||
meeting.location && meeting.location.toLowerCase().includes(needle)
|
||||
);
|
||||
|
||||
// Concurrently fetch agenda items for candidate meeting
|
||||
const agendaResponse = await opClient
|
||||
.get<HalCollection<Record<string, unknown>>>(`/api/v3/meetings/${meeting.id}/agenda_items`)
|
||||
.catch(() => undefined);
|
||||
|
||||
const rawItems = agendaResponse?._embedded?.elements ?? [];
|
||||
const agendaItems = rawItems.map(normalizeAgendaItem);
|
||||
|
||||
const matchedAgendaItems: Array<{ id: number; title: string; snippet?: string }> = [];
|
||||
for (const item of agendaItems) {
|
||||
const itemTitleMatches = item.title.toLowerCase().includes(needle);
|
||||
const itemNotesMatches = Boolean(item.notes && item.notes.toLowerCase().includes(needle));
|
||||
const outcomeNotesMatch = item.outcomes.find((o) =>
|
||||
o.notes.toLowerCase().includes(needle)
|
||||
);
|
||||
|
||||
if (itemTitleMatches || itemNotesMatches || outcomeNotesMatch) {
|
||||
let snippetText = "";
|
||||
if (itemNotesMatches && item.notes) {
|
||||
snippetText = extractSnippet(item.notes, params.query);
|
||||
} else if (outcomeNotesMatch) {
|
||||
snippetText = extractSnippet(outcomeNotesMatch.notes, params.query);
|
||||
} else {
|
||||
snippetText = item.notes ? extractSnippet(item.notes, params.query) : item.title;
|
||||
}
|
||||
|
||||
matchedAgendaItems.push({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
snippet: snippetText || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let matchType: "title" | "location" | "agenda_item" | null = null;
|
||||
if (titleMatches) {
|
||||
matchType = "title";
|
||||
} else if (locationMatches) {
|
||||
matchType = "location";
|
||||
} else if (matchedAgendaItems.length > 0) {
|
||||
matchType = "agenda_item";
|
||||
}
|
||||
|
||||
if (!matchType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const searchResult: MeetingSearchResult = {
|
||||
meeting,
|
||||
matchType,
|
||||
matchedAgendaItems: matchedAgendaItems.length > 0 ? matchedAgendaItems : undefined,
|
||||
};
|
||||
|
||||
return searchResult;
|
||||
});
|
||||
|
||||
const resolvedMatches = await Promise.all(matchPromises);
|
||||
const matchedElements: MeetingSearchResult[] = resolvedMatches.filter(
|
||||
(item): item is MeetingSearchResult => item !== null
|
||||
);
|
||||
|
||||
return {
|
||||
total: matchedElements.length,
|
||||
count: matchedElements.length,
|
||||
pageSize: params.pageSize ?? matchedElements.length,
|
||||
offset: params.offset ?? 1,
|
||||
elements: matchedElements,
|
||||
items: matchedElements,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { OpenProjectClient } from "../src/client/api-client.ts";
|
||||
import { runWithContext, type RequestContext } from "../src/context.ts";
|
||||
import { listMeetings, getMeeting, searchMeetings } from "../src/services/meetings.ts";
|
||||
|
||||
describe("Meetings Service", () => {
|
||||
test("listMeetings serializes project filter and parses meeting items", async () => {
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
expect(path).toContain("/api/v3/meetings");
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 1,
|
||||
count: 1,
|
||||
pageSize: 20,
|
||||
offset: 1,
|
||||
_embedded: {
|
||||
elements: [
|
||||
{
|
||||
_type: "Meeting",
|
||||
id: 2,
|
||||
title: "Weekly Planning",
|
||||
state: "open",
|
||||
startTime: "2026-09-08T00:22:53Z",
|
||||
endTime: "2026-09-08T01:22:53Z",
|
||||
duration: "PT1H",
|
||||
_links: {
|
||||
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
||||
author: { href: "/api/v3/users/4", title: "Admin" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
const res = await listMeetings({ projectId: 1 }, mockClient);
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.elements[0].title).toBe("Weekly Planning");
|
||||
expect(res.elements[0].project.name).toBe("Demo project");
|
||||
});
|
||||
|
||||
test("getMeeting fetches details and embeds agenda items", async () => {
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
if (path === "/api/v3/meetings/2") {
|
||||
return {
|
||||
_type: "Meeting",
|
||||
id: 2,
|
||||
title: "Weekly Planning",
|
||||
state: "open",
|
||||
startTime: "2026-09-08T00:22:53Z",
|
||||
endTime: "2026-09-08T01:22:53Z",
|
||||
template: false,
|
||||
notify: false,
|
||||
_links: {
|
||||
project: { href: "/api/v3/projects/1", title: "Demo project" },
|
||||
participants: [{ href: "/api/v3/users/4", title: "Admin" }]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (path === "/api/v3/meetings/2/agenda_items") {
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 1,
|
||||
_embedded: {
|
||||
elements: [
|
||||
{
|
||||
_type: "MeetingAgendaItem",
|
||||
id: 10,
|
||||
title: "Good news",
|
||||
position: 1,
|
||||
itemType: "simple",
|
||||
durationInMinutes: 5,
|
||||
notes: { raw: "What went well this week?" },
|
||||
_embedded: { outcomes: [] },
|
||||
_links: { outcomes: [] }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
const meeting = await getMeeting(2, { includeAgendaItems: true }, mockClient);
|
||||
expect(meeting.title).toBe("Weekly Planning");
|
||||
expect(meeting.agendaItems).toHaveLength(1);
|
||||
expect(meeting.agendaItems![0].title).toBe("Good news");
|
||||
expect(meeting.agendaItems![0].notes).toBe("What went well this week?");
|
||||
});
|
||||
|
||||
test("searchMeetings finds matches in agenda notes", async () => {
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
if (path.startsWith("/api/v3/meetings?")) {
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 1,
|
||||
_embedded: {
|
||||
elements: [
|
||||
{
|
||||
_type: "Meeting",
|
||||
id: 2,
|
||||
title: "Weekly Planning",
|
||||
state: "open",
|
||||
startTime: "2026-09-08T00:22:53Z",
|
||||
endTime: "2026-09-08T01:22:53Z",
|
||||
_links: {
|
||||
project: { href: "/api/v3/projects/1", title: "Demo project" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (path === "/api/v3/meetings/2/agenda_items") {
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 1,
|
||||
_embedded: {
|
||||
elements: [
|
||||
{
|
||||
_type: "MeetingAgendaItem",
|
||||
id: 10,
|
||||
title: "Sprint Review",
|
||||
notes: { raw: "Discussed critical architecture improvements." }
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
const res = await searchMeetings({ query: "architecture" }, mockClient);
|
||||
expect(res.total).toBe(1);
|
||||
expect(res.elements[0].matchType).toBe("agenda_item");
|
||||
expect(res.elements[0].matchedAgendaItems![0].snippet).toContain("architecture");
|
||||
});
|
||||
|
||||
test("searchMeetings matches by title and location without calling agenda items", async () => {
|
||||
let agendaItemsCalled = false;
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
if (path.startsWith("/api/v3/meetings?")) {
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 2,
|
||||
_embedded: {
|
||||
elements: [
|
||||
{
|
||||
_type: "Meeting",
|
||||
id: 20,
|
||||
title: "Sprint Retrospective",
|
||||
state: "closed",
|
||||
startTime: "2026-09-01T10:00:00Z",
|
||||
endTime: "2026-09-01T11:00:00Z",
|
||||
location: "Room A",
|
||||
_links: {
|
||||
project: { href: "/api/v3/projects/1", title: "Demo" }
|
||||
}
|
||||
},
|
||||
{
|
||||
_type: "Meeting",
|
||||
id: 21,
|
||||
title: "Standup",
|
||||
state: "open",
|
||||
startTime: "2026-09-02T10:00:00Z",
|
||||
endTime: "2026-09-02T10:15:00Z",
|
||||
location: "Virtual Stage",
|
||||
_links: {
|
||||
project: { href: "/api/v3/projects/1", title: "Demo" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
}
|
||||
if (path.includes("/agenda_items")) {
|
||||
agendaItemsCalled = true;
|
||||
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
const titleRes = await searchMeetings({ query: "Retrospective" }, mockClient);
|
||||
expect(titleRes.total).toBe(1);
|
||||
expect(titleRes.elements[0].matchType).toBe("title");
|
||||
expect(titleRes.elements[0].meeting.id).toBe(20);
|
||||
|
||||
const locRes = await searchMeetings({ query: "Virtual" }, mockClient);
|
||||
expect(locRes.total).toBe(1);
|
||||
expect(locRes.elements[0].matchType).toBe("location");
|
||||
expect(locRes.elements[0].meeting.id).toBe(21);
|
||||
});
|
||||
|
||||
test("listMeetings supports time filter (upcoming and past)", async () => {
|
||||
let capturedPath = "";
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
capturedPath = path;
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 0,
|
||||
count: 0,
|
||||
pageSize: 20,
|
||||
offset: 1,
|
||||
_embedded: { elements: [] }
|
||||
};
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
await listMeetings({ time: "upcoming" }, mockClient);
|
||||
expect(decodeURIComponent(capturedPath)).toContain('"time":{"operator":"upcoming","values":[]}');
|
||||
|
||||
await listMeetings({ time: "past" }, mockClient);
|
||||
expect(decodeURIComponent(capturedPath)).toContain('"time":{"operator":"past","values":[]}');
|
||||
});
|
||||
|
||||
test("listMeetings resolves project identifier string to ID", async () => {
|
||||
let capturedPath = "";
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
if (path === "projects/demo-project") {
|
||||
return { id: 77 };
|
||||
}
|
||||
capturedPath = path;
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 0,
|
||||
count: 0,
|
||||
pageSize: 20,
|
||||
offset: 1,
|
||||
_embedded: { elements: [] }
|
||||
};
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
await listMeetings({ projectId: "demo-project" }, mockClient);
|
||||
expect(decodeURIComponent(capturedPath)).toContain('"project_id":{"operator":"=","values":["77"]}');
|
||||
});
|
||||
|
||||
test("listMeetings resolves ambient RequestContext client when client is omitted", async () => {
|
||||
const ambientClient = {
|
||||
get: async (path: string) => {
|
||||
expect(path).toContain("/api/v3/meetings");
|
||||
return {
|
||||
_type: "Collection",
|
||||
total: 0,
|
||||
count: 0,
|
||||
pageSize: 20,
|
||||
offset: 1,
|
||||
_embedded: { elements: [] }
|
||||
};
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
const context: RequestContext = {
|
||||
client: ambientClient,
|
||||
isReadOnly: false
|
||||
};
|
||||
|
||||
await runWithContext(context, async () => {
|
||||
const res = await listMeetings();
|
||||
expect(res.total).toBe(0);
|
||||
expect(res.elements).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
test("getMeeting with includeAgendaItems=false does not request agenda items", async () => {
|
||||
let agendaCalled = false;
|
||||
const mockClient = {
|
||||
get: async (path: string) => {
|
||||
if (path === "/api/v3/meetings/5") {
|
||||
return {
|
||||
_type: "Meeting",
|
||||
id: 5,
|
||||
title: "Quick Sync",
|
||||
state: "open",
|
||||
startTime: "2026-09-08T00:00:00Z",
|
||||
endTime: "2026-09-08T00:30:00Z",
|
||||
template: true,
|
||||
notify: true,
|
||||
_links: {
|
||||
project: { href: "/api/v3/projects/1", title: "Demo" }
|
||||
}
|
||||
};
|
||||
}
|
||||
if (path.includes("/agenda_items")) {
|
||||
agendaCalled = true;
|
||||
return { _type: "Collection", total: 0, _embedded: { elements: [] } };
|
||||
}
|
||||
throw new Error(`Unexpected path: ${path}`);
|
||||
}
|
||||
} as unknown as OpenProjectClient;
|
||||
|
||||
const detail = await getMeeting(5, { includeAgendaItems: false }, mockClient);
|
||||
expect(detail.id).toBe(5);
|
||||
expect(detail.template).toBe(true);
|
||||
expect(detail.notify).toBe(true);
|
||||
expect(detail.agendaItems).toBeUndefined();
|
||||
expect(agendaCalled).toBe(false);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user