@@ -3,13 +3,45 @@ import path from 'node:path'
33import fs from 'fs-extra'
44import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
55
6+ import { enqueueCloudDownload } from '../../../cloudDownloads'
7+ import {
8+ getFileAvailability ,
9+ markAppWrittenFileAsLocal ,
10+ resetCloudFileExemptions ,
11+ setDatalessProbeForTests ,
12+ } from '../../../runtime/shared/cloudFiles'
613import { ensureNotesStateFile } from '../../runtime/state'
714import { resetNotesRuntimeCache } from '../../runtime/sync'
815import { createNotesFoldersStorage } from '../folders'
916import { createNotesNotesStorage } from '../notes'
1017
1118let tempVaultPath = ''
1219
20+ function makeSparsePlaceholder ( absolutePath : string , size = 4096 ) : void {
21+ fs . removeSync ( absolutePath )
22+ const fd = fs . openSync ( absolutePath , 'w' )
23+ fs . ftruncateSync ( fd , size )
24+ fs . closeSync ( fd )
25+ }
26+
27+ function mockFolderMetadataAsZeroBlocks ( ) {
28+ const statSync = fs . statSync . bind ( fs )
29+
30+ return vi . spyOn ( fs , 'statSync' ) . mockImplementation ( ( filePath ) => {
31+ const stats = statSync ( filePath )
32+
33+ if (
34+ typeof filePath === 'string'
35+ && filePath . endsWith ( '.meta.yaml' )
36+ && stats . size > 0
37+ ) {
38+ return Object . assign ( stats , { blocks : 0 } )
39+ }
40+
41+ return stats
42+ } )
43+ }
44+
1345vi . mock ( 'electron-store' , ( ) => {
1446 class MockStore {
1547 private state : Record < string , unknown >
@@ -60,6 +92,11 @@ vi.mock('electron', () => ({
6092 } ,
6193} ) )
6294
95+ vi . mock ( '../../../cloudDownloads' , ( ) => ( {
96+ enqueueCloudDownload : vi . fn ( ) ,
97+ prioritizeCloudDownload : vi . fn ( ) ,
98+ } ) )
99+
63100vi . mock ( '../../../../../../store' , ( ) => ( {
64101 store : {
65102 preferences : {
@@ -94,6 +131,10 @@ describe('folders storage validations', () => {
94131 } )
95132
96133 afterEach ( ( ) => {
134+ setDatalessProbeForTests ( null )
135+ resetCloudFileExemptions ( )
136+ resetNotesRuntimeCache ( )
137+
97138 if ( tempVaultPath ) {
98139 fs . removeSync ( tempVaultPath )
99140 }
@@ -215,4 +256,101 @@ describe('folders storage validations', () => {
215256
216257 expect ( notes . getNoteById ( linker . id ) ?. content ) . toBe ( 'See [[Foo]] here' )
217258 } )
259+
260+ it ( 'keeps resident zero-block subtree metadata local after parent move' , ( ) => {
261+ const notesRoot = path . join ( tempVaultPath , 'notes' )
262+ setDatalessProbeForTests ( ( ) => true )
263+ const statSpy = mockFolderMetadataAsZeroBlocks ( )
264+
265+ try {
266+ const storage = createNotesFoldersStorage ( )
267+ const destination = storage . createFolder ( { name : 'Destination' } )
268+ const parent = storage . createFolder ( { name : 'Parent' } )
269+ storage . createFolder ( { name : 'Child' , parentId : parent . id } )
270+ const beforeMetadata = fs . readFileSync (
271+ path . join ( notesRoot , 'Parent' , '.meta.yaml' ) ,
272+ 'utf8' ,
273+ )
274+ const previousUpdatedAt = storage
275+ . getFolders ( )
276+ . find ( folder => folder . id === parent . id ) ! . updatedAt
277+ const nowSpy = vi
278+ . spyOn ( Date , 'now' )
279+ . mockReturnValue ( previousUpdatedAt + 1 )
280+
281+ try {
282+ storage . updateFolder ( parent . id , { parentId : destination . id } )
283+ }
284+ finally {
285+ nowSpy . mockRestore ( )
286+ }
287+
288+ const parentMetaPath = path . join (
289+ notesRoot ,
290+ 'Destination' ,
291+ 'Parent' ,
292+ '.meta.yaml' ,
293+ )
294+ const childMetaPath = path . join (
295+ notesRoot ,
296+ 'Destination' ,
297+ 'Parent' ,
298+ 'Child' ,
299+ '.meta.yaml' ,
300+ )
301+
302+ expect ( getFileAvailability ( parentMetaPath ) . isCloudPlaceholder ) . toBe (
303+ false ,
304+ )
305+ expect ( getFileAvailability ( childMetaPath ) . isCloudPlaceholder ) . toBe ( false )
306+ expect ( fs . readFileSync ( parentMetaPath , 'utf8' ) ) . not . toBe ( beforeMetadata )
307+ }
308+ finally {
309+ statSpy . mockRestore ( )
310+ }
311+ } )
312+
313+ it ( 'does not exempt or overwrite descendant metadata placeholder after parent move' , ( ) => {
314+ const notesRoot = path . join ( tempVaultPath , 'notes' )
315+ setDatalessProbeForTests ( ( ) => true )
316+ const statSpy = mockFolderMetadataAsZeroBlocks ( )
317+
318+ try {
319+ const storage = createNotesFoldersStorage ( )
320+ const destination = storage . createFolder ( { name : 'Destination' } )
321+ const parent = storage . createFolder ( { name : 'Parent' } )
322+ storage . createFolder ( { name : 'Child' , parentId : parent . id } )
323+ const parentMetaPath = path . join ( notesRoot , 'Parent' , '.meta.yaml' )
324+ const childSourcePath = path . join (
325+ notesRoot ,
326+ 'Parent' ,
327+ 'Child' ,
328+ '.meta.yaml' ,
329+ )
330+
331+ makeSparsePlaceholder ( childSourcePath )
332+ resetCloudFileExemptions ( )
333+ markAppWrittenFileAsLocal ( parentMetaPath )
334+ const placeholderBefore = fs . readFileSync ( childSourcePath )
335+ const childTargetPath = path . join (
336+ notesRoot ,
337+ 'Destination' ,
338+ 'Parent' ,
339+ 'Child' ,
340+ '.meta.yaml' ,
341+ )
342+ vi . mocked ( enqueueCloudDownload ) . mockClear ( )
343+
344+ storage . updateFolder ( parent . id , { parentId : destination . id } )
345+
346+ expect ( fs . readFileSync ( childTargetPath ) ) . toEqual ( placeholderBefore )
347+ expect ( getFileAvailability ( childTargetPath ) . isCloudPlaceholder ) . toBe (
348+ true ,
349+ )
350+ expect ( enqueueCloudDownload ) . toHaveBeenCalledWith ( childTargetPath )
351+ }
352+ finally {
353+ statSpy . mockRestore ( )
354+ }
355+ } )
218356} )
0 commit comments