Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions apps/desktop/src/main/calendar/google/account-routing.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { calendarBindings } from '@memry/db-schema/schema/calendar-bindings'
import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
import { calendarSources } from '@memry/db-schema/schema/calendar-sources'
import { createTestDataDb, type TestDatabaseResult, type TestDb } from '@tests/utils/test-db'

vi.mock('./oauth', () => ({
resolveDefaultGoogleAccountId: vi.fn((_db: unknown) => 'fallback-default@example.com')
}))

import { resolveTargetGoogleAccountId } from './account-routing'

const NOW = '2026-04-19T10:00:00.000Z'

function seedAccountAndCalendar(
db: TestDb,
accountId: string,
calendarRemoteId: string,
calendarSourceId: string
): void {
db.insert(calendarSources)
.values({
id: `google-account:${accountId}`,
provider: 'google',
kind: 'account',
accountId,
remoteId: accountId,
title: accountId,
timezone: 'UTC',
color: null,
isPrimary: false,
isSelected: false,
isMemryManaged: false,
syncStatus: 'ok',
metadata: null,
createdAt: NOW,
modifiedAt: NOW
})
.run()
db.insert(calendarSources)
.values({
id: calendarSourceId,
provider: 'google',
kind: 'calendar',
accountId,
remoteId: calendarRemoteId,
title: `${accountId}/${calendarRemoteId}`,
timezone: 'UTC',
color: null,
isPrimary: false,
isSelected: true,
isMemryManaged: false,
syncStatus: 'ok',
metadata: null,
createdAt: NOW,
modifiedAt: NOW
})
.run()
}

describe('resolveTargetGoogleAccountId (M6 T4)', () => {
let dbResult: TestDatabaseResult
let db: TestDb

beforeEach(() => {
dbResult = createTestDataDb()
db = dbResult.db
})

afterEach(() => {
dbResult.close()
})

it('routes through existingBinding.remoteCalendarId when present', () => {
// #given two account+calendar pairs; binding points at account B's calendar
seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A')
seedAccountAndCalendar(db, 'bob@example.com', 'cal-B', 'gcal:B')
const binding: typeof calendarBindings.$inferSelect = {
id: 'binding-1',
sourceType: 'event',
sourceId: 'evt-1',
provider: 'google',
remoteCalendarId: 'cal-B',
remoteEventId: 'remote-evt-1',
ownershipMode: 'memry_managed',
writebackMode: 'broad',
remoteVersion: null,
lastLocalSnapshot: null,
archivedAt: null,
clock: null,
syncedAt: null,
createdAt: NOW,
modifiedAt: NOW
}

// #when
const accountId = resolveTargetGoogleAccountId(
db,
{ sourceType: 'event', sourceId: 'evt-1' },
binding
)

// #then
expect(accountId).toBe('bob@example.com')
})

it('routes through event.targetCalendarId when no existing binding', () => {
seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A')
seedAccountAndCalendar(db, 'bob@example.com', 'cal-B', 'gcal:B')
db.insert(calendarEvents)
.values({
id: 'evt-2',
title: 'Routed event',
startAt: NOW,
endAt: null,
timezone: 'UTC',
isAllDay: false,
targetCalendarId: 'cal-A',
createdAt: NOW,
modifiedAt: NOW
})
.run()

const accountId = resolveTargetGoogleAccountId(
db,
{ sourceType: 'event', sourceId: 'evt-2' },
undefined
)

expect(accountId).toBe('alice@example.com')
})

it('falls back to default Google account when no calendar context resolves', () => {
seedAccountAndCalendar(db, 'alice@example.com', 'cal-A', 'gcal:A')

const accountId = resolveTargetGoogleAccountId(
db,
{ sourceType: 'task', sourceId: 'task-1' },
undefined
)

expect(accountId).toBe('fallback-default@example.com')
})

it('returns null when neither calendar context nor any default account exists', async () => {
const oauth = await import('./oauth')
vi.mocked(oauth.resolveDefaultGoogleAccountId).mockReturnValueOnce(null)

const accountId = resolveTargetGoogleAccountId(
db,
{ sourceType: 'task', sourceId: 'task-1' },
undefined
)

expect(accountId).toBeNull()
})
})
53 changes: 53 additions & 0 deletions apps/desktop/src/main/calendar/google/account-routing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { and, eq, isNull } from 'drizzle-orm'
import { calendarEvents } from '@memry/db-schema/schema/calendar-events'
import { calendarSources } from '@memry/db-schema/schema/calendar-sources'
import type { calendarBindings } from '@memry/db-schema/schema/calendar-bindings'
import type { DataDb } from '../../database/types'
import type { CalendarSyncTarget } from '../types'
import { resolveDefaultGoogleAccountId } from './oauth'

