11import { createServer } from 'node:http'
22import { afterAll , beforeAll , beforeEach , describe , expect , it , vi } from 'vitest'
3+ import { classifyExternalDoc , contentHashMatch } from '@/lib/knowledge/connectors/sync-primitives'
34import { googleChatConnector } from '@/connectors/google-chat/google-chat'
45
56const SPACE = {
@@ -16,6 +17,8 @@ let messageReads = 0
1617let requestCount = 0
1718let firstParameters = ''
1819let now = Date . parse ( '2026-10-02T12:00:00Z' )
20+ let activity = SPACE . lastActiveTime
21+ let body = 'New decision: review on Friday.'
1922
2023const server = createServer ( ( request , response ) => {
2124 requestCount ++
@@ -29,7 +32,7 @@ const server = createServer((request, response) => {
2932 return
3033 }
3134 if ( url . pathname === '/v1/spaces' ) {
32- send ( { spaces : [ SPACE ] } )
35+ send ( { spaces : [ { ... SPACE , lastActiveTime : activity } ] } )
3336 return
3437 }
3538 if ( url . pathname === `/v1/${ SPACE . name } ` ) {
@@ -81,6 +84,28 @@ const server = createServer((request, response) => {
8184 } )
8285 return
8386 }
87+ if ( mode === 'projected' ) {
88+ const fields = url . searchParams . get ( 'fields' ) ?? '*'
89+ if ( fields === '*' || fields . includes ( 'cards' ) ) {
90+ send ( { cards : 'x' . repeat ( 33 * 1024 * 1024 ) } )
91+ return
92+ }
93+ if (
94+ ! [
95+ 'nextPageToken' ,
96+ 'messages' ,
97+ 'createTime' ,
98+ 'sender' ,
99+ 'name' ,
100+ 'displayName' ,
101+ 'text' ,
102+ 'fallbackText' ,
103+ ] . every ( ( field ) => fields . includes ( field ) )
104+ ) {
105+ send ( { error : 'Transcript or continuation field omitted' } , 400 )
106+ return
107+ }
108+ }
84109 if ( mode === 'empty-page' && ! token ) {
85110 send ( { nextPageToken : 'page-2' } )
86111 return
@@ -89,14 +114,14 @@ const server = createServer((request, response) => {
89114 const cutoff = url . searchParams . get ( 'filter' ) ?. match ( / " ( [ ^ " ] + ) " / ) ?. [ 1 ]
90115 send ( {
91116 messages :
92- cutoff && time <= cutoff
117+ cutoff && Date . parse ( time ) <= Date . parse ( cutoff )
93118 ? [ ]
94119 : [
95120 {
96121 name : `${ SPACE . name } /messages/${ token ? 'older' : 'newer' } ` ,
97122 sender : { name : 'users/fixture-author' } ,
98- createTime : time ,
99- text : token ? 'Older decision: launch is paused.' : 'New decision: review on Friday.' ,
123+ createTime : mode === 'unknown-time' ? undefined : time ,
124+ text : token ? 'Older decision: launch is paused.' : body ,
100125 } ,
101126 ] ,
102127 nextPageToken :
@@ -123,6 +148,8 @@ beforeEach(() => {
123148 requestCount = 0
124149 firstParameters = ''
125150 now = Date . parse ( '2026-10-02T12:00:00Z' )
151+ activity = SPACE . lastActiveTime
152+ body = 'New decision: review on Friday.'
126153} )
127154
128155afterAll ( async ( ) => {
@@ -184,15 +211,176 @@ describe('Google Chat connector over real HTTP', () => {
184211 const first = await googleChatConnector . listDocuments ( TOKEN , config , undefined , firstContext )
185212 now += 60_000
186213 const initial = await read ( config , firstContext )
214+ if ( ! initial ) throw new Error ( 'Expected a space document' )
187215 expect ( initial ?. content ) . toContain ( 'New decision:' )
188- expect ( initial ?. contentHash ) . toBe ( first . documents [ 0 ] . contentHash )
216+ expect (
217+ contentHashMatch (
218+ first . documents [ 0 ] . contentHash ,
219+ initial . contentHash ,
220+ googleChatConnector . matchContentHash
221+ )
222+ ) . toBe ( 'current' )
189223 now += 3 * 24 * 60 * 60 * 1000
190224 const nextContext = { }
191225 const next = await googleChatConnector . listDocuments ( TOKEN , config , undefined , nextContext )
192226 expect ( next . documents [ 0 ] . contentHash ) . not . toBe ( first . documents [ 0 ] . contentHash )
193227 const expired = await read ( config , nextContext )
228+ if ( ! expired ) throw new Error ( 'Expected a space document' )
229+ expect ( expired ?. content ) . not . toContain ( 'New decision:' )
230+ expect (
231+ contentHashMatch (
232+ next . documents [ 0 ] . contentHash ,
233+ expired . contentHash ,
234+ googleChatConnector . matchContentHash
235+ )
236+ ) . toBe ( 'current' )
237+ } )
238+
239+ it ( 'skips unchanged rolling-window spaces until the exact next message expiry' , async ( ) => {
240+ mode = 'window'
241+ vi . spyOn ( Date , 'now' ) . mockImplementation ( ( ) => now )
242+ const config = { lookbackDays : '2' }
243+ const context = { }
244+ await googleChatConnector . listDocuments ( TOKEN , config , undefined , context )
245+ const initial = await read ( config , context )
246+ if ( ! initial ) throw new Error ( 'Expected a space document' )
247+ const existing = { id : 'stored' , contentHash : initial . contentHash , storageKey : 'indexed' }
248+ const classify = async ( ) => {
249+ const page = await googleChatConnector . listDocuments ( TOKEN , config , undefined , { } )
250+ return classifyExternalDoc (
251+ page . documents [ 0 ] ,
252+ existing ,
253+ false ,
254+ googleChatConnector . matchContentHash
255+ ) . type
256+ }
257+ now += 1
258+ expect ( await classify ( ) ) . toBe ( 'unchanged' )
259+ now = Date . parse ( '2026-10-03T11:59:59.999Z' )
260+ expect ( await classify ( ) ) . toBe ( 'unchanged' )
261+ expect ( messageReads ) . toBe ( 1 )
262+ now = Date . parse ( '2026-10-02T11:59:59.999Z' )
263+ expect ( await classify ( ) ) . toBe ( 'update' )
264+ now = Date . parse ( '2026-10-03T11:59:59.999Z' )
265+ now += 1
266+ expect ( await classify ( ) ) . toBe ( 'update' )
267+ const expired = await read ( config , { } )
194268 expect ( expired ?. content ) . not . toContain ( 'New decision:' )
195- expect ( expired ?. contentHash ) . toBe ( next . documents [ 0 ] . contentHash )
269+ expect ( messageReads ) . toBe ( 2 )
270+ } )
271+
272+ it ( 'keeps empty windows cheap but refreshes changed settings, full syncs, and legacy hashes' , async ( ) => {
273+ mode = 'window'
274+ now = Date . parse ( '2026-10-04T12:00:00Z' )
275+ vi . spyOn ( Date , 'now' ) . mockImplementation ( ( ) => now )
276+ const config = { lookbackDays : '2' }
277+ const initial = await read ( config , { } )
278+ if ( ! initial ) throw new Error ( 'Expected a space document' )
279+ const existing = { id : 'stored' , contentHash : initial . contentHash , storageKey : 'indexed' }
280+ now += 24 * 60 * 60 * 1000
281+ const page = await googleChatConnector . listDocuments ( TOKEN , config , undefined , { } )
282+ const stub = page . documents [ 0 ]
283+ expect (
284+ classifyExternalDoc ( stub , existing , false , googleChatConnector . matchContentHash ) . type
285+ ) . toBe ( 'unchanged' )
286+ expect (
287+ classifyExternalDoc ( stub , existing , true , googleChatConnector . matchContentHash ) . type
288+ ) . toBe ( 'update' )
289+ expect (
290+ classifyExternalDoc (
291+ stub ,
292+ { ...existing , contentHash : 'gchat:v2:legacy' } ,
293+ false ,
294+ googleChatConnector . matchContentHash
295+ ) . type
296+ ) . toBe ( 'update' )
297+ const changed = await googleChatConnector . listDocuments (
298+ TOKEN ,
299+ { ...config , maxMessages : '100' } ,
300+ undefined ,
301+ { }
302+ )
303+ expect (
304+ classifyExternalDoc (
305+ changed . documents [ 0 ] ,
306+ existing ,
307+ false ,
308+ googleChatConnector . matchContentHash
309+ ) . type
310+ ) . toBe ( 'update' )
311+ } )
312+
313+ it ( 'rereads a lookback window when the provider omits creation timestamps' , async ( ) => {
314+ mode = 'unknown-time'
315+ vi . spyOn ( Date , 'now' ) . mockImplementation ( ( ) => now )
316+ const config = { lookbackDays : '90' }
317+ const initial = await read ( config , { } )
318+ if ( ! initial ) throw new Error ( 'Expected a space document' )
319+ now += 60_000
320+ const page = await googleChatConnector . listDocuments ( TOKEN , config , undefined , { } )
321+ const existing = { id : 'stored' , contentHash : initial . contentHash , storageKey : 'indexed' }
322+ expect (
323+ classifyExternalDoc ( page . documents [ 0 ] , existing , false , googleChatConnector . matchContentHash )
324+ . type
325+ ) . toBe ( 'update' )
326+ } )
327+
328+ it ( 'advances source metadata without replacing identical text but still indexes changed text' , async ( ) => {
329+ mode = 'window'
330+ vi . spyOn ( Date , 'now' ) . mockImplementation ( ( ) => now )
331+ const load = async ( ) => {
332+ const context = { }
333+ await googleChatConnector . listDocuments ( TOKEN , { } , undefined , context )
334+ const doc = await read ( { } , context )
335+ if ( ! doc ) throw new Error ( 'Expected a space document' )
336+ return doc
337+ }
338+ const initial = await load ( )
339+ activity = '2026-10-02T12:00:00Z'
340+ const page = await googleChatConnector . listDocuments ( TOKEN , { } , undefined , { } )
341+ expect (
342+ classifyExternalDoc (
343+ page . documents [ 0 ] ,
344+ { id : 'stored' , contentHash : initial . contentHash , storageKey : 'indexed' } ,
345+ false ,
346+ googleChatConnector . matchContentHash
347+ ) . type
348+ ) . toBe ( 'update' )
349+ const refreshed = await load ( )
350+ expect (
351+ contentHashMatch (
352+ refreshed . contentHash ,
353+ initial . contentHash ,
354+ googleChatConnector . matchContentHash
355+ )
356+ ) . toBe ( 'equivalent' )
357+ body = 'Changed decision: launch is approved.'
358+ const changed = await load ( )
359+ expect (
360+ contentHashMatch (
361+ changed . contentHash ,
362+ refreshed . contentHash ,
363+ googleChatConnector . matchContentHash
364+ )
365+ ) . toBe ( 'stale' )
366+ } )
367+
368+ it . each ( [ '100.0' , '1e2' ] ) (
369+ 'keeps saved whole-number strings such as %s usable through validation and hydration' ,
370+ async ( maxSpaces ) => {
371+ vi . spyOn ( Date , 'now' ) . mockImplementation ( ( ) => now )
372+ const config = { maxSpaces, maxMessages : '3.00' , lookbackDays : '90.0' }
373+ expect ( await googleChatConnector . validateConfig ( TOKEN , config ) ) . toEqual ( { valid : true } )
374+ const context = { }
375+ await googleChatConnector . listDocuments ( TOKEN , config , undefined , context )
376+ expect ( ( await read ( config , context ) ) ?. metadata ?. messageCount ) . toBe ( 2 )
377+ }
378+ )
379+
380+ it ( 'reads the transcript without downloading unused card payloads' , async ( ) => {
381+ mode = 'projected'
382+ const doc = await read ( )
383+ expect ( doc ?. content ) . toMatch ( / O l d e r d e c i s i o n : [ \s \S ] * N e w d e c i s i o n : / )
196384 } )
197385
198386 it . each ( [ 401 , 403 ] ) ( 'preserves HTTP %i authorization failures' , async ( status ) => {
@@ -203,7 +391,17 @@ describe('Google Chat connector over real HTTP', () => {
203391 it . each ( [ 'maxMessages' , 'maxSpaces' , 'lookbackDays' ] ) (
204392 'rejects invalid %s before contacting the provider' ,
205393 async ( field ) => {
206- for ( const value of [ 'Infinity' , '0.5' , '9007199254740992' ] ) {
394+ for ( const value of [
395+ 'Infinity' ,
396+ '0.5' ,
397+ '1.0000000000000001' ,
398+ '0.99999999999999999' ,
399+ '1.0000000000000001e2' ,
400+ '1e-400' ,
401+ '9007199254740992' ,
402+ true ,
403+ { } ,
404+ ] ) {
207405 const result = await googleChatConnector . validateConfig ( TOKEN , { [ field ] : value } )
208406 expect ( result . valid , `${ field } =${ value } ` ) . toBe ( false )
209407 }
0 commit comments