@@ -5,10 +5,19 @@ import type { OfflineLicenseMetadata } from '@sourcebot/shared';
55// Stub the rendered banner components — these tests assert on descriptor
66// metadata only (id, priority, etc), so avoiding their React/Next.js import
77// chains keeps the suite focused on resolver logic.
8+ // Stub @sourcebot /shared: importing its real index initializes env-backed
9+ // server code that can't run in the test environment. The resolver only
10+ // needs the threshold constant; type imports are erased at runtime.
11+ vi . mock ( '@sourcebot/shared' , ( ) => ( {
12+ STALE_ONLINE_LICENSE_THRESHOLD_MS : 7 * 24 * 60 * 60 * 1000 ,
13+ STALE_ONLINE_LICENSE_WARNING_THRESHOLD_MS : 48 * 60 * 60 * 1000 ,
14+ } ) ) ;
15+
816vi . mock ( './permissionSyncBanner' , ( ) => ( { PermissionSyncBanner : ( ) => null } ) ) ;
917vi . mock ( './licenseExpiredBanner' , ( ) => ( { LicenseExpiredBanner : ( ) => null } ) ) ;
1018vi . mock ( './licenseExpiryHeadsUpBanner' , ( ) => ( { LicenseExpiryHeadsUpBanner : ( ) => null } ) ) ;
1119vi . mock ( './invoicePastDueBanner' , ( ) => ( { InvoicePastDueBanner : ( ) => null } ) ) ;
20+ vi . mock ( './servicePingFailedBanner' , ( ) => ( { ServicePingFailedBanner : ( ) => null } ) ) ;
1221
1322import { resolveActiveBanner , type BannerContext } from './bannerResolver' ;
1423
@@ -34,7 +43,7 @@ const makeLicense = (overrides: Partial<License> = {}): License => ({
3443 nextRenewalAt : null ,
3544 nextRenewalAmount : null ,
3645 cancelAt : null ,
37- lastSyncAt : null ,
46+ lastSyncAt : NOW ,
3847 createdAt : NOW ,
3948 updatedAt : NOW ,
4049 ...overrides ,
@@ -305,6 +314,136 @@ describe('resolveActiveBanner', () => {
305314 } ) ;
306315 } ) ;
307316
317+ describe ( 'service ping staleness' , ( ) => {
318+ const WARNING_MS = 48 * 60 * 60 * 1000 ;
319+ const ENFORCEMENT_MS = 7 * 24 * 60 * 60 * 1000 ;
320+ const msBefore = ( ms : number ) => new Date ( NOW . getTime ( ) - ms ) ;
321+
322+ test ( 'fresh lastSyncAt → no banner' , ( ) => {
323+ const result = resolveActiveBanner ( makeContext ( {
324+ license : makeLicense ( { status : 'active' , lastSyncAt : msBefore ( 1000 ) } ) ,
325+ } ) ) ;
326+ expect ( result ) . toBeNull ( ) ;
327+ } ) ;
328+
329+ test ( 'stale between 48h and 7d → warning (dismissible, owner)' , ( ) => {
330+ const result = resolveActiveBanner ( makeContext ( {
331+ license : makeLicense ( {
332+ status : 'active' ,
333+ lastSyncAt : msBefore ( WARNING_MS + 60_000 ) ,
334+ } ) ,
335+ } ) ) ;
336+ expect ( result ?. id ) . toBe ( 'servicePingFailed' ) ;
337+ expect ( result ?. dismissible ) . toBe ( true ) ;
338+ expect ( result ?. audience ) . toBe ( 'owner' ) ;
339+ } ) ;
340+
341+ test ( 'stale beyond 7d → enforced (non-dismissible, everyone)' , ( ) => {
342+ const result = resolveActiveBanner ( makeContext ( {
343+ license : makeLicense ( {
344+ status : 'active' ,
345+ lastSyncAt : msBefore ( ENFORCEMENT_MS + 60_000 ) ,
346+ } ) ,
347+ } ) ) ;
348+ expect ( result ?. id ) . toBe ( 'servicePingFailed' ) ;
349+ expect ( result ?. dismissible ) . toBe ( false ) ;
350+ expect ( result ?. audience ) . toBe ( 'everyone' ) ;
351+ } ) ;
352+
353+ test ( 'null lastSyncAt on existing license → enforced' , ( ) => {
354+ const result = resolveActiveBanner ( makeContext ( {
355+ license : makeLicense ( { status : 'active' , lastSyncAt : null } ) ,
356+ } ) ) ;
357+ expect ( result ?. id ) . toBe ( 'servicePingFailed' ) ;
358+ expect ( result ?. audience ) . toBe ( 'everyone' ) ;
359+ } ) ;
360+
361+ test ( 'offline license suppresses staleness banner' , ( ) => {
362+ const result = resolveActiveBanner ( makeContext ( {
363+ offlineLicense : makeOfflineLicense ( ) ,
364+ license : makeLicense ( { status : 'active' , lastSyncAt : null } ) ,
365+ } ) ) ;
366+ expect ( result ) . toBeNull ( ) ;
367+ } ) ;
368+
369+ test ( 'warning banner hidden from non-owners' , ( ) => {
370+ const result = resolveActiveBanner ( makeContext ( {
371+ role : OrgRole . MEMBER ,
372+ license : makeLicense ( {
373+ status : 'active' ,
374+ lastSyncAt : msBefore ( WARNING_MS + 60_000 ) ,
375+ } ) ,
376+ } ) ) ;
377+ expect ( result ) . toBeNull ( ) ;
378+ } ) ;
379+
380+ test ( 'enforced banner shown to non-owners' , ( ) => {
381+ const result = resolveActiveBanner ( makeContext ( {
382+ role : OrgRole . MEMBER ,
383+ license : makeLicense ( {
384+ status : 'active' ,
385+ lastSyncAt : msBefore ( ENFORCEMENT_MS + 60_000 ) ,
386+ } ) ,
387+ } ) ) ;
388+ expect ( result ?. id ) . toBe ( 'servicePingFailed' ) ;
389+ } ) ;
390+
391+ test ( 'warning: dismissed today → filtered out' , ( ) => {
392+ const result = resolveActiveBanner ( makeContext ( {
393+ license : makeLicense ( {
394+ status : 'active' ,
395+ lastSyncAt : msBefore ( WARNING_MS + 60_000 ) ,
396+ } ) ,
397+ dismissals : { servicePingFailed : TODAY } ,
398+ } ) ) ;
399+ expect ( result ) . toBeNull ( ) ;
400+ } ) ;
401+
402+ test ( 'enforced: dismissal cookie is ignored' , ( ) => {
403+ const result = resolveActiveBanner ( makeContext ( {
404+ license : makeLicense ( {
405+ status : 'active' ,
406+ lastSyncAt : msBefore ( ENFORCEMENT_MS + 60_000 ) ,
407+ } ) ,
408+ dismissals : { servicePingFailed : TODAY } ,
409+ } ) ) ;
410+ expect ( result ?. id ) . toBe ( 'servicePingFailed' ) ;
411+ } ) ;
412+
413+ test ( 'enforced outranks invoice past due' , ( ) => {
414+ const result = resolveActiveBanner ( makeContext ( {
415+ license : makeLicense ( {
416+ status : 'past_due' ,
417+ lastSyncAt : msBefore ( ENFORCEMENT_MS + 60_000 ) ,
418+ } ) ,
419+ } ) ) ;
420+ expect ( result ?. id ) . toBe ( 'servicePingFailed' ) ;
421+ expect ( result ?. audience ) . toBe ( 'everyone' ) ;
422+ } ) ;
423+
424+ test ( 'license expired outranks enforced ping staleness' , ( ) => {
425+ const result = resolveActiveBanner ( makeContext ( {
426+ license : makeLicense ( {
427+ status : 'canceled' ,
428+ lastSyncAt : msBefore ( ENFORCEMENT_MS + 60_000 ) ,
429+ } ) ,
430+ } ) ) ;
431+ expect ( result ?. id ) . toBe ( 'licenseExpired' ) ;
432+ } ) ;
433+
434+ test ( 'warning ranks below permission sync' , ( ) => {
435+ const result = resolveActiveBanner ( makeContext ( {
436+ license : makeLicense ( {
437+ status : 'active' ,
438+ lastSyncAt : msBefore ( WARNING_MS + 60_000 ) ,
439+ } ) ,
440+ hasPermissionSyncEntitlement : true ,
441+ hasPendingFirstSync : true ,
442+ } ) ) ;
443+ expect ( result ?. id ) . toBe ( 'permissionSync' ) ;
444+ } ) ;
445+ } ) ;
446+
308447 describe ( 'permission sync' , ( ) => {
309448 test ( 'entitlement + pending → permissionSync' , ( ) => {
310449 const result = resolveActiveBanner ( makeContext ( {
0 commit comments