function findAccountIdForCalendarRemoteId(db: DataDb, remoteCalendarId: string): string | null {
const row = db
.select({ accountId: calendarSources.accountId })
.from(calendarSources)
.where(
and(
eq(calendarSources.provider, 'google'),
eq(calendarSources.kind, 'calendar'),
eq(calendarSources.remoteId, remoteCalendarId),
isNull(calendarSources.archivedAt)
)
)
.get()
return row?.accountId ?? null
}

function findEventTargetCalendarId(db: DataDb, eventId: string): string | null {
const row = db
.select({ targetCalendarId: calendarEvents.targetCalendarId })
.from(calendarEvents)
.where(eq(calendarEvents.id, eventId))
.get()
return row?.targetCalendarId ?? null
}

export function resolveTargetGoogleAccountId(
db: DataDb,
target: CalendarSyncTarget,
existingBinding: typeof calendarBindings.$inferSelect | undefined
): string | null {
if (existingBinding?.remoteCalendarId) {
const accountId = findAccountIdForCalendarRemoteId(db, existingBinding.remoteCalendarId)
if (accountId) return accountId
}

if (target.sourceType === 'event') {
const targetCalendarId = findEventTargetCalendarId(db, target.sourceId)
if (targetCalendarId) {
const accountId = findAccountIdForCalendarRemoteId(db, targetCalendarId)
if (accountId) return accountId
}
}

return resolveDefaultGoogleAccountId(db)
}
23 changes: 14 additions & 9 deletions apps/desktop/src/main/calendar/google/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,11 @@ vi.mock('../../lib/logger', () => ({
}))

import { createGoogleCalendarClient } from './client'
import { clearGoogleCalendarTokens, storeGoogleCalendarTokens } from './keychain'
import {
LEGACY_DEFAULT_ACCOUNT_ID,
clearGoogleCalendarTokens,
storeGoogleCalendarTokens
} from './keychain'

describe('google calendar client — push channels (Task 7)', () => {
const keytarStore = new Map<string, string>()
Expand All @@ -53,6 +57,7 @@ describe('google calendar client — push channels (Task 7)', () => {
})

await storeGoogleCalendarTokens({
accountId: LEGACY_DEFAULT_ACCOUNT_ID,
accessToken: 'seeded-access-token',
refreshToken: 'seeded-refresh-token'
})
Expand All @@ -61,7 +66,7 @@ describe('google calendar client — push channels (Task 7)', () => {
afterEach(async () => {
delete process.env.GOOGLE_CALENDAR_CLIENT_ID
vi.unstubAllGlobals()
await clearGoogleCalendarTokens()
await clearGoogleCalendarTokens(LEGACY_DEFAULT_ACCOUNT_ID)
})

describe('watchCalendar', () => {
Expand Down Expand Up @@ -98,7 +103,7 @@ describe('google calendar client — push channels (Task 7)', () => {
)
})

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
const result = await client.watchCalendar({
calendarId: 'primary@group.calendar.google.com',
channelId: 'channel-abc',
Expand All @@ -124,7 +129,7 @@ describe('google calendar client — push channels (Task 7)', () => {
)
)

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(
client.watchCalendar({
calendarId: 'primary',
Expand All @@ -148,7 +153,7 @@ describe('google calendar client — push channels (Task 7)', () => {
return new Response(null, { status: 204 })
})

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(
client.stopChannel({ channelId: 'channel-abc', resourceId: 'resource-123' })
).resolves.toBeUndefined()
Expand All @@ -162,7 +167,7 @@ describe('google calendar client — push channels (Task 7)', () => {
)
)

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(
client.stopChannel({ channelId: 'stale', resourceId: 'stale-resource' })
).resolves.toBeUndefined()
Expand All @@ -171,7 +176,7 @@ describe('google calendar client — push channels (Task 7)', () => {
it('throws for non-404 errors (e.g. 500)', async () => {
fetchMock.mockResolvedValue(new Response('oops', { status: 500 }))

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await expect(client.stopChannel({ channelId: 'c', resourceId: 'r' })).rejects.toThrow()
})
})
Expand Down Expand Up @@ -204,7 +209,7 @@ describe('google calendar client — push channels (Task 7)', () => {
it('#given an all-day recurring exception #when upserted #then emits originalStartTime as { date } (no dateTime)', async () => {
const captured = captureBody()

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await client.upsertEvent({
calendarId: 'primary',
eventId: null,
Expand Down Expand Up @@ -233,7 +238,7 @@ describe('google calendar client — push channels (Task 7)', () => {
it('#given a timed recurring exception #when upserted #then emits originalStartTime as { dateTime, timeZone }', async () => {
const captured = captureBody()

const client = createGoogleCalendarClient()
const client = createGoogleCalendarClient({ accountId: LEGACY_DEFAULT_ACCOUNT_ID })
await client.upsertEvent({
calendarId: 'primary',
eventId: null,
Expand Down
Loading
Loading