mirror of
https://github.com/kennethnym/aris.git
synced 2026-03-20 17:11:17 +00:00
291 lines
9.8 KiB
TypeScript
291 lines
9.8 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("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 empty when no events", async () => {
|
||
|
|
const source = new GoogleCalendarSource({ client: createMockClient({ primary: [] }) })
|
||
|
|
const result = await source.fetchContext(createContext())
|
||
|
|
expect(result).toEqual({})
|
||
|
|
})
|
||
|
|
|
||
|
|
test("returns empty 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).toEqual({})
|
||
|
|
})
|
||
|
|
|
||
|
|
test("returns next upcoming timed event (not ongoing)", async () => {
|
||
|
|
const source = new GoogleCalendarSource({ client: defaultMockClient() })
|
||
|
|
const result = await source.fetchContext(createContext())
|
||
|
|
|
||
|
|
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())
|
||
|
|
|
||
|
|
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).toEqual({})
|
||
|
|
})
|
||
|
|
})
|
||
|
|
|
||
|
|
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)
|
||
|
|
})
|
||
|
|
})
|
||
|
|
})
|