@@ -1437,12 +1437,12 @@ describe("cancellationWasSelfInitiatedFn", () => {
14371437 ) . toBe ( false ) ;
14381438 } ) ;
14391439
1440- it ( "does not conclude self-initiated from a sparse payload (no organizer or attendees)" , ( ) => {
1441- // Google only guarantees id/status/updated on a cancelled event. Absence of
1442- // attendees is "unknown", not "solo" — never suppress on missing data.
1440+ it ( "reports 'unknown' for a sparse payload (no organizer or attendees)" , ( ) => {
1441+ // Google only guarantees id/status on a cancelled event. Absence of
1442+ // attendees is "unknown", not "solo" — never conclude from missing data.
14431443 expect (
14441444 cancellationWasSelfInitiatedFn ( { id : "e" , status : "cancelled" } )
1445- ) . toBe ( false ) ;
1445+ ) . toBeNull ( ) ;
14461446 } ) ;
14471447} ) ;
14481448
@@ -1538,6 +1538,222 @@ describe("processCalendarEventsFn — self-initiated cancellations", () => {
15381538 } ) ;
15391539} ) ;
15401540
1541+ describe ( "processCalendarEventsFn — sparse cancelled occurrences" , ( ) => {
1542+ // The Calendar API only guarantees `id`, `recurringEventId` and
1543+ // `originalStartTime` on a cancelled exception, so the instance payload
1544+ // carries none of the organizer/attendee signal the self-initiated check
1545+ // needs. Whether the user could have cancelled the occurrence is a property
1546+ // of the SERIES, so it has to be resolved from the recurring master.
1547+ afterEach ( ( ) => {
1548+ vi . unstubAllGlobals ( ) ;
1549+ } ) ;
1550+
1551+ const calendarId = "user@example.com" ;
1552+ const futureStart = ( ) => new Date ( Date . now ( ) + 7 * 24 * 60 * 60 * 1000 ) ;
1553+
1554+ /** A cancelled exception with only the fields Google guarantees. */
1555+ function sparseCancelledInstance ( masterId : string , start : Date ) {
1556+ return {
1557+ id : `${ masterId } _${ start . toISOString ( ) } ` ,
1558+ recurringEventId : masterId ,
1559+ originalStartTime : { dateTime : start . toISOString ( ) } ,
1560+ status : "cancelled" as const ,
1561+ updated : new Date ( ) . toISOString ( ) ,
1562+ } ;
1563+ }
1564+
1565+ /**
1566+ * Stub fetch so `events/<id>` GETs resolve to `masters[id]` (or the given
1567+ * status when absent) and everything else returns an empty events page.
1568+ * Returns the mock so tests can assert on call counts.
1569+ */
1570+ function stubFetchWithMasters (
1571+ masters : Record < string , object > ,
1572+ missingStatus = 404
1573+ ) {
1574+ const fetchMock = vi . fn ( async ( url : string | URL ) => {
1575+ const href = typeof url === "string" ? url : url . toString ( ) ;
1576+ const match = href . match ( / \/ e v e n t s \/ ( [ ^ ? ] + ) / ) ;
1577+ if ( match ) {
1578+ const id = decodeURIComponent ( match [ 1 ] ) ;
1579+ const master = masters [ id ] ;
1580+ if ( ! master ) {
1581+ return new Response ( "not found" , { status : missingStatus } ) ;
1582+ }
1583+ return new Response ( JSON . stringify ( master ) , {
1584+ status : 200 ,
1585+ headers : { "Content-Type" : "application/json" } ,
1586+ } ) ;
1587+ }
1588+ return makeEventsResponse ( [ ] ) ;
1589+ } ) ;
1590+ vi . stubGlobal ( "fetch" , fetchMock ) ;
1591+ return fetchMock ;
1592+ }
1593+
1594+ function seedHost ( ) {
1595+ const host = makeFakeHost ( { calendarId } ) ;
1596+ host . store . set (
1597+ `first_sync_at_${ calendarId } ` ,
1598+ new Date ( Date . now ( ) - 30 * 24 * 60 * 60 * 1000 ) . toISOString ( )
1599+ ) ;
1600+ return host ;
1601+ }
1602+
1603+ it ( "marks the cancellation read when the recurring master is self-organized" , async ( ) => {
1604+ const host = seedHost ( ) ;
1605+ const start = futureStart ( ) ;
1606+ stubFetchWithMasters ( {
1607+ "master-self" : {
1608+ id : "master-self" ,
1609+ iCalUID : "series@google.com" ,
1610+ status : "confirmed" ,
1611+ summary : "Church" ,
1612+ organizer : { email : "user@example.com" , self : true } ,
1613+ } ,
1614+ } ) ;
1615+
1616+ await processCalendarEventsFn (
1617+ host ,
1618+ [ sparseCancelledInstance ( "master-self" , start ) ] ,
1619+ calendarId ,
1620+ false
1621+ ) ;
1622+
1623+ const saved = host . savedLinks . flat ( ) ;
1624+ expect ( saved ) . toHaveLength ( 1 ) ;
1625+ // Still annotates the series with the cancellation …
1626+ expect (
1627+ saved [ 0 ] . notes ?. some ( ( n ) =>
1628+ ( n as { key ?: string } ) . key ?. startsWith ( "cancellation-" )
1629+ )
1630+ ) . toBe ( true ) ;
1631+ // … but does not flip it unread for the person who cancelled it.
1632+ expect ( saved [ 0 ] . unread ) . toBe ( false ) ;
1633+ } ) ;
1634+
1635+ it ( "keeps the cancellation unread when someone else organizes the series" , async ( ) => {
1636+ const host = seedHost ( ) ;
1637+ const start = futureStart ( ) ;
1638+ stubFetchWithMasters ( {
1639+ "master-other" : {
1640+ id : "master-other" ,
1641+ iCalUID : "series@google.com" ,
1642+ status : "confirmed" ,
1643+ summary : "Standup" ,
1644+ organizer : { email : "boss@example.com" , self : false } ,
1645+ attendees : [
1646+ { email : "user@example.com" , self : true } ,
1647+ { email : "boss@example.com" , organizer : true } ,
1648+ ] ,
1649+ } ,
1650+ } ) ;
1651+
1652+ await processCalendarEventsFn (
1653+ host ,
1654+ [ sparseCancelledInstance ( "master-other" , start ) ] ,
1655+ calendarId ,
1656+ false
1657+ ) ;
1658+
1659+ const saved = host . savedLinks . flat ( ) ;
1660+ expect ( saved ) . toHaveLength ( 1 ) ;
1661+ expect ( saved [ 0 ] . unread ) . toBeUndefined ( ) ;
1662+ } ) ;
1663+
1664+ it ( "keeps the cancellation unread when the master cannot be fetched" , async ( ) => {
1665+ const host = seedHost ( ) ;
1666+ const start = futureStart ( ) ;
1667+ stubFetchWithMasters ( { } , 500 ) ;
1668+
1669+ await processCalendarEventsFn (
1670+ host ,
1671+ [ sparseCancelledInstance ( "master-gone" , start ) ] ,
1672+ calendarId ,
1673+ false
1674+ ) ;
1675+
1676+ const saved = host . savedLinks . flat ( ) ;
1677+ expect ( saved ) . toHaveLength ( 1 ) ;
1678+ expect ( saved [ 0 ] . unread ) . toBeUndefined ( ) ;
1679+ } ) ;
1680+
1681+ it ( "fetches each master once per page of events" , async ( ) => {
1682+ const host = seedHost ( ) ;
1683+ const start = futureStart ( ) ;
1684+ const fetchMock = stubFetchWithMasters ( {
1685+ "master-self" : {
1686+ id : "master-self" ,
1687+ iCalUID : "series@google.com" ,
1688+ status : "confirmed" ,
1689+ organizer : { email : "user@example.com" , self : true } ,
1690+ } ,
1691+ } ) ;
1692+
1693+ await processCalendarEventsFn (
1694+ host ,
1695+ [
1696+ sparseCancelledInstance ( "master-self" , start ) ,
1697+ sparseCancelledInstance (
1698+ "master-self" ,
1699+ new Date ( start . getTime ( ) + 7 * 24 * 60 * 60 * 1000 )
1700+ ) ,
1701+ sparseCancelledInstance (
1702+ "master-self" ,
1703+ new Date ( start . getTime ( ) + 14 * 24 * 60 * 60 * 1000 )
1704+ ) ,
1705+ ] ,
1706+ calendarId ,
1707+ false
1708+ ) ;
1709+
1710+ const eventGets = fetchMock . mock . calls . filter ( ( [ url ] ) =>
1711+ String ( url ) . includes ( "/events/master-self" )
1712+ ) ;
1713+ expect ( eventGets ) . toHaveLength ( 1 ) ;
1714+
1715+ const saved = host . savedLinks . flat ( ) ;
1716+ expect ( saved ) . toHaveLength ( 1 ) ;
1717+ expect ( saved [ 0 ] . unread ) . toBe ( false ) ;
1718+ } ) ;
1719+
1720+ it ( "keeps the read intent when the master arrives in the same page" , async ( ) => {
1721+ // Cancelling an occurrence also bumps the master, so both land in one
1722+ // incremental page. The master is the link that carries `schedules`, and
1723+ // merging must not drop the occurrence link's read intent.
1724+ const host = seedHost ( ) ;
1725+ const start = futureStart ( ) ;
1726+ stubFetchWithMasters ( { } ) ;
1727+
1728+ const master = {
1729+ id : "master-self" ,
1730+ iCalUID : "series@google.com" ,
1731+ status : "confirmed" as const ,
1732+ summary : "Church" ,
1733+ updated : new Date ( ) . toISOString ( ) ,
1734+ start : { dateTime : start . toISOString ( ) } ,
1735+ end : { dateTime : new Date ( start . getTime ( ) + 3_600_000 ) . toISOString ( ) } ,
1736+ recurrence : [ "RRULE:FREQ=WEEKLY;BYDAY=SU" ] ,
1737+ organizer : { email : "user@example.com" , self : true } ,
1738+ } ;
1739+ const cancelled = {
1740+ ...sparseCancelledInstance ( "master-self" , start ) ,
1741+ // Both links must resolve to the same canonical source to be coalesced,
1742+ // and the point of this test is the merge — so give the instance the
1743+ // series iCalUID and enough signal to conclude self-initiated on its own.
1744+ iCalUID : "series@google.com" ,
1745+ organizer : { email : "user@example.com" , self : true } ,
1746+ } ;
1747+
1748+ // Master first — the ordering that previously discarded `unread`.
1749+ await processCalendarEventsFn ( host , [ master , cancelled ] , calendarId , false ) ;
1750+
1751+ const saved = host . savedLinks . flat ( ) ;
1752+ expect ( saved ) . toHaveLength ( 1 ) ;
1753+ expect ( saved [ 0 ] . unread ) . toBe ( false ) ;
1754+ } ) ;
1755+ } ) ;
1756+
15411757describe ( "processCalendarEventsFn — message-model note audiences" , ( ) => {
15421758 afterEach ( ( ) => {
15431759 vi . unstubAllGlobals ( ) ;
0 commit comments