@simplepg/repo
Version:
SimplePage repository
688 lines (548 loc) • 26.9 kB
JavaScript
import { Files, FILES_FOLDER } from '../src/files.js'
import { CHANGE_TYPE } from '../src/constants.js'
import { emptyUnixfs, ls, CidSet } from '@simplepg/common'
import { jest } from '@jest/globals'
import { CID } from 'multiformats/cid'
describe('Files', () => {
let files
let mockDservice
let mockEnsureRepoData
let mockStorage
let fs
let blockstore
let repoRootCid
let filesCid
beforeEach(async () => {
// Create actual filesystem
({ fs, blockstore } = emptyUnixfs())
// Create a real repository root with _files directory
const emptyDir = await fs.addDirectory()
const filesDir = await fs.addDirectory()
const testFile = await fs.addBytes(new TextEncoder().encode('test content'))
// Add test file to files directory
filesCid = await fs.cp(testFile, filesDir, 'test.txt')
// Add _files directory to repo root
repoRootCid = await fs.cp(filesCid, emptyDir, '_files')
// Mock dservice
mockDservice = {
fetch: jest.fn()
}
// Mock ensureRepoData function
mockEnsureRepoData = jest.fn().mockResolvedValue(undefined)
// Mock storage
mockStorage = {
getItem: jest.fn().mockResolvedValue(null),
setItem: jest.fn().mockResolvedValue(undefined)
}
files = new Files(fs, blockstore, mockDservice, mockEnsureRepoData, mockStorage)
})
describe('initialization', () => {
it('should set root when /_files exists', async () => {
await files.unsafeSetRepoRoot(repoRootCid)
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' }
])
expect((await files.stage()).cid.toString()).toBe(filesCid.toString())
})
it('should create empty directory when /_files does not exist', async () => {
// Create a repo root without _files directory
const emptyRepoRoot = await fs.addDirectory()
await files.unsafeSetRepoRoot(emptyRepoRoot)
const lsResult = await files.ls('/')
expect(lsResult).toEqual([])
// even though it's a different folder, all empty folders have the same CID in unixfs
expect((await files.stage()).cid.toString()).toBe(emptyRepoRoot.toString())
})
})
describe('ls method', () => {
it('should throw error if unsafeSetRepoRoot is not called yet', async () => {
await expect(files.ls('/')).rejects.toThrow('Root not set. Call unsafeSetRoot() first.')
})
})
describe('add, rm, cat, restore, clearChanges with ls verification', () => {
beforeEach(async () => {
await files.unsafeSetRepoRoot(repoRootCid)
})
it('should add new file and verify with ls', async () => {
const content = new TextEncoder().encode('new file content')
await files.add('newfile.txt', content)
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' },
{ name: 'newfile.txt', cid: expect.any(Object), size: expect.any(Number), path: 'newfile.txt', type: 'file', change: CHANGE_TYPE.NEW }
])
// Verify content can be read via cat
const readContent = await files.cat('newfile.txt')
expect(readContent).toEqual(content)
})
it('should add edit to existing file and verify with ls', async () => {
const content = new TextEncoder().encode('updated content')
await files.add('test.txt', content)
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file', change: CHANGE_TYPE.EDIT }
])
// Verify content can be read via cat
const readContent = await files.cat('test.txt')
expect(readContent).toEqual(content)
})
it('should remove file and verify with ls', async () => {
await files.rm('test.txt')
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file', change: CHANGE_TYPE.DELETE }
])
})
it('should read staged content with cat', async () => {
const content = new TextEncoder().encode('staged content')
await files.add('test.txt', content)
const result = await files.cat('/test.txt')
expect(result).toEqual(content)
})
it('should read filesystem content with cat when no staged content', async () => {
const result = await files.cat('/test.txt')
expect(result).toEqual(new TextEncoder().encode('test content'))
})
it('should fetch from dservice when file not in filesystem', async () => {
// First, add a file and commit it
const content = new TextEncoder().encode('remote file content')
await files.add('remote.txt', content)
// Stage the changes
const stagedResult = await files.stage()
// Finalize the commit - this updates the root
await files.finalizeCommit(stagedResult.cid)
// remove the remote.txt file from the blockstore
const remoteTxtCid = (await ls(blockstore, stagedResult.cid)).find(([name]) => name === 'remote.txt')[1]
await blockstore.delete(remoteTxtCid)
const newFiles = new Files(fs, blockstore, mockDservice, mockEnsureRepoData, mockStorage)
// Create root directory with _files folder containing staged result
const rootDir = await fs.cp(stagedResult.cid, await fs.addDirectory(), '_files')
await newFiles.unsafeSetRepoRoot(rootDir)
// Mock dservice response
const mockResponse = {
status: 200,
arrayBuffer: jest.fn().mockResolvedValue(content.buffer)
}
mockDservice.fetch.mockResolvedValue(mockResponse)
// Try to cat the file - this should trigger dservice fetch
const result = await newFiles.cat('remote.txt')
expect(result).toEqual(content)
expect(mockDservice.fetch).toHaveBeenCalledWith('/file?cid=' + remoteTxtCid.toString())
})
it('should restore staged change', async () => {
const content = new TextEncoder().encode('content to restore')
await files.add('restore.txt', content)
// Verify it's staged
let lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({ name: 'restore.txt', change: CHANGE_TYPE.NEW }))
// Restore it
await files.restore('restore.txt')
// Verify it's removed from ls
lsResult = await files.ls('/')
expect(lsResult).not.toContainEqual(expect.objectContaining({ name: 'restore.txt', change: CHANGE_TYPE.NEW }))
// Verify it's no longer accessible via cat
await expect(files.cat('restore.txt')).rejects.toThrow()
})
it('should restore file deletion', async () => {
await files.rm('test.txt')
// Verify it's staged
let lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({ name: 'test.txt', change: CHANGE_TYPE.DELETE }))
await files.restore('test.txt')
lsResult = await files.ls('/')
expect(lsResult).not.toContainEqual(expect.objectContaining({ name: 'test.txt', change: CHANGE_TYPE.DELETE }))
})
it('should clear all changes', async () => {
// Add multiple staged changes
await files.add('file1.txt', new TextEncoder().encode('content1'))
await files.add('file2.txt', new TextEncoder().encode('content2'))
await files.rm('test.txt')
// Verify they're staged
let lsResult = await files.ls('/')
expect(lsResult).toHaveLength(3) // 'test.txt' (deleted) + 'file1.txt' + 'file2.txt'
// Clear all changes
await files.clearChanges()
// Verify they're all removed from ls
lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' }
])
// Verify files are no longer accessible via cat
await expect(files.cat('/file1.txt')).rejects.toThrow()
await expect(files.cat('/file2.txt')).rejects.toThrow()
// test.txt should still be accessible since it was restored
const testContent = await files.cat('/test.txt')
expect(testContent).toEqual(new TextEncoder().encode('test content'))
})
})
describe('stage', () => {
beforeEach(async () => {
await files.unsafeSetRepoRoot(repoRootCid)
})
it('should return original _files CID when no changes', async () => {
const result = await files.stage()
expect(result.cid.toString()).toBe(filesCid.toString())
})
it('should return empty _files CID when folder did not exist', async () => {
// Create a repo root without _files directory
const emptyRepoRoot = await fs.addDirectory()
await files.unsafeSetRepoRoot(emptyRepoRoot)
const result = await files.stage()
expect(result).toBeDefined()
expect(result.toString()).not.toBe(filesCid.toString())
})
it('should create files properly in filesystem', async () => {
const content1 = new TextEncoder().encode('content1')
const content2 = new TextEncoder().encode('content2')
await files.add('/newfile1.txt', content1)
await files.add('/newfile2.txt', content2)
await files.rm('/test.txt')
const result = await files.stage()
// Verify the new root is different
expect(result.cid.toString()).not.toBe(filesCid.toString())
// Verify files were actually created in filesystem
await files.finalizeCommit(result.cid)
const newLs = await files.ls('/')
expect(newLs).toEqual([
{ name: 'newfile1.txt', cid: expect.any(Object), size: expect.any(Number), path: 'newfile1.txt', type: 'file' },
{ name: 'newfile2.txt', cid: expect.any(Object), size: expect.any(Number), path: 'newfile2.txt', type: 'file' }
// test.txt should be deleted
])
// Verify we can read the new files
const readContent1 = await files.cat('/newfile1.txt')
const readContent2 = await files.cat('/newfile2.txt')
expect(readContent1).toEqual(content1)
expect(readContent2).toEqual(content2)
})
})
describe('finalizeCommit', () => {
beforeEach(async () => {
await files.unsafeSetRepoRoot(repoRootCid)
})
it('should update root and clear changes', async () => {
// Add some staged changes
await files.add('file1.txt', new TextEncoder().encode('content1'))
await files.add('file2.txt', new TextEncoder().encode('content2'))
// Verify changes exist
let lsResult = await files.ls('/')
expect(lsResult).toHaveLength(3) // 'test.txt' (original) + 'file1.txt' + 'file2.txt'
// Stage the changes to get a new root
const newRoot = await files.stage()
// Finalize commit
await files.finalizeCommit(newRoot.cid)
// Verify changes are cleared
lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'file1.txt', cid: expect.any(Object), size: expect.any(Number), path: 'file1.txt', type: 'file' },
{ name: 'file2.txt', cid: expect.any(Object), size: expect.any(Number), path: 'file2.txt', type: 'file' },
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' }
])
})
it('should handle non-cached files', async () => {
// Add some files and folders
await files.rm('test.txt')
await files.add('file1.txt', new TextEncoder().encode('content1'))
await files.add('file2.txt', new TextEncoder().encode('content2'))
await files.mkdir('/folder1')
await files.add('/folder1/file3.txt', new TextEncoder().encode('content3'))
await files.add('/folder2/file4.txt', new TextEncoder().encode('content4'))
await files.add('/folder3/subfolder/file6.txt', new TextEncoder().encode('content6'))
// Stage and finalize
const stagedResult = await files.stage()
await files.finalizeCommit(stagedResult.cid)
// Create new files instance with new unixfs and blockstore
const { fs: newFs, blockstore: newBlockstore } = emptyUnixfs()
const newFiles = new Files(newFs, newBlockstore, mockDservice, mockEnsureRepoData, mockStorage)
mockDservice.fetch.mockImplementation(async (path) => {
const cid = path.split('?cid=')[1]
const block = await blockstore.get(CID.parse(cid))
return {
status: 200,
arrayBuffer: jest.fn().mockResolvedValue(block)
}
})
// Copy the staged result blocks from old blockstore to new blockstore
const rootBlock = await blockstore.get(stagedResult.cid)
await newBlockstore.put(stagedResult.cid, rootBlock)
// Create a repo root with _files directory containing the committed state
const emptyDir = await newFs.addDirectory()
const repoRootWithFiles = await newFs.cp(stagedResult.cid, emptyDir, '_files')
// Set the repo root to the new repo root containing _files
await newFiles.unsafeSetRepoRoot(repoRootWithFiles)
await newFiles.add('file1.txt', new TextEncoder().encode('content1-updated'))
await newFiles.add('/folder2/file5.txt', new TextEncoder().encode('content5'))
await newFiles.add('/folder3/subfolder/file7.txt', new TextEncoder().encode('content7'))
// Stage again (without changes)
const newStagedResult = await newFiles.stage()
// Ensure unchangedCids are correct - should contain all the CIDs from the committed state
expect(Array.from(newStagedResult.unchangedCids.values())).toEqual([
(await fs.stat(stagedResult.cid, { path: '/file2.txt' })).cid.toString(),
(await fs.stat(stagedResult.cid, { path: '/folder1' })).cid.toString(),
(await fs.stat(stagedResult.cid, { path: '/folder2/file4.txt' })).cid.toString(),
(await fs.stat(stagedResult.cid, { path: '/folder3/subfolder/file6.txt' })).cid.toString()
])
// Ensure mockDservice is called as expected (if any fetch calls are made during content loading)
// The mockDservice.fetch should be called when ensuring content for non-cached CIDs
expect(mockDservice.fetch).toHaveBeenCalled()
})
})
describe('mkdir', () => {
beforeEach(async () => {
await files.unsafeSetRepoRoot(repoRootCid)
})
it('should list folder in ls when mkdir is called', async () => {
await files.mkdir('/newfolder')
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' },
{ name: 'newfolder', cid: expect.any(Object), size: expect.any(Number), path: 'newfolder', type: 'directory', change: CHANGE_TYPE.NEW }
])
})
it('should create folder if commit without any files in the folder', async () => {
await files.mkdir('/emptyfolder')
// Stage the changes
const stagedResult = await files.stage()
// Finalize the commit
await files.finalizeCommit(stagedResult.cid)
// Verify the folder is not in the ls after commit
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'emptyfolder', cid: expect.any(Object), size: expect.any(Number), path: 'emptyfolder', type: 'directory' },
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' }
])
})
it('should create folder if files are added to the folder', async () => {
await files.mkdir('/newfolder')
await files.add('/newfolder/file.txt', new TextEncoder().encode('file content'))
// Stage the changes
const stagedResult = await files.stage()
// Finalize the commit
await files.finalizeCommit(stagedResult.cid)
// Verify the folder and file are in the ls after commit
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'newfolder', cid: expect.any(Object), size: expect.any(Number), path: 'newfolder', type: 'directory' },
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' }
])
// Check the folder contents
const folderLs = await files.ls('/newfolder')
expect(folderLs).toEqual([
{ name: 'file.txt', cid: expect.any(Object), size: expect.any(Number), path: 'newfolder/file.txt', type: 'file' }
])
})
it('should not create duplicate mkdir changes for same folder', async () => {
await files.mkdir('/newfolder')
await expect(files.mkdir('/newfolder')).rejects.toThrow('Directory or file already exists: /newfolder')
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' },
{ name: 'newfolder', cid: expect.any(Object), size: expect.any(Number), path: 'newfolder', type: 'directory', change: CHANGE_TYPE.NEW }
])
})
it('should not create mkdir change if folder already exists', async () => {
// First create the folder with a file
await files.mkdir('/newfolder')
await files.add('/newfolder/file.txt', new TextEncoder().encode('file content'))
// Stage and commit to create the folder
const stagedResult = await files.stage()
await files.finalizeCommit(stagedResult.cid)
// Try to mkdir the same folder again
await expect(files.mkdir('/newfolder')).rejects.toThrow('Directory or file already exists: /newfolder')
const lsResult = await files.ls('/')
expect(lsResult).toEqual([
{ name: 'newfolder', cid: expect.any(Object), size: expect.any(Number), path: 'newfolder', type: 'directory' },
{ name: 'test.txt', cid: expect.any(Object), size: expect.any(Number), path: 'test.txt', type: 'file' }
])
const lsFolder = await files.ls('/newfolder')
expect(lsFolder).toEqual([
{ name: 'file.txt', cid: expect.any(Object), size: expect.any(Number), path: 'newfolder/file.txt', type: 'file' }
])
})
})
describe('avatar methods', () => {
beforeEach(async () => {
await files.unsafeSetRepoRoot(repoRootCid)
})
it('should set avatar with correct filename', async () => {
const avatarContent = new TextEncoder().encode('avatar image data')
const fileExt = 'png'
await files.setAvatar(avatarContent, fileExt)
// Verify avatar is added to the filesystem
const lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.png',
type: 'file',
change: CHANGE_TYPE.NEW
}))
// Verify avatar content can be read
const readContent = await files.cat('/.avatar.png')
expect(readContent).toEqual(avatarContent)
})
it('should replace existing avatar when setting new one', async () => {
// First set an avatar
const firstAvatar = new TextEncoder().encode('first avatar')
await files.setAvatar(firstAvatar, 'jpg')
// Set a different avatar
const secondAvatar = new TextEncoder().encode('second avatar')
await files.setAvatar(secondAvatar, 'png')
// Verify only the new avatar exists
const lsResult = await files.ls('/')
const avatarFiles = lsResult.filter(f => f.name.startsWith('.avatar.'))
expect(avatarFiles).toHaveLength(1)
expect(avatarFiles[0]).toEqual(expect.objectContaining({
name: '.avatar.png',
type: 'file',
change: CHANGE_TYPE.NEW
}))
// Verify old avatar content is gone
await expect(files.cat('/.avatar.jpg')).rejects.toThrow()
// Verify new avatar content is correct
const readContent = await files.cat('/.avatar.png')
expect(readContent).toEqual(secondAvatar)
})
it('should get avatar path with prefix when noPrefix is false', async () => {
const avatarContent = new TextEncoder().encode('avatar data')
await files.setAvatar(avatarContent, 'svg')
const avatarPath = await files.getAvatarPath(false)
expect(avatarPath).toBe(`/${FILES_FOLDER}/.avatar.svg`)
})
it('should get avatar path without prefix when noPrefix is true', async () => {
const avatarContent = new TextEncoder().encode('avatar data')
await files.setAvatar(avatarContent, 'gif')
const avatarPath = await files.getAvatarPath(true)
expect(avatarPath).toBe('.avatar.gif')
})
it('should return undefined when no avatar is set', async () => {
const avatarPath = await files.getAvatarPath()
expect(avatarPath).toBeUndefined()
})
it('should return undefined when no avatar is set (noPrefix true)', async () => {
const avatarPath = await files.getAvatarPath(true)
expect(avatarPath).toBeUndefined()
})
it('should handle avatar with different file extensions', async () => {
const extensions = ['jpg', 'jpeg', 'png', 'gif', 'svg', 'webp']
for (const ext of extensions) {
const avatarContent = new TextEncoder().encode(`avatar ${ext}`)
await files.setAvatar(avatarContent, ext)
const avatarPath = await files.getAvatarPath()
expect(avatarPath).toBe(`/${FILES_FOLDER}/.avatar.${ext}`)
// Verify content
const readContent = await files.cat(`/.avatar.${ext}`)
expect(readContent).toEqual(avatarContent)
}
})
it('should stage avatar changes correctly', async () => {
const avatarContent = new TextEncoder().encode('staged avatar')
await files.setAvatar(avatarContent, 'png')
// Stage the changes
const stagedResult = await files.stage()
// Verify the new root is different
expect(stagedResult.cid.toString()).not.toBe(filesCid.toString())
// Finalize commit
await files.finalizeCommit(stagedResult.cid)
// Verify avatar is now part of the committed state
const lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.png',
type: 'file'
}))
// Verify avatar content is accessible
const readContent = await files.cat('/.avatar.png')
expect(readContent).toEqual(avatarContent)
})
it('should clear avatar changes when clearing all changes', async () => {
const avatarContent = new TextEncoder().encode('avatar to clear')
await files.setAvatar(avatarContent, 'jpg')
// Verify avatar is staged
let lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.jpg',
change: CHANGE_TYPE.NEW
}))
// Clear all changes
await files.clearChanges()
// Verify avatar is no longer in ls
lsResult = await files.ls('/')
expect(lsResult).not.toContainEqual(expect.objectContaining({
name: '.avatar.jpg'
}))
// Verify avatar is no longer accessible
await expect(files.cat('/.avatar.jpg')).rejects.toThrow()
})
it('should restore avatar to committed state', async () => {
// First set and commit an avatar
const originalAvatar = new TextEncoder().encode('original avatar')
await files.setAvatar(originalAvatar, 'png')
const stagedResult = await files.stage()
await files.finalizeCommit(stagedResult.cid)
// Change the avatar
const newAvatar = new TextEncoder().encode('new avatar')
await files.setAvatar(newAvatar, 'jpg')
// Verify new avatar is staged
let lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.jpg',
change: CHANGE_TYPE.NEW
}))
// Restore the original avatar
await files.restore('/.avatar.png')
// Verify original avatar is restored
lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.png'
}))
// Verify original content is accessible
const readContent = await files.cat('/.avatar.png')
expect(readContent).toEqual(originalAvatar)
})
it('should handle avatar removal and restoration', async () => {
// First set and commit an avatar
const avatarContent = new TextEncoder().encode('avatar to remove')
await files.setAvatar(avatarContent, 'png')
const stagedResult = await files.stage()
await files.finalizeCommit(stagedResult.cid)
// Remove the avatar
await files.rm('/.avatar.png')
// Verify avatar is marked for deletion
let lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.png',
change: CHANGE_TYPE.DELETE
}))
// Restore the avatar
await files.restore('/.avatar.png')
// Verify avatar is no longer marked for deletion
lsResult = await files.ls('/')
expect(lsResult).toContainEqual(expect.objectContaining({
name: '.avatar.png'
}))
// Verify avatar content is accessible again
const readContent = await files.cat('/.avatar.png')
expect(readContent).toEqual(avatarContent)
})
it('should handle multiple avatar operations in sequence', async () => {
// Set first avatar
const avatar1 = new TextEncoder().encode('avatar 1')
await files.setAvatar(avatar1, 'png')
// Change to second avatar
const avatar2 = new TextEncoder().encode('avatar 2')
await files.setAvatar(avatar2, 'jpg')
// Change to third avatar
const avatar3 = new TextEncoder().encode('avatar 3')
await files.setAvatar(avatar3, 'gif')
// Verify only the last avatar exists
const lsResult = await files.ls('/')
const avatarFiles = lsResult.filter(f => f.name.startsWith('.avatar.'))
expect(avatarFiles).toHaveLength(1)
expect(avatarFiles[0]).toEqual(expect.objectContaining({
name: '.avatar.gif',
change: CHANGE_TYPE.NEW
}))
// Verify content is correct
const readContent = await files.cat('/.avatar.gif')
expect(readContent).toEqual(avatar3)
})
})
})