mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 09:01:19 +00:00
Add listActions() and executeAction() to FeedSource for write operations back to external services. Actions use arktype schemas for input validation via StandardSchemaV1. - ActionDefinition type with optional input schema - FeedEngine routes actions with existence and ID validation - Source IDs use reverse-domain format (aris.location, aris.tfl) - LocationSource: update-location action with schema validation - TflSource: set-lines-of-interest action with lineId validation - No-op implementations for sources without actions Co-authored-by: Ona <no-reply@ona.com>
297 lines
9.9 KiB
TypeScript
297 lines
9.9 KiB
TypeScript
import { contextValue, type Context } from "@aris/core"
|
|
import { describe, expect, test } from "bun:test"
|
|
|
|
import type { ApiCalendarEvent, GoogleCalendarClient, ListEventsOptions } from "./types"
|
|
|
|
import fixture from "../fixtures/events.json"
|
|
import { NextEventKey } from "./calendar-context"
|
|
import { CalendarFeedItemType } from "./feed-items"
|
|
import { GoogleCalendarSource } from "./google-calendar-source"
|
|
|
|
const NOW = new Date("2026-01-20T10:00:00Z")
|
|
|
|
function fixtureEvents(): ApiCalendarEvent[] {
|
|
return fixture.items as unknown as ApiCalendarEvent[]
|
|
}
|
|
|
|
function createMockClient(
|
|
eventsByCalendar: Record<string, ApiCalendarEvent[]>,
|
|
): GoogleCalendarClient {
|
|
return {
|
|
listCalendarIds: async () => Object.keys(eventsByCalendar),
|
|
listEvents: async (options: ListEventsOptions) => {
|
|
const events = eventsByCalendar[options.calendarId] ?? []
|
|
return events.filter((e) => {
|
|
const startRaw = e.start.dateTime ?? e.start.date ?? ""
|
|
const endRaw = e.end.dateTime ?? e.end.date ?? ""
|
|
return (
|
|
new Date(startRaw).getTime() < options.timeMax.getTime() &&
|
|
new Date(endRaw).getTime() > options.timeMin.getTime()
|
|
)
|
|
})
|
|
},
|
|
}
|
|
}
|
|
|
|
function defaultMockClient(): GoogleCalendarClient {
|
|
return createMockClient({ primary: fixtureEvents() })
|
|
}
|
|
|
|
function createContext(time?: Date): Context {
|
|
return { time: time ?? NOW }
|
|
}
|
|
|
|
describe("GoogleCalendarSource", () => {
|
|
describe("constructor", () => {
|
|
test("has correct id", () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
expect(source.id).toBe("aris.google-calendar")
|
|
})
|
|
})
|
|
|
|
describe("fetchItems", () => {
|
|
test("returns empty array when no events", async () => {
|
|
const source = new GoogleCalendarSource({
|
|
client: createMockClient({ primary: [] }),
|
|
})
|
|
const items = await source.fetchItems(createContext())
|
|
expect(items).toEqual([])
|
|
})
|
|
|
|
test("returns feed items for all events in window", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
expect(items.length).toBe(fixture.items.length)
|
|
})
|
|
|
|
test("assigns calendar-event type to timed events", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const timedItems = items.filter((i) => i.type === CalendarFeedItemType.event)
|
|
expect(timedItems.length).toBe(4)
|
|
})
|
|
|
|
test("assigns calendar-all-day type to all-day events", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const allDayItems = items.filter((i) => i.type === CalendarFeedItemType.allDay)
|
|
expect(allDayItems.length).toBe(1)
|
|
})
|
|
|
|
test("ongoing events get highest priority (1.0)", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")
|
|
expect(ongoing).toBeDefined()
|
|
expect(ongoing!.priority).toBe(1.0)
|
|
})
|
|
|
|
test("upcoming events get higher priority when sooner", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const soon = items.find((i) => i.data.eventId === "evt-soon")
|
|
const later = items.find((i) => i.data.eventId === "evt-later")
|
|
|
|
expect(soon).toBeDefined()
|
|
expect(later).toBeDefined()
|
|
expect(soon!.priority).toBeGreaterThan(later!.priority)
|
|
})
|
|
|
|
test("all-day events get flat priority (0.4)", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const allDay = items.find((i) => i.data.eventId === "evt-allday")
|
|
expect(allDay).toBeDefined()
|
|
expect(allDay!.priority).toBe(0.4)
|
|
})
|
|
|
|
test("generates unique IDs for each item", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const ids = items.map((i) => i.id)
|
|
const uniqueIds = new Set(ids)
|
|
expect(uniqueIds.size).toBe(ids.length)
|
|
})
|
|
|
|
test("sets timestamp from context.time", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
for (const item of items) {
|
|
expect(item.timestamp).toEqual(NOW)
|
|
}
|
|
})
|
|
|
|
test("respects lookaheadHours", async () => {
|
|
// Only 2 hours lookahead from 10:00 → events before 12:00
|
|
const source = new GoogleCalendarSource({
|
|
client: defaultMockClient(),
|
|
lookaheadHours: 2,
|
|
})
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
// Should include: ongoing (09:30-10:15), soon (10:10-10:40), allday (00:00-next day)
|
|
// Should exclude: later (14:00), tentative lunch (12:00)
|
|
const eventIds = items.map((i) => i.data.eventId)
|
|
expect(eventIds).toContain("evt-ongoing")
|
|
expect(eventIds).toContain("evt-soon")
|
|
expect(eventIds).toContain("evt-allday")
|
|
expect(eventIds).not.toContain("evt-later")
|
|
expect(eventIds).not.toContain("evt-tentative")
|
|
})
|
|
|
|
test("defaults to all user calendars via listCalendarIds", async () => {
|
|
const workEvent: ApiCalendarEvent = {
|
|
id: "evt-work",
|
|
status: "confirmed",
|
|
htmlLink: "https://calendar.google.com/event?eid=evt-work",
|
|
summary: "Work Meeting",
|
|
start: { dateTime: "2026-01-20T11:00:00Z" },
|
|
end: { dateTime: "2026-01-20T12:00:00Z" },
|
|
}
|
|
|
|
const client = createMockClient({
|
|
primary: fixtureEvents(),
|
|
"work@example.com": [workEvent],
|
|
})
|
|
|
|
// No calendarIds provided — should discover both calendars
|
|
const source = new GoogleCalendarSource({ client })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const eventIds = items.map((i) => i.data.eventId)
|
|
expect(eventIds).toContain("evt-work")
|
|
expect(eventIds).toContain("evt-ongoing")
|
|
})
|
|
|
|
test("fetches from explicit calendar IDs", async () => {
|
|
const workEvent: ApiCalendarEvent = {
|
|
id: "evt-work",
|
|
status: "confirmed",
|
|
htmlLink: "https://calendar.google.com/event?eid=evt-work",
|
|
summary: "Work Meeting",
|
|
start: { dateTime: "2026-01-20T11:00:00Z" },
|
|
end: { dateTime: "2026-01-20T12:00:00Z" },
|
|
}
|
|
|
|
const client = createMockClient({
|
|
primary: fixtureEvents(),
|
|
"work@example.com": [workEvent],
|
|
})
|
|
|
|
const source = new GoogleCalendarSource({
|
|
client,
|
|
calendarIds: ["primary", "work@example.com"],
|
|
})
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const eventIds = items.map((i) => i.data.eventId)
|
|
expect(eventIds).toContain("evt-work")
|
|
expect(eventIds).toContain("evt-ongoing")
|
|
})
|
|
})
|
|
|
|
describe("fetchContext", () => {
|
|
test("returns null when no events", async () => {
|
|
const source = new GoogleCalendarSource({
|
|
client: createMockClient({ primary: [] }),
|
|
})
|
|
const result = await source.fetchContext(createContext())
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("returns null when only all-day events", async () => {
|
|
const allDayOnly: ApiCalendarEvent[] = [
|
|
{
|
|
id: "evt-allday",
|
|
status: "confirmed",
|
|
htmlLink: "https://calendar.google.com/event?eid=evt-allday",
|
|
summary: "Holiday",
|
|
start: { date: "2026-01-20" },
|
|
end: { date: "2026-01-21" },
|
|
},
|
|
]
|
|
const source = new GoogleCalendarSource({
|
|
client: createMockClient({ primary: allDayOnly }),
|
|
})
|
|
const result = await source.fetchContext(createContext())
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
test("returns next upcoming timed event (not ongoing)", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const result = await source.fetchContext(createContext())
|
|
|
|
expect(result).not.toBeNull()
|
|
const nextEvent = contextValue(result! as Context, NextEventKey)
|
|
expect(nextEvent).toBeDefined()
|
|
// evt-soon starts at 10:10, which is the nearest future timed event
|
|
expect(nextEvent!.title).toBe("1:1 with Manager")
|
|
expect(nextEvent!.minutesUntilStart).toBe(10)
|
|
expect(nextEvent!.location).toBeNull()
|
|
})
|
|
|
|
test("includes location when available", async () => {
|
|
const events: ApiCalendarEvent[] = [
|
|
{
|
|
id: "evt-loc",
|
|
status: "confirmed",
|
|
htmlLink: "https://calendar.google.com/event?eid=evt-loc",
|
|
summary: "Offsite",
|
|
location: "123 Main St",
|
|
start: { dateTime: "2026-01-20T11:00:00Z" },
|
|
end: { dateTime: "2026-01-20T12:00:00Z" },
|
|
},
|
|
]
|
|
const source = new GoogleCalendarSource({
|
|
client: createMockClient({ primary: events }),
|
|
})
|
|
const result = await source.fetchContext(createContext())
|
|
|
|
expect(result).not.toBeNull()
|
|
const nextEvent = contextValue(result! as Context, NextEventKey)
|
|
expect(nextEvent).toBeDefined()
|
|
expect(nextEvent!.location).toBe("123 Main St")
|
|
})
|
|
|
|
test("skips ongoing events for next-event context", async () => {
|
|
const events: ApiCalendarEvent[] = [
|
|
{
|
|
id: "evt-now",
|
|
status: "confirmed",
|
|
htmlLink: "https://calendar.google.com/event?eid=evt-now",
|
|
summary: "Current Meeting",
|
|
start: { dateTime: "2026-01-20T09:30:00Z" },
|
|
end: { dateTime: "2026-01-20T10:30:00Z" },
|
|
},
|
|
]
|
|
const source = new GoogleCalendarSource({
|
|
client: createMockClient({ primary: events }),
|
|
})
|
|
const result = await source.fetchContext(createContext())
|
|
expect(result).toBeNull()
|
|
})
|
|
})
|
|
|
|
describe("priority ordering", () => {
|
|
test("ongoing > upcoming > all-day", async () => {
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
|
const items = await source.fetchItems(createContext())
|
|
|
|
const ongoing = items.find((i) => i.data.eventId === "evt-ongoing")!
|
|
const upcoming = items.find((i) => i.data.eventId === "evt-soon")!
|
|
const allDay = items.find((i) => i.data.eventId === "evt-allday")!
|
|
|
|
expect(ongoing.priority).toBeGreaterThan(upcoming.priority)
|
|
expect(upcoming.priority).toBeGreaterThan(allDay.priority)
|
|
})
|
|
})
|
|
})
|