@@ -14,7 +14,7 @@ import {
1414 useStorageSelector ,
1515 useStorage ,
1616} from "../index.web" ;
17- import { serializeWithPrimitiveFastPath } from "../internal" ;
17+ import { MIGRATION_VERSION_KEY , serializeWithPrimitiveFastPath } from "../internal" ;
1818
1919function createStorageMock ( ) : Storage {
2020 const store = new Map < string , string > ( ) ;
@@ -606,4 +606,236 @@ describe("Web Storage", () => {
606606
607607 expect ( migrateToLatest ( StorageScope . Memory ) ) . toBe ( version ) ;
608608 } ) ;
609+
610+ it ( "reads pending secure values before coalesced writes flush" , async ( ) => {
611+ const item = createStorageItem ( {
612+ key : "pending-secure-read" ,
613+ scope : StorageScope . Secure ,
614+ defaultValue : "default" ,
615+ coalesceSecureWrites : true ,
616+ } ) ;
617+
618+ item . set ( "queued" ) ;
619+ expect ( item . get ( ) ) . toBe ( "queued" ) ;
620+
621+ await Promise . resolve ( ) ;
622+ expect ( globalThis . sessionStorage . getItem ( "pending-secure-read" ) ) . toBe (
623+ serializeWithPrimitiveFastPath ( "queued" )
624+ ) ;
625+ } ) ;
626+
627+ it ( "keeps direct and coalesced secure write paths independent" , async ( ) => {
628+ const coalesced = createStorageItem ( {
629+ key : "queued-secure" ,
630+ scope : StorageScope . Secure ,
631+ defaultValue : "default" ,
632+ coalesceSecureWrites : true ,
633+ } ) ;
634+ const direct = createStorageItem ( {
635+ key : "direct-secure" ,
636+ scope : StorageScope . Secure ,
637+ defaultValue : "default" ,
638+ } ) ;
639+ const setSpy = jest . spyOn ( globalThis . sessionStorage , "setItem" ) ;
640+ const removeSpy = jest . spyOn ( globalThis . sessionStorage , "removeItem" ) ;
641+
642+ coalesced . set ( "queued-1" ) ;
643+ direct . set ( "direct-1" ) ;
644+ coalesced . delete ( ) ;
645+ direct . delete ( ) ;
646+
647+ expect ( setSpy ) . toHaveBeenCalledWith (
648+ "direct-secure" ,
649+ serializeWithPrimitiveFastPath ( "direct-1" )
650+ ) ;
651+ expect ( removeSpy ) . toHaveBeenCalledWith ( "direct-secure" ) ;
652+
653+ await Promise . resolve ( ) ;
654+ expect ( removeSpy ) . toHaveBeenCalledWith ( "queued-secure" ) ;
655+ } ) ;
656+
657+ it ( "handles memory TTL expiration and delete cleanup" , ( ) => {
658+ const nowSpy = jest . spyOn ( Date , "now" ) . mockReturnValue ( 1_000 ) ;
659+ const listener = jest . fn ( ) ;
660+ const item = createStorageItem < string > ( {
661+ key : "memory-ttl-web" ,
662+ scope : StorageScope . Memory ,
663+ defaultValue : "fallback" ,
664+ expiration : { ttlMs : 10 } ,
665+ } ) ;
666+
667+ item . subscribe ( listener ) ;
668+ item . set ( "live" ) ;
669+ expect ( item . get ( ) ) . toBe ( "live" ) ;
670+
671+ nowSpy . mockReturnValue ( 1_020 ) ;
672+ expect ( item . get ( ) ) . toBe ( "fallback" ) ;
673+
674+ item . set ( "second" ) ;
675+ item . delete ( ) ;
676+ expect ( item . get ( ) ) . toBe ( "fallback" ) ;
677+ expect ( listener ) . toHaveBeenCalled ( ) ;
678+ nowSpy . mockRestore ( ) ;
679+ } ) ;
680+
681+ it ( "uses cache and pending secure paths in batch reads" , async ( ) => {
682+ const diskGetSpy = jest . spyOn ( globalThis . localStorage , "getItem" ) ;
683+ const cachedDisk = createStorageItem ( {
684+ key : "disk-batch-cache" ,
685+ scope : StorageScope . Disk ,
686+ defaultValue : "default" ,
687+ readCache : true ,
688+ } ) ;
689+ cachedDisk . set ( "cached-value" ) ;
690+ diskGetSpy . mockClear ( ) ;
691+
692+ expect ( getBatch ( [ cachedDisk ] , StorageScope . Disk ) ) . toEqual ( [ "cached-value" ] ) ;
693+ expect ( diskGetSpy ) . toHaveBeenCalledTimes ( 0 ) ;
694+
695+ const pendingSecure = createStorageItem ( {
696+ key : "secure-batch-pending" ,
697+ scope : StorageScope . Secure ,
698+ defaultValue : "default" ,
699+ coalesceSecureWrites : true ,
700+ } ) ;
701+ pendingSecure . set ( "queued-secure-value" ) ;
702+ expect ( getBatch ( [ pendingSecure ] , StorageScope . Secure ) ) . toEqual ( [
703+ "queued-secure-value" ,
704+ ] ) ;
705+
706+ await Promise . resolve ( ) ;
707+ } ) ;
708+
709+ it ( "falls back to item.get in web getBatch when raw value is missing" , ( ) => {
710+ const item = createStorageItem ( {
711+ key : "web-batch-fallback" ,
712+ scope : StorageScope . Disk ,
713+ defaultValue : "fallback" ,
714+ } ) ;
715+
716+ expect ( getBatch ( [ item ] , StorageScope . Disk ) ) . toEqual ( [ "fallback" ] ) ;
717+ } ) ;
718+
719+ it ( "uses per-item fallback path for non-raw batch set items" , ( ) => {
720+ const validatedItem = createStorageItem < number > ( {
721+ key : "batch-web-fallback-valid" ,
722+ scope : StorageScope . Disk ,
723+ defaultValue : 1 ,
724+ validate : ( value ) : value is number => typeof value === "number" && value > 0 ,
725+ } ) ;
726+
727+ setBatch ( [ { item : validatedItem , value : 9 } ] , StorageScope . Disk ) ;
728+ expect ( validatedItem . get ( ) ) . toBe ( 9 ) ;
729+ } ) ;
730+
731+ it ( "handles missing browser storage in web batch operations" , ( ) => {
732+ const originalLocalStorage = globalThis . localStorage ;
733+ try {
734+ Object . defineProperty ( globalThis , "localStorage" , {
735+ value : undefined ,
736+ configurable : true ,
737+ writable : true ,
738+ } ) ;
739+
740+ const item = createStorageItem ( {
741+ key : "missing-storage-batch" ,
742+ scope : StorageScope . Disk ,
743+ defaultValue : "default" ,
744+ } ) ;
745+
746+ expect ( ( ) =>
747+ setBatch ( [ { item, value : "next" } ] , StorageScope . Disk )
748+ ) . not . toThrow ( ) ;
749+ expect ( ( ) => removeBatch ( [ item ] , StorageScope . Disk ) ) . not . toThrow ( ) ;
750+ } finally {
751+ Object . defineProperty ( globalThis , "localStorage" , {
752+ value : originalLocalStorage ,
753+ configurable : true ,
754+ writable : true ,
755+ } ) ;
756+ }
757+ } ) ;
758+
759+ it ( "flushes pending secure writes for secure batch set/remove" , ( ) => {
760+ const coalesced = createStorageItem ( {
761+ key : "secure-batch-coalesced" ,
762+ scope : StorageScope . Secure ,
763+ defaultValue : "default" ,
764+ coalesceSecureWrites : true ,
765+ } ) ;
766+ const secureBatchItem = createStorageItem ( {
767+ key : "secure-batch-item" ,
768+ scope : StorageScope . Secure ,
769+ defaultValue : "default" ,
770+ } ) ;
771+
772+ coalesced . set ( "queued-before-batch" ) ;
773+ setBatch ( [ { item : secureBatchItem , value : "batched" } ] , StorageScope . Secure ) ;
774+ expect ( secureBatchItem . get ( ) ) . toBe ( "batched" ) ;
775+
776+ coalesced . set ( "queued-before-remove" ) ;
777+ removeBatch ( [ secureBatchItem ] , StorageScope . Secure ) ;
778+ expect ( secureBatchItem . get ( ) ) . toBe ( "default" ) ;
779+ } ) ;
780+
781+ it ( "validates migration registration version rules" , ( ) => {
782+ expect ( ( ) => registerMigration ( 0 , ( ) => undefined ) ) . toThrow (
783+ / p o s i t i v e i n t e g e r /
784+ ) ;
785+
786+ const version = migrationVersionSeed ++ ;
787+ registerMigration ( version , ( ) => undefined ) ;
788+ expect ( ( ) => registerMigration ( version , ( ) => undefined ) ) . toThrow (
789+ / a l r e a d y r e g i s t e r e d /
790+ ) ;
791+ } ) ;
792+
793+ it ( "treats invalid stored migration versions as zero" , ( ) => {
794+ const version = migrationVersionSeed ++ ;
795+ registerMigration ( version , ( ) => undefined ) ;
796+ globalThis . localStorage . setItem ( MIGRATION_VERSION_KEY , "not-a-number" ) ;
797+
798+ expect ( migrateToLatest ( StorageScope . Disk ) ) . toBe ( version ) ;
799+ } ) ;
800+
801+ it ( "flushes secure queue in transactions and records rollback once per key" , ( ) => {
802+ const queued = createStorageItem ( {
803+ key : "secure-tx-queued" ,
804+ scope : StorageScope . Secure ,
805+ defaultValue : "default" ,
806+ coalesceSecureWrites : true ,
807+ } ) ;
808+ const item = createStorageItem ( {
809+ key : "secure-tx-key" ,
810+ scope : StorageScope . Secure ,
811+ defaultValue : "default" ,
812+ } ) ;
813+
814+ queued . set ( "pending" ) ;
815+
816+ runTransaction ( StorageScope . Secure , ( tx ) => {
817+ tx . setRaw ( "secure-tx-key" , serializeWithPrimitiveFastPath ( "first" ) ) ;
818+ tx . setRaw ( "secure-tx-key" , serializeWithPrimitiveFastPath ( "second" ) ) ;
819+ tx . setItem ( item , "committed" ) ;
820+ } ) ;
821+
822+ expect ( item . get ( ) ) . toBe ( "committed" ) ;
823+ } ) ;
824+
825+ it ( "notifies listeners when _triggerListeners is called" , ( ) => {
826+ const item = createStorageItem ( {
827+ key : "manual-trigger-web" ,
828+ scope : StorageScope . Disk ,
829+ defaultValue : "default" ,
830+ } ) ;
831+ const listenerA = jest . fn ( ) ;
832+ const listenerB = jest . fn ( ) ;
833+
834+ item . subscribe ( listenerA ) ;
835+ item . subscribe ( listenerB ) ;
836+ item . _triggerListeners ( ) ;
837+
838+ expect ( listenerA ) . toHaveBeenCalledTimes ( 1 ) ;
839+ expect ( listenerB ) . toHaveBeenCalledTimes ( 1 ) ;
840+ } ) ;
609841} ) ;
0 commit comments