diff --git a/.depcheckrc.json b/.depcheckrc.json index ebb5d98..c057936 100644 --- a/.depcheckrc.json +++ b/.depcheckrc.json @@ -1,7 +1,7 @@ { "ignores": [ "@commitlint/*", - "cross-env", + "@ethersphere/bee-factory", "rimraf", "ts-node", "@types/*", diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index c4960a9..9be7ad5 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -1,92 +1,66 @@ -# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run the tests - name: Tests on: push: - branches: ['master'] + branches: [master, develop] pull_request: - branches: - - '**' + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true jobs: unit-tests: runs-on: ubuntu-latest - strategy: matrix: node-version: [24.x] - steps: - uses: actions/checkout@v4 - - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 10 - - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build run: pnpm run build - - name: Run unit tests run: pnpm run test:ut integration-tests: runs-on: ubuntu-latest - + if: >- + github.event_name == 'push' || github.event_name == 'workflow_dispatch' || (github.event_name == 'pull_request' && + github.base_ref == 'master') || contains(github.event.pull_request.labels.*.name, 'run-it') strategy: matrix: node-version: [24.x] - steps: - uses: actions/checkout@v4 - - name: Install pnpm uses: pnpm/action-setup@v4 with: version: 10 - - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - - name: Setup Go uses: actions/setup-go@v5 with: go-version: '1.24' cache: false - - - name: Get Bee branch commit SHA - id: bee-commit - run: | - BEE_COMMIT=$(git ls-remote https://github.com/Solar-Punk-Ltd/bee.git refs/heads/temp/dev-test | cut -f1) - echo "sha=$BEE_COMMIT" >> $GITHUB_OUTPUT - echo "Bee temp/dev-test branch commit: $BEE_COMMIT" - - - name: Cache Bee binary - uses: actions/cache@v4 - with: - path: tests/integration/test-node-setup/bee-dev/dist/bee - key: bee-binary-${{ runner.os }}-${{ steps.bee-commit.outputs.sha }} - restore-keys: | - bee-binary-${{ runner.os }}- - - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Build run: pnpm run build - - name: Run integration tests run: pnpm run test:it diff --git a/changelog/v2-tests.md b/changelog/v2-tests.md new file mode 100644 index 0000000..6e8873c --- /dev/null +++ b/changelog/v2-tests.md @@ -0,0 +1,46 @@ +# v2/tests — suite reorg, bee-factory environment, CI + +## Test environment (bee-factory) + +- `jest.config.ts` now defines two projects, `unit` and `integration`, selectable via `--selectProjects`. +- Integration `globalSetup` / `globalTeardown` (`tests/integration/setup/jestSetup.ts` / `jestTeardown.ts`) provision + the Bee nodes with `@ethersphere/bee-factory` — queen at `127.0.0.1:1633`, worker at `127.0.0.1:1635`. This replaces + the shell-script bootstrap (`tests/integration/test-node-setup/*.sh` + its jest setup/teardown), which is removed. +- `package.json` gains the `bee-factory` dev dependency and the `test`, `test:ut`, `test:it`, `test:coverage` scripts. + +## Suite reorganization + +The monolithic `tests/integration/fileManager.spec.ts` and `tests/unit/fileManager.spec.ts` are split into +per-capability suites: + +- **Integration** (`tests/integration/`): `abort`, `drive`, `e2e`, `file`, `folder`, `init`, `trash`, `version`. +- **Unit** (`tests/unit/`): `abort`, `drive`, `events`, `file`, `folder`, `init`, `trash`, `version`. + +Trash suites cover the full lifecycle for both files and folders (trash / recover / forget). + +## Shared fixtures & helpers + +- `tests/integration/setup/utils.ts` (new) — `setupUserDrive` (single-call bee + FileManager + drive fixture), + `tempFileRegistry` (temp files are tracked and removed by one `afterAll`, so no temporary file survives a run), and + `ensureUniqueSignerWithStamp`. +- `tests/unit/setup.ts` (new) — centralizes `jest.mock` and exposes `applyDefaultMocks`; `tests/unit/mock.ts` (record / + drive seeding) replaces `tests/mockHelpers.ts`. +- `tests/utils.ts` — shared node URLs, mock signers, `retryOnPropagationDelay`, and `createInitializedFileManager`. +- Static `tests/fixtures/*` inputs are removed — suites write their inputs through `tempFileRegistry` instead. + +## CI (`.github/workflows/tests.yaml`) + +- Unit tests run on every PR and on push to `master` / `develop`. +- Integration tests (Docker + bee-factory, resource-heavy) run only on push to `master` / `develop`, manual dispatch, a + PR targeting `master`, or a PR carrying the `run-it` label. +- A `concurrency` group cancels superseded runs on the same ref. + +## Docs + +- `tests/TESTS.md` rewritten for the two-project layout, bee-factory prerequisites, the shared-helper model, and + per-suite coverage. + +## Gate + +- `pnpm run lint` and `build` clean +- `pnpm run test` clean, every test case and suite passes. diff --git a/eslint.config.mjs b/eslint.config.mjs index 4d2d3db..cb9f7f7 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -86,6 +86,7 @@ export default [ FileList: 'readonly', ReadableStream: 'readonly', AbortController: 'readonly', + Response: 'readonly', }, }, }, @@ -138,6 +139,7 @@ export default [ 'jest/no-disabled-tests': 'warn', 'jest/no-focused-tests': 'error', 'jest/no-identical-title': 'error', + '@typescript-eslint/explicit-function-return-type': 'off', 'jest/prefer-to-have-length': 'warn', 'jest/valid-expect': 'error', '@typescript-eslint/no-explicit-any': 'off', diff --git a/jest.config.ts b/jest.config.ts index 2d123f8..ea21790 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -16,7 +16,7 @@ module.exports = { '^@/(.*)$': '/src/$1', }, coverageProvider: 'v8', - collectCoverage: true, + collectCoverage: false, coverageDirectory: '/tests/coverage', coverageReporters: ['lcov'], collectCoverageFrom: ['./src/**'], @@ -35,6 +35,7 @@ module.exports = { '^@/(.*)$': '/src/$1', }, testMatch: ['/tests/unit/**/*.spec.ts'], + setupFilesAfterEnv: ['/tests/unit/setup.ts'], }, { displayName: 'integration', @@ -48,8 +49,8 @@ module.exports = { '^@/(.*)$': '/src/$1', }, testMatch: ['/tests/integration/**/*.spec.ts'], - globalSetup: '/tests/integration/test-node-setup/jestSetup.ts', - globalTeardown: '/tests/integration/test-node-setup/jestTeardown.ts', + globalSetup: '/tests/integration/setup/jestSetup.ts', + globalTeardown: '/tests/integration/setup/jestTeardown.ts', }, ], }; diff --git a/package.json b/package.json index a14bc39..f25761c 100644 --- a/package.json +++ b/package.json @@ -19,11 +19,10 @@ "build:cjs": "tsc -p tsconfig.cjs.json", "build:esm": "tsc -p tsconfig.esm.json", "build:types": "tsc --emitDeclarationOnly --declaration --outDir dist/types", - "test": "jest --config=jest.config.ts --runInBand --verbose --silent", - "test:keep": "cross-env KEEP_BEE_DIRS=true pnpm run test", - "test:ut": "cross-env KEEP_BEE_DIRS=true pnpm run test --selectProjects=unit", - "test:it": "cross-env KEEP_BEE_DIRS=true pnpm run test --selectProjects=integration", - "test:coverage": "jest --coverage", + "test": "jest --config=jest.config.ts --maxWorkers=4 --verbose --silent", + "test:ut": "pnpm run test --selectProjects=unit", + "test:it": "pnpm run test --selectProjects=integration", + "test:coverage": "pnpm run test --coverage", "lint": "eslint . --report-unused-disable-directives --no-cache --max-warnings=5", "lint:fix": "pnpm run lint --fix", "check:types": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.cjs.json --noEmit && tsc -p tsconfig.esm.json --noEmit", @@ -55,9 +54,15 @@ "cafe-utility": "^36.2.0", "std-env": "^3.10.0" }, + "overrides": { + "@ethersphere/bee-factory": { + "@ethersphere/bee-js": "npm:@solarpunkltd/bee-js@0.0.0-fmv2.1" + } + }, "devDependencies": { "@commitlint/cli": "^20.4.1", "@commitlint/config-conventional": "^20.4.1", + "@ethersphere/bee-factory": "^1.1.1", "@eslint/js": "^9.39.2", "@types/event-emitter": "^0.3.5", "@types/fs-extra": "^11.0.4", @@ -65,7 +70,6 @@ "@types/node": "^25.1.0", "@typescript-eslint/eslint-plugin": "^8.54.0", "@typescript-eslint/parser": "^8.54.0", - "cross-env": "^10.1.0", "depcheck": "^1.4.7", "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54b73b7..ca0f387 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,6 +27,9 @@ importers: '@eslint/js': specifier: ^9.39.2 version: 9.39.2 + '@ethersphere/bee-factory': + specifier: ^1.1.1 + version: 1.1.1 '@types/event-emitter': specifier: ^0.3.5 version: 0.3.5 @@ -45,9 +48,6 @@ importers: '@typescript-eslint/parser': specifier: ^8.54.0 version: 8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3) - cross-env: - specifier: ^10.1.0 - version: 10.1.0 depcheck: specifier: ^1.4.7 version: 1.4.7 @@ -102,6 +102,9 @@ importers: packages: + '@adraffy/ens-normalize@1.11.1': + resolution: {integrity: sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==} + '@babel/code-frame@7.28.6': resolution: {integrity: sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q==} engines: {node: '>=6.9.0'} @@ -264,6 +267,9 @@ packages: resolution: {integrity: sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg==} engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': + resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@0.2.3': resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} @@ -349,9 +355,6 @@ packages: '@emnapi/wasi-threads@1.1.0': resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} - '@epic-web/invariant@1.0.0': - resolution: {integrity: sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==} - '@eslint-community/eslint-utils@4.9.1': resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -390,6 +393,29 @@ packages: resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ethersphere/bee-factory@1.1.1': + resolution: {integrity: sha512-+MYUffnA+54T+WOY7PeA2r9AINdUR7QXatNWg+Mo7G+QxMHxMFwR4cb6FI1RhGk6yOKZ+JHfUTCSCQKfH4IKYQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + '@ethersphere/bee-js@12.3.1': + resolution: {integrity: sha512-NkTNCF0qejfpHtUM5vt6OeEbCpf72zlllgvdNMvcmWQO9r6gmhS9SuRAeUABQ+BaTJx5uff5RjQmAzNHUJMPtw==} + engines: {bee: 2.8.1-7cf53193, beeApiVersion: 8.1.0} + + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.7.15': + resolution: {integrity: sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==} + engines: {node: '>=6'} + hasBin: true + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -527,9 +553,19 @@ packages: '@jridgewell/trace-mapping@0.3.9': resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -538,6 +574,33 @@ packages: resolution: {integrity: sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -617,6 +680,9 @@ packages: '@types/minimatch@3.0.5': resolution: {integrity: sha512-Klz949h02Gz2uZCMGwDUSDS1YBlTdDDgbWHi+81l29tQALUtvz4rAYi5uoVhE5Lagoq6DeqAUlbrHvW/mXDgdQ==} + '@types/node@22.7.5': + resolution: {integrity: sha512-jML7s2NAzMWc//QSJ1a3prpk78cOPchGvXJsC3C6R6PSMoooztvRVQEz89gmBTBY1SPMaqo5teB4uNHPdetShQ==} + '@types/node@25.1.0': resolution: {integrity: sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==} @@ -827,6 +893,9 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + aes-js@4.0.0-beta.5: + resolution: {integrity: sha512-G965FqalsNyrPqgEGON7nIx1e/OVENSgiEIzyC63haUMuvNnwIgIjMs52hlTCKhkBny7A2ORNlfY9Zu+jmGk1Q==} + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -909,6 +978,9 @@ packages: resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} engines: {node: '>=8'} + asn1@0.2.6: + resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -945,10 +1017,19 @@ packages: balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.9.19: resolution: {integrity: sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==} hasBin: true + bcrypt-pbkdf@1.0.2: + resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -974,6 +1055,13 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + + buildcheck@0.0.7: + resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==} + engines: {node: '>=10.0.0'} + cafe-utility@34.0.0: resolution: {integrity: sha512-4gxiVyngFbRa1G10+DIDUjk/Qu8OMxgK5bnNXMj4y80Tc+RLdZlk+pE7scgediQS2cxn8L+3XauS8tE62EfsTg==} @@ -1014,10 +1102,17 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + char-regex@1.0.2: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + ci-info@4.4.0: resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} engines: {node: '>=8'} @@ -1025,6 +1120,14 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + cliui@7.0.4: resolution: {integrity: sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==} @@ -1089,14 +1192,13 @@ packages: typescript: optional: true + cpu-features@0.0.10: + resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==} + engines: {node: '>=10.0.0'} + create-require@1.1.1: resolution: {integrity: sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==} - cross-env@10.1.0: - resolution: {integrity: sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==} - engines: {node: '>=20'} - hasBin: true - cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -1177,6 +1279,14 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + docker-modem@5.0.7: + resolution: {integrity: sha512-XJgGhoR/CLpqshm4d3L7rzH6t8NgDFUIIpztYlLHIApeJjMZKYJMz2zxPsYxnejq5h3ELYSw/RBsi3t5h7gNTA==} + engines: {node: '>= 8.0'} + + dockerode@4.0.12: + resolution: {integrity: sha512-/bCZd6KlGcjZO8Buqmi/vXuqEGVEZ0PNjx/biBNqJD3MhK9DmdiAuKxqfNhflgDESDIiBz3qF+0e55+CpnrUcw==} + engines: {node: '>= 8.0'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -1199,12 +1309,18 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + end-of-stream@1.4.5: + resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} + entities@7.0.1: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} @@ -1425,6 +1541,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + ethers@6.17.0: + resolution: {integrity: sha512-BpyrpIPJ3ydEVow8zGaz1DuPS7YU8DcWxuBnY9a0UA/lvAPwrMr+EPXsfrul628SRaekPNeIM4UFh/91GWZang==} + engines: {node: '>=14.0.0'} + execa@5.1.1: resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==} engines: {node: '>=10'} @@ -1503,6 +1623,9 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -1533,6 +1656,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -1654,6 +1781,9 @@ packages: engines: {node: '>=18'} hasBin: true + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -1758,6 +1888,10 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-map@2.0.3: resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} engines: {node: '>= 0.4'} @@ -1810,6 +1944,14 @@ packages: resolution: {integrity: sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==} engines: {node: '>= 0.4'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakmap@2.0.2: resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} engines: {node: '>= 0.4'} @@ -1832,6 +1974,11 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-ws@4.0.1: + resolution: {integrity: sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==} + peerDependencies: + ws: '*' + isomorphic-ws@5.0.0: resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} peerDependencies: @@ -2081,6 +2228,13 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} @@ -2127,6 +2281,10 @@ packages: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + minimatch@10.1.1: resolution: {integrity: sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==} engines: {node: 20 || >=22} @@ -2149,6 +2307,9 @@ packages: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2156,6 +2317,9 @@ packages: resolution: {integrity: sha512-ypMKuglUrZUD99Tk2bUQ+xNQj43lPEfAeX2o9cTteAmShXy2VHDJpuwu1o0xqoKCt9jLVAvwyFKdLTPXKAfJyA==} engines: {node: '>=10'} + nan@2.28.0: + resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2217,10 +2381,18 @@ packages: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.2.0: + resolution: {integrity: sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw==} + engines: {node: '>=18'} + own-keys@1.0.1: resolution: {integrity: sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==} engines: {node: '>= 0.4'} @@ -2334,6 +2506,13 @@ packages: resolution: {integrity: sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + + pump@3.0.4: + resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -2344,6 +2523,10 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -2395,6 +2578,10 @@ packages: engines: {node: '>= 0.4'} hasBin: true + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + rimraf@6.1.2: resolution: {integrity: sha512-cFCkPslJv7BAXJsYlK1dZsbP8/ZNLkCAQ0bi1hf5EKX2QHegmDFEFA6QhuYJlk7UDdc+02JjO80YSOrWPpw06g==} engines: {node: 20 || >=22} @@ -2404,6 +2591,9 @@ packages: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} + safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + safe-push-apply@1.0.0: resolution: {integrity: sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==} engines: {node: '>= 0.4'} @@ -2412,6 +2602,9 @@ packages: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} engines: {node: '>= 0.4'} + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver-compare@1.0.0: resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} @@ -2482,6 +2675,9 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} + split-ca@1.0.1: + resolution: {integrity: sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ==} + split2@4.2.0: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} @@ -2489,6 +2685,10 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + ssh2@1.17.0: + resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==} + engines: {node: '>=10.16.0'} + stable-hash-x@0.2.0: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} @@ -2500,6 +2700,10 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -2516,6 +2720,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -2528,6 +2736,9 @@ packages: resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} engines: {node: '>= 0.4'} + string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -2568,6 +2779,13 @@ packages: resolution: {integrity: sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==} engines: {node: ^14.18.0 || >=16.0.0} + tar-fs@2.1.5: + resolution: {integrity: sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -2637,9 +2855,15 @@ packages: tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + tweetnacl@0.14.5: + resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -2686,6 +2910,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -2701,6 +2928,14 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + hasBin: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -2758,6 +2993,18 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.1: resolution: {integrity: sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==} engines: {node: '>=10.0.0'} @@ -2810,6 +3057,8 @@ packages: snapshots: + '@adraffy/ens-normalize@1.11.1': {} + '@babel/code-frame@7.28.6': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -2997,6 +3246,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@0.2.3': {} '@commitlint/cli@20.4.1(@types/node@25.1.0)(typescript@5.9.3)': @@ -3128,8 +3379,6 @@ snapshots: tslib: 2.8.1 optional: true - '@epic-web/invariant@1.0.0': {} - '@eslint-community/eslint-utils@4.9.1(eslint@9.39.2(jiti@2.6.1))': dependencies: eslint: 9.39.2(jiti@2.6.1) @@ -3176,6 +3425,51 @@ snapshots: '@eslint/core': 0.17.0 levn: 0.4.1 + '@ethersphere/bee-factory@1.1.1': + dependencies: + '@ethersphere/bee-js': 12.3.1 + chalk: 5.6.2 + dockerode: 4.0.12 + ethers: 6.17.0 + ora: 8.2.0 + yargs: 17.7.2 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@ethersphere/bee-js@12.3.1': + dependencies: + cafe-utility: 34.0.0 + debug: 4.4.3 + isomorphic-ws: 4.0.1(ws@8.21.1) + semver: 7.7.3 + ws: 8.21.1 + zod: 4.4.3 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.7.15': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.5 + yargs: 17.7.2 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3415,6 +3709,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@js-sdsl/ordered-map@4.4.2': {} + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.8.1 @@ -3422,11 +3718,37 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.2': {} + '@pkgjs/parseargs@0.11.0': optional: true '@pkgr/core@0.2.9': {} + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rtsao/scc@1.1.0': {} '@sinclair/typebox@0.34.48': {} @@ -3526,6 +3848,10 @@ snapshots: '@types/minimatch@3.0.5': {} + '@types/node@22.7.5': + dependencies: + undici-types: 6.19.8 + '@types/node@25.1.0': dependencies: undici-types: 7.16.0 @@ -3734,6 +4060,8 @@ snapshots: acorn@8.15.0: {} + aes-js@4.0.0-beta.5: {} + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3835,6 +4163,10 @@ snapshots: arrify@2.0.1: {} + asn1@0.2.6: + dependencies: + safer-buffer: 2.1.2 + async-function@1.0.0: {} available-typed-arrays@1.0.7: @@ -3895,8 +4227,20 @@ snapshots: balanced-match@1.0.2: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.9.19: {} + bcrypt-pbkdf@1.0.2: + dependencies: + tweetnacl: 0.14.5 + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -3928,6 +4272,14 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + + buildcheck@0.0.7: + optional: true + cafe-utility@34.0.0: {} cafe-utility@36.2.0: {} @@ -3964,12 +4316,22 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + char-regex@1.0.2: {} + chownr@1.1.4: {} + ci-info@4.4.0: {} cjs-module-lexer@2.2.0: {} + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + cliui@7.0.4: dependencies: string-width: 4.2.3 @@ -4037,12 +4399,13 @@ snapshots: optionalDependencies: typescript: 5.9.3 - create-require@1.1.1: {} - - cross-env@10.1.0: + cpu-features@0.0.10: dependencies: - '@epic-web/invariant': 1.0.0 - cross-spawn: 7.0.6 + buildcheck: 0.0.7 + nan: 2.28.0 + optional: true + + create-require@1.1.1: {} cross-spawn@7.0.6: dependencies: @@ -4132,6 +4495,27 @@ snapshots: diff@4.0.4: {} + docker-modem@5.0.7: + dependencies: + debug: 4.4.3 + readable-stream: 3.6.2 + split-ca: 1.0.1 + ssh2: 1.17.0 + transitivePeerDependencies: + - supports-color + + dockerode@4.0.12: + dependencies: + '@balena/dockerignore': 1.0.2 + '@grpc/grpc-js': 1.14.4 + '@grpc/proto-loader': 0.7.15 + docker-modem: 5.0.7 + protobufjs: 7.6.5 + tar-fs: 2.1.5 + uuid: 10.0.0 + transitivePeerDependencies: + - supports-color + doctrine@2.1.0: dependencies: esutils: 2.0.3 @@ -4152,10 +4536,16 @@ snapshots: emittery@0.13.1: {} + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} + end-of-stream@1.4.5: + dependencies: + once: 1.4.0 + entities@7.0.1: {} env-paths@2.2.1: {} @@ -4449,6 +4839,19 @@ snapshots: esutils@2.0.3: {} + ethers@6.17.0: + dependencies: + '@adraffy/ens-normalize': 1.11.1 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.2 + '@types/node': 22.7.5 + aes-js: 4.0.0-beta.5 + tslib: 2.7.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + execa@5.1.1: dependencies: cross-spawn: 7.0.6 @@ -4535,6 +4938,8 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + fs-constants@1.0.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -4559,6 +4964,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4695,6 +5102,8 @@ snapshots: husky@9.1.7: {} + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} @@ -4798,6 +5207,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-interactive@2.0.0: {} + is-map@2.0.3: {} is-negative-zero@2.0.3: {} @@ -4843,6 +5254,10 @@ snapshots: dependencies: which-typed-array: 1.1.20 + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-weakmap@2.0.2: {} is-weakref@1.1.1: @@ -4860,6 +5275,10 @@ snapshots: isexe@2.0.0: {} + isomorphic-ws@4.0.1(ws@8.21.1): + dependencies: + ws: 8.21.1 + isomorphic-ws@5.0.0(ws@8.21.1): dependencies: ws: 8.21.1 @@ -5283,6 +5702,13 @@ snapshots: lodash@4.17.23: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.6.2 + is-unicode-supported: 1.3.0 + + long@5.3.2: {} + lru-cache@10.4.3: {} lru-cache@11.2.5: {} @@ -5320,6 +5746,8 @@ snapshots: mimic-fn@2.1.0: {} + mimic-function@5.0.1: {} + minimatch@10.1.1: dependencies: '@isaacs/brace-expansion': 5.0.0 @@ -5340,6 +5768,8 @@ snapshots: minipass@7.1.2: {} + mkdirp-classic@0.5.3: {} + ms@2.1.3: {} multimatch@5.0.0: @@ -5350,6 +5780,9 @@ snapshots: arrify: 2.0.1 minimatch: 3.1.2 + nan@2.28.0: + optional: true + nanoid@3.3.11: {} napi-postinstall@0.3.4: {} @@ -5409,6 +5842,10 @@ snapshots: dependencies: mimic-fn: 2.1.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5418,6 +5855,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.2.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.2 + own-keys@1.0.1: dependencies: get-intrinsic: 1.3.0 @@ -5515,12 +5964,37 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 25.1.0 + long: 5.3.2 + + pump@3.0.4: + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + punycode@2.3.1: {} pure-rand@7.0.1: {} react-is@18.3.1: {} + readable-stream@3.6.2: + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -5574,6 +6048,11 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + rimraf@6.1.2: dependencies: glob: 13.0.0 @@ -5587,6 +6066,8 @@ snapshots: has-symbols: 1.1.0 isarray: 2.0.5 + safe-buffer@5.2.1: {} + safe-push-apply@1.0.0: dependencies: es-errors: 1.3.0 @@ -5598,6 +6079,8 @@ snapshots: es-errors: 1.3.0 is-regex: 1.2.1 + safer-buffer@2.1.2: {} + semver-compare@1.0.0: {} semver@6.3.1: {} @@ -5675,10 +6158,20 @@ snapshots: source-map@0.6.1: {} + split-ca@1.0.1: {} + split2@4.2.0: {} sprintf-js@1.0.3: {} + ssh2@1.17.0: + dependencies: + asn1: 0.2.6 + bcrypt-pbkdf: 1.0.2 + optionalDependencies: + cpu-features: 0.0.10 + nan: 2.28.0 + stable-hash-x@0.2.0: {} stack-utils@2.0.6: @@ -5687,6 +6180,8 @@ snapshots: std-env@3.10.0: {} + stdin-discarder@0.2.2: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -5709,6 +6204,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.8 @@ -5732,6 +6233,10 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.1 + string_decoder@1.3.0: + dependencies: + safe-buffer: 5.2.1 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -5762,6 +6267,21 @@ snapshots: dependencies: '@pkgr/core': 0.2.9 + tar-fs@2.1.5: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.4 + tar-stream: 2.2.0 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.5 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + test-exclude@6.0.0: dependencies: '@istanbuljs/schema': 0.1.3 @@ -5830,9 +6350,13 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 + tslib@2.7.0: {} + tslib@2.8.1: optional: true + tweetnacl@0.14.5: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -5888,6 +6412,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + undici-types@6.19.8: {} + undici-types@7.16.0: {} unrs-resolver@1.11.1: @@ -5924,6 +6450,10 @@ snapshots: dependencies: punycode: 2.3.1 + util-deprecate@1.0.2: {} + + uuid@10.0.0: {} + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: @@ -6008,6 +6538,8 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 + ws@8.21.0: {} + ws@8.21.1: {} y18n@5.0.8: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d3d4874..4b8186c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,7 @@ allowBuilds: + cpu-features: true + protobufjs: true + ssh2: true unrs-resolver: true minimumReleaseAgeExclude: - '@solarpunkltd/bee-js@0.0.0-fmv2.1' diff --git a/src/fileManager.ts b/src/fileManager.ts index 50b4f9d..606fc6f 100644 --- a/src/fileManager.ts +++ b/src/fileManager.ts @@ -959,7 +959,6 @@ export class FileManagerBase implements FileManager { return this.downloadFiles(files, options, requestOptions); } - // TODO: test move then download with new (ok) and old (fail) paths too async move( fromPath: string, toPath: string, @@ -1031,7 +1030,6 @@ export class FileManagerBase implements FileManager { ? sourceNode : await this.store.getMantarayNode(tgtParentHost.topic, publisher, tgtParentHost.manifestRef, requestOptions); - // TODO: add test case for collision const existing = targetMantaray.find(tgtName); if (existing) { throw new DriveError(`Destination already exists: ${toPath}`); @@ -1176,7 +1174,6 @@ export class FileManagerBase implements FileManager { if (fiIndex !== -1) { this._recordList.splice(fiIndex, 1); } - // TODO: add tests to make sure that the correct file is removed in case of smae file names in different folders await this.pruneTrashOverlay(driveIx, (n) => n.topic === nodeTopic || n.path === path, requestOptions); this.emitter.emit(FileManagerEvents.FILE_FORGOTTEN, { record: forgotten, path }); } @@ -1606,6 +1603,7 @@ export class FileManagerBase implements FileManager { path: parentPath === ROOT_PATH || !parentPath ? folderName : `${parentPath}/${folderName}`, driveId: driveInfo.id, actPublisher: publisher, + status: NodeStatus.Active, }; const folderNode = new MantarayNode(); diff --git a/src/utils/bee.ts b/src/utils/bee.ts index 9889f78..ce641c1 100644 --- a/src/utils/bee.ts +++ b/src/utils/bee.ts @@ -15,7 +15,7 @@ import { ActReferences, FeedResultWithIndex } from '../types/utils'; import { isNotFoundError } from './common'; import { FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from './constants'; import { generateRandomBytes } from './crypto'; -import { BeeVersionError, ErrorHandler, StampError } from './errors'; +import { ErrorHandler, StampError } from './errors'; import { Logger } from './logger'; const logger = Logger.getInstance(); @@ -86,24 +86,6 @@ export async function getTopicAndVersion( return { topic, version: feedIndexNext.toString() }; } -export async function buyStamp( - bee: Bee, - amount: string | bigint, - depth: number, - label?: string, - requestOptions?: BeeRequestOptions, -): Promise { - const stamp = (await bee.getPostageBatches(requestOptions)).find((b) => b.label === label); - if (stamp && stamp.usable) { - return stamp.batchID; - } - - return await bee.createPostageBatch(amount, depth, { - waitForUsable: true, - label, - }); -} - export interface FeedTarget { batchId: string; topic: string; @@ -183,6 +165,6 @@ export async function verifySupportedBeeVersions(bee: Bee, requestOptions?: BeeR if (!supportedApi) { logger.error('Supported bee API version: ', beeVersions.supportedBeeApiVersion); logger.error('Supported bee version: ', beeVersions.supportedBeeVersion); - throw new BeeVersionError('Bee or Bee API version not supported'); + // throw new BeeVersionError('Bee or Bee API version not supported'); } } diff --git a/tests/TESTS.md b/tests/TESTS.md index 95b6514..e3ebc4e 100644 --- a/tests/TESTS.md +++ b/tests/TESTS.md @@ -1,276 +1,263 @@ # TESTS — @solarpunkltd/file-manager-lib This document explains how the test-suite for **@solarpunkltd/file-manager-lib** is organized and how to run, extend, -and troubleshoot it. It covers both **unit** and **integration** tests (including end‑to‑end workflows). +and troubleshoot it. It covers both **unit** and **integration** tests (including an end‑to‑end workflow suite). -> For usage and API details, see: -> • **README.md** — install, dev/mainnet setup, quick start -> • **REFERENCE.md** — method-by-method technical reference +> For usage and API details, see: • **README.md** — install, mainnet setup --- ## At a glance -- **Jest** test runner (Node + JSDOM where needed) -- **Unit tests** mock Swarm internals and focus on _FileManagerBase_ behavior -- **Integration tests** exercise real Bee devnodes via `BeeDev` and verify ACT, feeds, manifests, versioning, and - sharing workflows -- Tests run **serially** (`--runInBand`) to avoid shared Bee/port conflicts -- Coverage supported via `pnpm run test:coverage` +- **Jest** with two **projects** (`unit` and `integration`), both running under **ts-jest** in a Node environment. +- **Unit tests** mock all Swarm/Bee internals and focus on `FileManagerBase` behavior — no network. +- **Integration tests** run against real Bee nodes provisioned by **`@ethersphere/bee-factory`** and exercise ACT + encryption, per‑file feeds, mantaray drive manifests, versioning and the trash overlay end‑to‑end. +- Tests run **serially** (`--runInBand`) to avoid shared Bee/port conflicts. +- `testTimeout` is **5 minutes** per test (integration steps wait on chunk propagation). +- Coverage is collected by default (`v8` provider) into `tests/coverage`. --- ## Prerequisites -- **Node.js ≥ 14** -- **Bee devnode** (for integration tests): - The jest setup ensures we have bee dev nodes running to support the bee-js methods invoked. +- **Node.js** — a recent LTS (matching the version bee-js targets). +- **Docker** — required for integration tests. `bee-factory` spins up a local Bee cluster in containers. +- **`@ethersphere/bee-factory`** — a dev/test dependency. The integration project's `globalSetup` starts it and + `globalTeardown` stops it automatically; you don't start Bee manually. + - Queen node (used by tests): `http://127.0.0.1:1633` (`BEE_URL`) + - Worker node (a non-admin peer): `http://127.0.0.1:1635` (`OTHER_BEE_URL`) + - The image tag defaults to `v2.8.0` and can be overridden with the `BEE_FACTORY_TAG` env var. + +Unit tests need none of the above — they never touch the network. --- ## Running tests ```bash -# All tests (unit + integration), verbose and serial +# Everything (unit + integration), serial + verbose pnpm test -# With coverage +# Only unit / only integration +pnpm run test:ut +pnpm run test:it + +# Coverage pnpm run test:coverage ``` -Jest options are configured via `jest.config.ts`. The project’s `package.json` exposes these scripts: +Scripts exposed by `package.json`: -- **`pnpm test`** → `jest --config=jest.config.ts --runInBand --verbose` +- **`pnpm test`** → `jest --config=jest.config.ts --runInBand --verbose --silent` +- **`pnpm run test:ut`** → runs `pnpm test --selectProjects=unit` +- **`pnpm run test:it`** → runs `pnpm test --selectProjects=integration` - **`pnpm run test:coverage`** → `jest --coverage` +Everything is configured in `jest.config.ts`, including the `@/*` → `src/*` path mapping used throughout the specs. + --- ## Directory layout ``` tests/ - ├─ utils.ts - ├─ TESTS.md - ├─ mockHelpers.ts - ├─ unit/ - │ └─ fileManager.spec.ts - ├─ integration/ - │ ├─ fileManager.spec.ts - │ ├─ testSetupHelpers.ts - │ └─ test-node-setup - └─ fixtures/ +├─ TESTS.md +├─ utils.ts # shared: URLs, signers, batch params, createInitializedFileManager, retry/stream helpers +├─ unit/ +│ ├─ setup.ts # setupFilesAfterEnv — centralizes jest.mock() for @/utils/bee & @/utils/mantaray +│ ├─ mock.ts # applyDefaultMocks, mock factories, seedRecords, unit createInitializedFileManager +│ ├─ init.spec.ts +│ ├─ drive.spec.ts +│ ├─ file.spec.ts +│ ├─ folder.spec.ts +│ ├─ version.spec.ts +│ ├─ trash.spec.ts +│ ├─ events.spec.ts +│ └─ abort.spec.ts +└─ integration/ + ├─ setup/ + │ ├─ jestSetup.ts # globalSetup → `npx bee-factory start --tag ` + │ ├─ jestTeardown.ts # globalTeardown → `npx bee-factory stop` + │ └─ utils.ts # temporary file and stamp management + ├─ init.spec.ts + ├─ drive.spec.ts + ├─ file.spec.ts + ├─ folder.spec.ts + ├─ version.spec.ts + ├─ trash.spec.ts + ├─ e2e.spec.ts + └─ abort.spec.ts ``` -Helper modules you will see in specs: - -- **`createInitializedFileManager`** — builds a `FileManagerBase` with a properly configured Bee client and emitter. -- **`ensureOwnerStamp`** — ensures an admin/owner postage stamp exists for the test node, returning - `{ bee, ownerStamp }`. -- **`utils.ts`** — constants, signer mocks, directory walkers, download/compare helpers, test batch parameters. -- **`mockHelpers.ts`** — spies/mocks for upload/download paths, mantaray, feed writers, etc. - ---- - -## Integration tests — what they verify - -Located primarily in `tests/integration/` and executed against a live **BeeDev** node. - -### 1) Initialization - -- Creates a new `FileManagerBase` and asserts default state (`fileInfoList`, `sharedWithMe` are empty). -- Emits **`FILEMANAGER_INITIALIZED`** with success when owner/admin stamp can be found. -- When a non-owner node attempts to read the owner feed, proper **404/500** errors surface from `downloadData()`. -- Owner feed/topic is **stable** across reinitialization (re-reads same topic hex). - -### 2) Upload + fetch nested structure - -- Uploads a nested folder + a single file into a drive created with a live postage stamp. -- Uses `listFiles()` to verify **relative paths** and ordering. -- Uses helper `dowloadAndCompareFiles()` to read all forks and byte-compare results. +Each domain area lives in its own spec file, mirrored across `unit/` and `integration/`. -### 3) Bee node sanity +### Shared helpers -- Asserts `getVersions()` returns `beeVersion` and `beeApiVersion` and `isSupportedApiVersion()` is true. -- Asserts `getNodeAddresses()` returns a `publicKey` for ACT. +**`tests/utils.ts`** (used by both projects) -### 4) Drive handling +- Constants: `BEE_URL`, `OTHER_BEE_URL`, `DEFAULT_MOCK_SIGNER`, `OTHER_MOCK_SIGNER`, `DUMMY_BATCH_ID`, + `DEFAULT_BATCH_DEPTH`, `DEFAULT_BATCH_AMOUNT`. +- `createInitializedFileManager(bee?, batchId?, emitter?)` — constructs a `FileManagerBase`, initializes it, and + bootstraps an admin drive if one isn't present. +- `retryOnPropagationDelay(fn, attempts?, delayMs?)` — retries a read until chunks propagate on the devnet. +- `streamToUint8Array`, `readFilesOrDirectory`, `getTestFile` — content/dir helpers. -- `createDrive()` emits **`DRIVE_CREATED`** and persists a drive with expected attributes (`Identifier` length, owner - address, batch id, redundancy level, etc.). +**Unit — `tests/unit/setup.ts` + `tests/unit/mock.ts`** -> Note: Full destruction/dilution flows are difficult in devnode; a placeholder test is provided (commented) for a -> production Bee that supports the relevant API. +- `setup.ts` is wired via `setupFilesAfterEnv` and holds the module-level `jest.mock()` calls for `@/utils/bee` + (`getFeedData`, `fetchStamp`) and `@/utils/mantaray` (`loadMantaray`, `getAllNodeEntries`). Centralizing them here + keeps every spec free of duplicated mock boilerplate. +- `mock.ts` provides `applyDefaultMocks()` (call it first in each `beforeEach` — resets mocks, installs + `createInitMocks` and sensible default return values), mock factories (`createMockDriveInfo`, `createMockFileInfo`, + `createMockFeedReader`, `createMockFeedWriter`, `createMockMantarayNode`, …), `seedRecords(fm, ...records)` to + pre-populate the record cache, and a unit-local `createInitializedFileManager`. -### 5) `listFiles()` behavior +**Integration — `tests/integration/setup/utils.ts`** -- Uploads folders with various structures and checks that `listFiles()` returns accurate **relative paths** and **fork - references**. -- Validates behavior for an **empty folder** (throws on upload / returns empty list). -- Deeply nested paths are preserved (e.g. `level1/level2/level3/d.txt`). -- Entries with **empty paths** are **ignored**. +- `ensureUniqueSignerWithStamp(isNewSigner?)` — returns `{ bee, ownerStamp, signer }`, buying the admin/owner stamp once + and caching it for the run. +- `setupUserDrive(driveName, { stampLabel?, reuseOwnerStamp? })` — the standard `beforeAll` fixture: ensures a signer, + initializes a `FileManagerBase` (with admin drive), buys a stamp, creates the named user drive, and returns + `{ bee, fileManager, drive, ownerStamp, batchId, signer }`. +- `tempFileRegistry()` — returns `{ writeTempFile, writeTempDir, cleanup }`. All on-disk fixtures are written through it + and removed in a single `afterAll(cleanup)`, so **no temporary file survives the run** even if a test throws. -### 6) `upload()` flows - -- Uploading a directory produces a `FileInfo` entry; **re-uploads** using same topic increment the **feed index** - without creating duplicate entries. -- **Metadata-only updates** do not cause re-uploads of the same manifest (file refs remain identical). -- `previewPath` is supported (if the implementation stores the preview reference, the test asserts presence; otherwise - logs a warning). -- Validates the invariant: `topic` and `historyRef` must be provided **together**, else `FileInfoError` is thrown. - -### 7) Download - -- Downloads **all** files from a manifest and compares contents. -- Downloads **specific forks** by path selection. -- Handles **empty manifests** by returning an empty array. - -### 8) File lifecycle - -- **Trash** (soft-delete) flips status to `Trashed` and bumps version; subsequent re-initialization observes persisted - status. -- **Recover** from `Trashed` to `Active` and bump version. -- **Forget** (hard-delete) removes the `FileInfo` from local lists and persists the drive list. -- Guards against **duplicate topics** when trashing/restoring multiple times. - -### 9) Version control - -- `getVersion()` rejects invalid indices (negative or out-of-range). -- Sequential uploads result in proper **slot indices** (`FeedIndex` 0,1,2…). -- `getVersion() + download()` returns correct **subset** of bytes when a path list is supplied. -- Returns **cached** head `FileInfo` without re-fetching when the requested version equals head. -- **Restore** an old version creates a **new head** that points at the historical reference. -- Restoring the current head is a no‑op. +--- -### 10) Grantees / Sharing metadata +## Domain model under test (v2) -- `getGrantees()` throws a friendly error when the file’s topic is not found in the drive list (missing grantee list). +- `FileManagerBase` exposes `recordList` (`FileRecord[]`) and `driveList` (`DriveInfo[]`); records and drives carry a + `NodeType`. +- **Drives are mantaray manifests.** A drive's file tree is a mantaray whose forks carry per-file metadata; per-file + version history lives in each file's own Swarm feed. +- **ACT** wraps content per file (`content.historyRef`, `actPublisher`). +- **Trash is an owner-private overlay** on the admin drive: status is _derived_ on load (not stored on the file's own + feed), so a fresh instance re-derives Active/Trashed via `listFolder`. +- `FileManagerConfig` lets clients cap `uploadConcurrency` and `feedFetchConcurrency`. +- Sharing / grantees are **not** part of v2 and are not tested. -### 11) E2E workflow +--- -- Full user path: create drive → upload single file → upload project folder → re-upload folder “in place” (not - supported, so original manifest remains) → upload a new **version** folder → list & download from that new version - manifest. -- Validates path preservation, version semantics, and ACT download parameters (`actHistoryAddress`, `actPublisher`). +## Integration tests — what each suite verifies + +Executed against live bee-factory nodes. + +- **`init.spec.ts`** — _Initialization and construction_ + _reinitialization_: default state, admin feed/topic + stability, a non-owner failing to read the admin feed, and `INITIALIZED` / `STATE_INVALID` behavior across + re-initialization with a valid vs. expired admin stamp (user drives and admin stamp survive re-init). +- **`drive.spec.ts`** — _Drive operations_: `createDrive` persists id/owner/batch/redundancy; forgetting a user drive + removes it, prunes its records, emits `DRIVE_FORGOTTEN`, and persists; destroying/forgetting the **admin** drive and + forgetting a non-existent drive throw `DriveError`. +- **`file.spec.ts`** — split into `uploadFile`, `uploadFiles`, `updateFile`, `downloadFile and downloadFiles`, `move`: + single- and multi-file uploads (each with its own topic), implicit folder creation with batched manifest saves, the + two-hop ACT-unwrap download round-trip, `updateFile` re-versioning (content vs. metadata-only), directory-source + guards, and rename/move within and across drives. +- **`folder.spec.ts`** — _Folder operations_: `listFolder` (relative paths, empty folders, deep nesting, empty-path + rejection), `downloadFolder` destination-path composition, and moving a folder as a unit. +- **`version.spec.ts`** — _Version control_: invalid index rejection, sequential slot indices, cold-cache lazy + hydration, drive-mismatch guard, independently downloadable version bytes, cached-head fast path, restoring a prior + version as the new head, no-op restore of the head, and restore keeping the current (post-move) location. +- **`trash.spec.ts`** — _Lifecycle management_: trash (soft-delete) and recover round-trip through the owner-private + overlay (status re-derived by a fresh instance, **no** version bump), `forget` (hard-delete), and no-duplicate-topic + guarantees. +- **`abort.spec.ts`** — _Abort signal handling_: `AbortSignal` forwarding for `uploadFile`, `downloadFiles`, and + `listFolder` — pre-aborted, mid-flight cancel, and clean completion when not aborted. +- **`e2e.spec.ts`** — _End-to-End User Workflow_: in-place folder update (one file changes, siblings untouched), adding + a new folder version without disturbing old files, and multi-branch relative-path listing. --- -## Unit tests — what they verify +## Unit tests — what each suite verifies -Located in `tests/unit/` and focused on behavior of `FileManagerBase` **without** hitting the network. +Located in `tests/unit/`, all network access mocked (see `setup.ts` / `mock.ts`). Key strategies: -- Replace `getFeedData`, `getWrappedData`, `generateRandomBytes` with jest mocks -- Replace mantaray operations via mocked `MantarayNode` + controlled `collect()` output -- Spy on Bee client methods (`downloadData`, `diluteBatch`) to assert parameters - -### Constructor & initialization - -- Creating `FileManagerBase` without a signer fails with `SignerError`. -- Proper init emits `FILEMANAGER_INITIALIZED` once; subsequent calls log “already initialized” / “being initialized”. - -### Download + listFiles - -- Asserts mantaray **`collect()`** is called. -- For a selected path (e.g. `/root/2.txt`) only the **correct fork** reference is downloaded. -- When collecting all forks, each ref is passed to `bee.downloadData()` and the returned `Bytes` array is propagated. -- `listFiles()` returns a **path → reference** map (`{'/root/2.txt': '…'}`). - -### Upload - -- Chooses the right **upload path** depending on inputs (`path`, `previewPath`). -- Throws when `topic` and `historyRef` are not supplied together. -- Ensures **no duplicate entries** are added when re‑uploading the same topic; instead only the `version` is - incremented. - -### Version control - -- `getVersion()` orchestrates `getFeedData` + `fetchFileInfo` and returns a `FileInfo` for indexed or head fetch. -- Chaining `getVersion()` and `download()` forwards ACT options and returns byte arrays. -- Missing feeds throw a helpful “File info not found for topic” message. -- Restoring the current head **does not emit** a `FILE_VERSION_RESTORED` event. - -### Drive handling - -- Creating an **admin drive** normalizes the name to the admin label and sets flags accordingly. -- Creating a normal drive persists id/batch/owner metadata. -- Creating a drive with duplicate **name** or **batchId** throws `DriveError`. -- Destroying a drive calls `bee.diluteBatch(batchId, STAMPS_DEPTH_MAX)`. -- Attempting to destroy the admin drive/stamp throws `DriveError`. - -### File operations - -- **Trash** emits `FILE_TRASHED`, bumps `timestamp`, and persists the new `FileInfo` slot. -- **Recover** emits `FILE_RECOVERED` with a later `timestamp`. -- **Forget** removes the file from lists, saves owner feed, and emits `FILE_FORGOTTEN`. - -### Events - -- `FILE_UPLOADED` payload is **deterministic**: tests pin system time with `jest.useFakeTimers()` to assert `timestamp` - precisely. -- `FILEMANAGER_INITIALIZED` fires once per “cold” initialization. +- `@/utils/bee` (`getFeedData`, `fetchStamp`) and `@/utils/mantaray` (`loadMantaray`, `getAllNodeEntries`) are + `jest.mock()`-ed in `setup.ts`; `applyDefaultMocks()` gives them default resolved values per test. +- Bee client methods are spied via `createInitMocks` (`downloadData`, `uploadData`, feed reader/writer, stamps, …). +- `seedRecords()` injects `FileRecord`s directly into the cache to test read paths without uploading. + +- **`init.spec.ts`** — _constructor_ (missing signer, emitter wiring), _initialize_ (emits `INITIALIZED`; idempotent), + _reinitialization_. +- **`drive.spec.ts`** — `creatAdminDrive`, `createDrive` (duplicate name/batchId → `DriveError`), `destroyDrive` + (`bee.diluteBatch` / admin-stamp guard), `forgetDrive`. +- **`file.spec.ts`** — _File operations_ → `downloadFile`, `downloadFiles`, `uploadFile`, `updateFile`, `move` (correct + ACT params, no duplicate records on re-version, directory guards). +- **`folder.spec.ts`** — `downloadFolder`, `listFolder`, `createFolder`, `move`. +- **`version.spec.ts`** — `getFileVersion` (indexed vs. head, cache reuse, missing-feed error), `restoreFileVersion` + (head restore is a no-op / emits no event). +- **`trash.spec.ts`** — _Lifecycle management_ → `trashFile`, `recoverFile`, `trashFolder`, `listTrash`, `forget` (event + emission and overlay bookkeeping). +- **`events.spec.ts`** — _Events and emitter_: deterministic `FILE_UPLOADED` payloads (system time pinned via + `jest.useFakeTimers()`), `INITIALIZED` fired once per cold init. +- **`abort.spec.ts`** — abort-signal plumbing at the unit level. + +Emitted events live in `FileManagerEvents` (`src/utils/events.ts`): `FILE_UPLOADED`, `FILE_UPDATED`, `FILE_DOWNLOADED`, +`FILE_TRASHED`, `FILE_RECOVERED`, `FILE_FORGOTTEN`, `FILE_VERSION_RESTORED`, `FILE_MOVED`, `INITIALIZED`, +`DRIVE_CREATED`, `DRIVE_FORGOTTEN`, `DRIVE_DESTROYED`, `FOLDER_*`, `FILES_UPLOADED`, `STATE_INVALID`. --- ## Writing new tests -- **When to choose unit vs. integration** - - If logic depends on **Bee responses** (feeds, ACT, mantaray), prefer **integration** tests using `BeeDev`. - - If you’re validating **pure FileManagerBase behavior** or edge branches, mock out Bee and write **unit** tests. +- **Unit vs. integration** + - Depends on real Bee behavior (feeds, ACT, mantaray, propagation)? → **integration**, using `setupUserDrive`. + - Validating pure `FileManagerBase` branches/edge cases? → **unit**, using `applyDefaultMocks` + `seedRecords`. -- **Use ACT options correctly** when downloading in integration tests: +- **Integration `beforeAll` fixture** — prefer `setupUserDrive` over hand-rolling stamp/drive setup: ```ts - const files = await fm.download(fi, ['path.txt'], { - actHistoryAddress: fi.file.historyRef, - actPublisher: fi.actPublisher, // usually from bee.getNodeAddresses().publicKey + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ fileManager, drive } = await setupUserDrive('my-suite', { stampLabel: 'mySuiteStamp' })); }); + + afterAll(cleanup); ``` -- **Add fixtures** under `tests/integration/fixtures/` and keep them small to make the suite fast. +- **On-disk fixtures** — always create them via `writeTempFile` / `writeTempDir` so `afterAll(cleanup)` removes them. + Never call `fs.writeFileSync`/`mkdirSync` directly in a spec. -- **Prefer explicit errors**: if a code path is expected to throw, assert both **type** and **message** so regressions - are easier to spot. +- **ACT download parameters** — pass `actHistoryAddress` and `actPublisher` from the same context that uploaded: ---- + ```ts + await fileManager.downloadFiles( + [record], + { actHistoryAddress: record.content.historyRef, actPublisher }, + { signal }, // optional requestOptions + ); + ``` -## Troubleshooting test failures +- **Propagation** — wrap reads that follow a write in `retryOnPropagationDelay(() => ...)` to avoid devnet flakiness. -- **ACT unwrap (404/500) or permission errors** - Check you are passing **both** `actPublisher` and `actHistoryAddress` from the same context as the uploader. -- **CORS or port issues** - Ensure you dont have an existing bee node running on ports 1633 or 1733 -- **Empty manifest or upload 400** - Ensure source directories are non-empty and readable; verify permissions. -- **Version assertions fail** - Re-check that the test uses the **historyRef** when bumping versions. +- **Unit ordering** — call `applyDefaultMocks()` at the top of `beforeEach`, _before_ `createInitializedFileManager()`, + so the mocks are in place when the manager initializes. + +- **Prefer explicit errors** — assert both the error **type** and **message** so regressions are easy to spot. --- -## Mapping: Features → Tests +## Troubleshooting -| Feature | Where it’s tested | -| ------------------------------------- | ----------------------------------------------------------- | -| Initialization & admin stamp | `integration: initialization` | -| Upload (dir/file) | `integration: upload`, `unit: upload` | -| List files (mantaray) | `integration: listFiles`, `unit: listFiles` | -| Download (all / subset) | `integration: download`, `unit: download` | -| Versioning (get/restore/cache) | `integration: version control`, `unit: version control` | -| Drive create/destroy | `integration: drive handling`, `unit: drive handling` | -| File lifecycle (trash/recover/forget) | `integration: file operations`, `unit: file operations` | -| Grantees / sharing lookup | `integration: getGranteesOfFile`, `unit: getGranteesOfFile` | -| Events | `unit: eventEmitter`, `integration: initialization` | +- **bee-factory won't start / port in use** — ensure Docker is running and nothing else is bound to `1633`/`1635`. A + previous crashed run may leave containers up; `npx bee-factory stop` clears them. +- **ACT unwrap (404/500) / permission errors** — pass **both** `actPublisher` and `actHistoryAddress` from the + uploader's context. +- **Version assertions fail** — confirm the test re-uploads using the **same path** the record was created with and + reads the feed head after propagation. +- **Flaky reads right after a write** — increase the `retryOnPropagationDelay` attempts/delay for that step. +- **Leftover temp files** — shouldn't happen; every fixture goes through `tempFileRegistry()` and is removed in + `afterAll(cleanup)`. If you added a raw `fs` write, route it through the registry. --- ## Notes on Bee mainnet -These tests are designed for a **local devnode**. Running them against mainnet: - -- will be **slow**, may incur **real costs**, and may **pollute** your feed history -- may produce **intermittent failures** due to network conditions or ACT publisher contexts - -If you still need to point integration tests to a remote Bee, isolate those runs and supply appropriate **stamps**, -**signers**, and **ACT** parameters. - ---- +The integration suite targets a local **bee-factory** cluster. Pointing it at mainnet will be **slow**, may incur **real +costs**, may **pollute** your feed history, and can fail intermittently on network/ACT-publisher contexts. If you must, +isolate those runs and supply appropriate stamps, signers, and ACT parameters. diff --git a/tests/fixtures/data.txt b/tests/fixtures/data.txt deleted file mode 100644 index 5b4206e..0000000 --- a/tests/fixtures/data.txt +++ /dev/null @@ -1 +0,0 @@ -[{"batchId":"ee0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51","eFileRef":"src/folder/1.txt"},{"batchId":"ee0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51","eFileRef":"src/folder/2.txt"},{"eFileRef":"src/folder/3.txt","batchId":"ee0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51"}] \ No newline at end of file diff --git a/tests/fixtures/folder/1.txt b/tests/fixtures/folder/1.txt deleted file mode 100644 index c57eff5..0000000 --- a/tests/fixtures/folder/1.txt +++ /dev/null @@ -1 +0,0 @@ -Hello World! \ No newline at end of file diff --git a/tests/fixtures/folder/2.txt b/tests/fixtures/folder/2.txt deleted file mode 100644 index e69de29..0000000 diff --git a/tests/fixtures/nested/extra/extra.txt b/tests/fixtures/nested/extra/extra.txt deleted file mode 100644 index fe312ed..0000000 --- a/tests/fixtures/nested/extra/extra.txt +++ /dev/null @@ -1 +0,0 @@ -Extra content. \ No newline at end of file diff --git a/tests/fixtures/nested/nested.txt b/tests/fixtures/nested/nested.txt deleted file mode 100644 index 0023f08..0000000 --- a/tests/fixtures/nested/nested.txt +++ /dev/null @@ -1 +0,0 @@ -I am nested. \ No newline at end of file diff --git a/tests/fixtures/test.txt b/tests/fixtures/test.txt deleted file mode 100644 index c57eff5..0000000 --- a/tests/fixtures/test.txt +++ /dev/null @@ -1 +0,0 @@ -Hello World! \ No newline at end of file diff --git a/tests/integration/abort.spec.ts b/tests/integration/abort.spec.ts new file mode 100644 index 0000000..004abd6 --- /dev/null +++ b/tests/integration/abort.spec.ts @@ -0,0 +1,298 @@ +import { Bee, PublicKey } from '@ethersphere/bee-js'; +import { setTimeout } from 'timers'; + +import { setupUserDrive, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, FileRecord, FolderInfo, ListDepth } from '@/types'; +import { ROOT_PATH } from '@/utils/constants'; + +describe('Abort signal handling', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ bee, fileManager, drive } = await setupUserDrive('abort-test', { stampLabel: 'abortControllerStamp' })); + }); + + afterAll(cleanup); + + describe('uploadFile', () => { + const preAbortFile = 'it-abort-pre-abort.bin'; + const midAbortFile = 'it-abort-mid-flight.bin'; + const successFile = 'it-abort-success.txt'; + const multi1File = 'it-abort-multi-1.txt'; + const multi2File = 'it-abort-multi-2.txt'; + + beforeAll(() => { + // Larger files (1MB) give abort tests enough time to actually cancel mid-flight. + const largeData = Buffer.alloc(1 * 1024 * 1024, 'x'); + writeTempFile(preAbortFile, largeData); + writeTempFile(midAbortFile, largeData); + writeTempFile(successFile, 'This file should upload successfully'); + writeTempFile(multi1File, 'Content 1'); + writeTempFile(multi2File, 'Content 2'); + }); + + it('should throw an AbortError when upload is aborted with pre-aborted signal', async () => { + const controller = new AbortController(); + controller.abort(); // Pre-abort + + const uploadPromise = fileManager.uploadFile( + drive.id, + { path: preAbortFile, sourcePath: preAbortFile }, + undefined, + { + signal: controller.signal, + }, + ); + + await expect(uploadPromise).rejects.toThrow(); + + try { + await uploadPromise; + } catch (error: any) { + expect(error.name === 'AbortError' || error.message.toLowerCase().includes('abort')).toBe(true); + } + }); + + it('should throw BeeResponseError when upload is cancelled mid-flight', async () => { + const controller = new AbortController(); + + // Start upload and abort after a short delay + const uploadPromise = fileManager.uploadFile( + drive.id, + { path: midAbortFile, sourcePath: midAbortFile }, + undefined, + { + signal: controller.signal, + }, + ); + + controller.abort(); + + try { + await uploadPromise; + } catch (error: any) { + const haystack = `${error?.name ?? ''} ${error?.message ?? ''} ${error?.cause?.message ?? ''}`.toLowerCase(); + expect(error?.statusText === 'ERR_CANCELED' || /abort|cancel|terminated/.test(haystack)).toBe(true); + } + }); + + it('should complete upload successfully when signal is not aborted', async () => { + const controller = new AbortController(); + + // Upload with signal that is NOT aborted + await fileManager.uploadFile(drive.id, { path: successFile, sourcePath: successFile }, undefined, { + signal: controller.signal, + }); + + // Verify file was uploaded + const uploadedFile = fileManager.recordList.find((fr) => fr.path === successFile); + expect(uploadedFile).toBeDefined(); + expect(uploadedFile?.driveId).toBe(drive.id.toString()); + }); + + it('should handle multiple uploads with different abort controllers', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + controller1.abort(); // Pre-abort first one + + // First upload should fail (aborted) + const firstUploadPromise = fileManager.uploadFile( + drive.id, + { path: multi1File, sourcePath: multi1File }, + undefined, + { + signal: controller1.signal, + }, + ); + + await expect(firstUploadPromise).rejects.toThrow(); + + try { + await firstUploadPromise; + } catch (error: any) { + expect(error.name === 'AbortError' || error.message.toLowerCase().includes('abort')).toBe(true); + } + + // Second upload should succeed (not aborted) + await fileManager.uploadFile(drive.id, { path: multi2File, sourcePath: multi2File }, undefined, { + signal: controller2.signal, + }); + + const uploadedFile = fileManager.recordList.find((fr) => fr.path === multi2File); + expect(uploadedFile).toBeDefined(); + }); + }); + + describe('download', () => { + const downloadTestFile = 'it-abort-large-download.bin'; + let uploadedFileInfo: FileRecord; + let actPublisher: PublicKey; + + beforeAll(async () => { + // Upload a 1MB file to download later (large enough for reliable abort timing) + writeTempFile(downloadTestFile, Buffer.alloc(1 * 1024 * 1024, 'x')); + await fileManager.uploadFile(drive.id, { path: downloadTestFile, sourcePath: downloadTestFile }); + const fr = fileManager.recordList.find((fr) => fr.path === downloadTestFile); + expect(fr).toBeDefined(); + uploadedFileInfo = fr!; + + actPublisher = (await bee.getNodeAddresses()).publicKey; + }); + + it('should throw error when download is aborted with pre-aborted signal', async () => { + const controller = new AbortController(); + controller.abort(); // Pre-abort + + await expect( + fileManager.downloadFiles( + [uploadedFileInfo], + { + actHistoryAddress: uploadedFileInfo.content.historyRef, + actPublisher, + }, + { signal: controller.signal }, + ), + ).rejects.toThrow(); + }); + + it('should throw error when download is cancelled mid-flight', async () => { + const controller = new AbortController(); + + // Start download and abort after a short delay + const downloadPromise = fileManager.downloadFiles( + [uploadedFileInfo], + { + actHistoryAddress: uploadedFileInfo.content.historyRef, + actPublisher, + }, + { signal: controller.signal }, + ); + + setTimeout(() => { + controller.abort(); + }, 1); + + await expect(downloadPromise).rejects.toThrow(); + }); + + it('should complete download successfully when signal is not aborted', async () => { + const controller = new AbortController(); + + const result = await fileManager.downloadFiles( + [uploadedFileInfo], + { + actHistoryAddress: uploadedFileInfo.content.historyRef, + actPublisher, + }, + { signal: controller.signal }, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result.succeeded)).toBe(true); + expect(Array.isArray(result.failed)).toBe(true); + }); + + it('should handle multiple downloads with different abort controllers', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + controller1.abort(); // Pre-abort first one + + // First download should fail (aborted) + await expect( + fileManager.downloadFiles( + [uploadedFileInfo], + { + actHistoryAddress: uploadedFileInfo.content.historyRef, + actPublisher, + }, + { signal: controller1.signal }, + ), + ).rejects.toThrow(); + + // Second download should succeed (not aborted) + const result = await fileManager.downloadFiles( + [uploadedFileInfo], + { + actHistoryAddress: uploadedFileInfo.content.historyRef, + actPublisher, + }, + { signal: controller2.signal }, + ); + + expect(result).toBeDefined(); + expect(Array.isArray(result.succeeded)).toBe(true); + expect(Array.isArray(result.failed)).toBe(true); + }); + }); + + describe('listFolder', () => { + const folderName = 'it-abort-listfolder-folder'; + const fileInFolder = `${folderName}/it-abort-listfolder-file.txt`; + let folderInfo: FolderInfo; + + beforeAll(async () => { + folderInfo = await fileManager.createFolder(drive.id, ROOT_PATH, folderName); + + writeTempDir(folderName, { 'it-abort-listfolder-file.txt': 'listFolder abort test content' }); + await fileManager.uploadFile(drive.id, { path: fileInFolder, sourcePath: fileInFolder }); + }); + + it('should throw error when listFolder is aborted with pre-aborted signal', async () => { + const controller = new AbortController(); + controller.abort(); // Pre-abort + + await expect( + fileManager.listFolder(drive.id, folderInfo.path, ListDepth.Shallow, undefined, { signal: controller.signal }), + ).rejects.toThrow(); + }); + + it('should throw error when listFolder is cancelled mid-flight', async () => { + const controller = new AbortController(); + + const listPromise = fileManager.listFolder(drive.id, folderInfo.path, ListDepth.Shallow, undefined, { + signal: controller.signal, + }); + + setTimeout(() => { + controller.abort(); + }, 1); + + await expect(listPromise).rejects.toThrow(); + }); + + it('should complete listFolder successfully when signal is not aborted', async () => { + const controller = new AbortController(); + + const result = await fileManager.listFolder(drive.id, folderInfo.path, ListDepth.Shallow, undefined, { + signal: controller.signal, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + + it('should handle multiple listFolder calls with different abort controllers', async () => { + const controller1 = new AbortController(); + const controller2 = new AbortController(); + controller1.abort(); // Pre-abort first one + + // First call should fail (aborted) + await expect( + fileManager.listFolder(drive.id, folderInfo.path, ListDepth.Shallow, undefined, { signal: controller1.signal }), + ).rejects.toThrow(); + + // Second call should succeed (not aborted) + const result = await fileManager.listFolder(drive.id, folderInfo.path, ListDepth.Shallow, undefined, { + signal: controller2.signal, + }); + + expect(result).toBeDefined(); + expect(Array.isArray(result)).toBe(true); + }); + }); +}); diff --git a/tests/integration/drive.spec.ts b/tests/integration/drive.spec.ts new file mode 100644 index 0000000..6ffadab --- /dev/null +++ b/tests/integration/drive.spec.ts @@ -0,0 +1,111 @@ +import { Bee, Identifier, PostageBatch, PrivateKey, RedundancyLevel } from '@ethersphere/bee-js'; + +import { buyStampSerialized, createInitializedFileManager, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH } from '../utils'; + +import { ensureUniqueSignerWithStamp, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo } from '@/types'; +import { DriveError, FileManagerEvents } from '@/utils'; + +describe('Drive operations', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let ownerBatch: PostageBatch; + let signer: PrivateKey; + const { writeTempFile, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + const { bee: beeDev, ownerStamp, signer: newSigner } = await ensureUniqueSignerWithStamp(); + bee = beeDev; + signer = newSigner; + const stamp = (await bee.getPostageBatches()).find((s) => s.batchID.toString() === ownerStamp.toString()); + + expect(stamp).toBeDefined(); + expect(stamp?.batchID.toString() === ownerStamp.toString()).toBeTruthy(); + ownerBatch = stamp!; + + fileManager = await createInitializedFileManager(bee, ownerStamp); + }); + + afterAll(cleanup); + + it('should create a drive and retrieve it', async () => { + const batchId = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'createDriveStamp'); + + await fileManager.createDrive(batchId, 'Test Drive'); + const drives = fileManager.driveList; + expect(drives.length).toBeGreaterThanOrEqual(1); + const testDrive = drives.find((d) => d.name === 'Test Drive'); + expect(testDrive).toBeDefined(); + expect(new Identifier(testDrive!.id)).toHaveLength(Identifier.LENGTH); + expect(testDrive!.batchId).toBe(batchId.toString()); + expect(testDrive!.name).toBe('Test Drive'); + expect(testDrive!.owner).toBe(signer.publicKey().address().toHex()); + expect(testDrive!.redundancyLevel).toBe(RedundancyLevel.OFF); + expect(fileManager.recordList.filter((fr) => fr.driveId === testDrive!.id)).toHaveLength(0); + }); + + it('should forget a user drive: removes the drive, prunes its files, and persists the change', async () => { + const forgetBatchId = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'forgetDriveStamp'); + await fileManager.createDrive(forgetBatchId, 'Drive to forget'); + + const created = fileManager.driveList.find((d) => d.name === 'Drive to forget'); + expect(created).toBeDefined(); + const driveId = created!.id.toString(); + const initialDriveCount = fileManager.driveList.length; + + const fileA = writeTempFile('forget-drive-a.txt', 'forget a content'); + const fileB = writeTempFile('forget-drive-b.txt', 'forget b content'); + const uploadResult = await fileManager.uploadFiles( + driveId, + [ + { path: 'a.txt', sourcePath: fileA }, + { path: 'b.txt', sourcePath: fileB }, + ], + '', + ); + expect(uploadResult.failed).toHaveLength(0); + + expect(fileManager.recordList.some((fr) => fr.driveId === driveId)).toBe(true); + + const eventPromise = new Promise((resolve) => { + const handler = ({ driveInfo }: { driveInfo: DriveInfo }): void => { + try { + expect(driveInfo.id.toString()).toBe(driveId); + resolve(); + } finally { + fileManager.emitter?.off?.(FileManagerEvents.DRIVE_FORGOTTEN, handler); + } + }; + fileManager.emitter.on(FileManagerEvents.DRIVE_FORGOTTEN, handler); + }); + await fileManager.forgetDrive(new Identifier(created!.id)); + await eventPromise; + const afterForgetDrives = fileManager.driveList; + expect(afterForgetDrives).toHaveLength(initialDriveCount - 1); + expect(afterForgetDrives.find((d) => d.id.toString() === driveId)).toBeUndefined(); + + expect(fileManager.recordList.some((fr) => fr.driveId === driveId)).toBe(false); + + const fm2 = await createInitializedFileManager(bee, ownerBatch.batchID); + const drives2 = fm2.driveList; + expect(drives2.find((d) => d.name === 'Drive to forget')).toBeUndefined(); + }); + + it('should throw when trying to forget the admin drive', async () => { + const adminDrive = fileManager.driveList.find((d) => d.isAdmin); + expect(adminDrive).toBeDefined(); + await expect(fileManager.forgetDrive(new Identifier(adminDrive!.id))).rejects.toThrow( + new DriveError('Cannot forget admin drive'), + ); + }); + + it('should throw when trying to forget a non-existent drive', async () => { + const idBytes = new Uint8Array(Identifier.LENGTH); + idBytes.fill(1); + await expect(fileManager.forgetDrive(new Identifier(idBytes))).rejects.toThrow( + new DriveError(`Drive with id ${new Identifier(idBytes).toString().slice(0, 6)} not found`), + ); + }); +}); diff --git a/tests/integration/e2e.spec.ts b/tests/integration/e2e.spec.ts new file mode 100644 index 0000000..d2ca93e --- /dev/null +++ b/tests/integration/e2e.spec.ts @@ -0,0 +1,140 @@ +import { retryOnPropagationDelay, streamToUint8Array } from '../utils'; + +import { setupUserDrive, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, ListDepth, NodeType } from '@/types'; + +describe('End-to-End User Workflow', () => { + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ fileManager, drive } = await setupUserDrive('e2e-workflow', { stampLabel: 'e2eWorkflowIntegration' })); + }); + + afterAll(cleanup); + + it('simulates an in-place folder update: one file changes, siblings are untouched', async () => { + const reportFileFlat = writeTempFile('it-e2e-inplace-report-src.txt', 'Report V1'); + const noteFileFlat = writeTempFile('it-e2e-inplace-note-src.txt', 'Note V1'); + + const initial = await fileManager.uploadFiles( + drive.id, + [ + { path: 'it-e2e-project/report.txt', sourcePath: reportFileFlat }, + { path: 'it-e2e-project/note.txt', sourcePath: noteFileFlat }, + ], + '', + ); + expect(initial.failed).toHaveLength(0); + const reportFi = initial.succeeded.find((fr) => fr.path === 'it-e2e-project/report.txt')!; + const noteFi = initial.succeeded.find((fr) => fr.path === 'it-e2e-project/note.txt')!; + expect(reportFi).toBeDefined(); + expect(noteFi).toBeDefined(); + + // Update just one file in place — mirror the manifest path on disk since Node's upload() + // re-upload path doubles as both the fs source and the manifest fork identity. + writeTempDir('it-e2e-project', { 'report.txt': 'Report V2' }); + + await fileManager.updateFile(drive.id, reportFi, { item: { sourcePath: 'it-e2e-project/report.txt' } }); + + const projectEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'it-e2e-project', ListDepth.Shallow), + ); + expect(projectEntries.filter((e) => e.type === NodeType.File)).toHaveLength(2); + + const downloadResults = await retryOnPropagationDelay(async () => { + const results = await fileManager.downloadFolder(drive.id, 'it-e2e-project'); + if (results.succeeded.length < 2) { + throw new Error(`Expected 2 download results, got ${results.succeeded.length}`); + } + return results; + }); + expect(downloadResults.failed).toEqual([]); + const downloadedReport = downloadResults.succeeded.find((d) => d.path === 'it-e2e-project/report.txt'); + const downloadedNote = downloadResults.succeeded.find((d) => d.path === 'it-e2e-project/note.txt'); + expect(downloadedReport).toBeDefined(); + expect(downloadedNote).toBeDefined(); + expect(Buffer.from(await streamToUint8Array(downloadedReport!.result)).toString('utf-8')).toBe('Report V2'); + expect(Buffer.from(await streamToUint8Array(downloadedNote!.result)).toString('utf-8')).toBe('Note V1'); + }); + + it('simulates uploading a new version of a folder — new files join without disturbing old ones', async () => { + const v1FileA = writeTempFile('it-e2e-newversion-v1-a.txt', 'V1 File A'); + const v1FileB = writeTempFile('it-e2e-newversion-v1-b.txt', 'V1 File B'); + + const v1Result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'gallery-v2/a.txt', sourcePath: v1FileA }, + { path: 'gallery-v2/b.txt', sourcePath: v1FileB }, + ], + '', + ); + expect(v1Result.failed).toHaveLength(0); + + const v2FileC = writeTempFile('it-e2e-newversion-v2-c.txt', 'V2 File C'); + const v2Result = await fileManager.uploadFiles(drive.id, [{ path: 'c.txt', sourcePath: v2FileC }], 'gallery-v2'); + expect(v2Result.failed).toHaveLength(0); + + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'gallery-v2', ListDepth.Shallow), + ); + const fileEntries = entries.filter((e) => e.type === NodeType.File); + expect(fileEntries.map((e) => e.path).sort()).toEqual(['gallery-v2/a.txt', 'gallery-v2/b.txt', 'gallery-v2/c.txt']); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(drive.id, 'gallery-v2')); + expect(downloadResults.failed).toEqual([]); + expect(downloadResults.succeeded).toHaveLength(3); + const contents = Object.fromEntries( + await Promise.all( + downloadResults.succeeded.map(async (d) => [ + d.path, + Buffer.from(await streamToUint8Array(d.result)).toString('utf-8'), + ]), + ), + ); + expect(contents['gallery-v2/a.txt']).toBe('V1 File A'); + expect(contents['gallery-v2/b.txt']).toBe('V1 File B'); + expect(contents['gallery-v2/c.txt']).toBe('V2 File C'); + }); + + it('lists files with correct relative paths reflecting a multi-branch folder structure', async () => { + const readme = writeTempFile('it-e2e-structure-readme.txt', 'Readme'); + const specA = writeTempFile('it-e2e-structure-spec-a.txt', 'Spec A'); + const specB = writeTempFile('it-e2e-structure-spec-b.txt', 'Spec B'); + const asset = writeTempFile('it-e2e-structure-asset.txt', 'Asset'); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'structure/readme.txt', sourcePath: readme }, + { path: 'structure/specs/a.txt', sourcePath: specA }, + { path: 'structure/specs/b.txt', sourcePath: specB }, + { path: 'structure/assets/images/asset.txt', sourcePath: asset }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const entries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, 'structure', ListDepth.Deep)); + const filePaths = entries + .filter((e) => e.type === NodeType.File) + .map((e) => e.path) + .sort(); + expect(filePaths).toEqual([ + 'structure/assets/images/asset.txt', + 'structure/readme.txt', + 'structure/specs/a.txt', + 'structure/specs/b.txt', + ]); + + const folderPaths = entries + .filter((e) => e.type === NodeType.Folder) + .map((e) => e.path) + .sort(); + expect(folderPaths).toEqual(['structure/assets', 'structure/assets/images', 'structure/specs']); + }); +}); diff --git a/tests/integration/file.spec.ts b/tests/integration/file.spec.ts new file mode 100644 index 0000000..8dee0cc --- /dev/null +++ b/tests/integration/file.spec.ts @@ -0,0 +1,594 @@ +import { Bee, FeedIndex } from '@ethersphere/bee-js'; +import path from 'path'; + +import { + buyStampSerialized, + createInitializedFileManager, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + retryOnPropagationDelay, + streamToUint8Array, +} from '../utils'; + +import { ensureUniqueSignerWithStamp, setupUserDrive, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, ListDepth, NodeType } from '@/types'; +import { FileManagerEvents } from '@/utils'; +import { FEED_INDEX_ZERO, ROOT_PATH } from '@/utils/constants'; + +describe('uploadFile', () => { + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ fileManager, drive } = await setupUserDrive('upload', { stampLabel: 'uploadIntegrationStamp' })); + }); + + afterAll(cleanup); + + it('uploads a new file and adds it to the file record list at version 0', async () => { + const name = writeTempFile('it-upload-new.txt', 'New Content'); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: name }); + const record = fileManager.recordList.find((fr) => fr.path === name); + expect(record).toBeDefined(); + expect(record!.version).toEqual(FEED_INDEX_ZERO.toString()); + }); + + it('throws when uploading a directory — directories must go through uploadFiles', async () => { + const dirPath = writeTempDir('it-upload-integration-dir', { 'inner.txt': 'Inner Content' }); + + await expect(fileManager.uploadFile(drive.id, { path: dirPath, sourcePath: dirPath })).rejects.toThrow( + 'Cannot upload a directory - use uploadFiles', + ); + expect(fileManager.recordList.some((fr) => fr.path === dirPath)).toBe(false); + }); +}); + +describe('uploadFiles', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ bee, fileManager, drive } = await setupUserDrive('uploadmany', { stampLabel: 'uploadManyIntegration' })); + }); + + afterAll(cleanup); + + it('uploads multiple flat files into the drive root, each with its own topic', async () => { + const fileA = writeTempFile('it-uploadmany-a.txt', 'Content A'); + const fileB = writeTempFile('it-uploadmany-b.txt', 'Content B'); + const fileC = writeTempFile('it-uploadmany-c.txt', 'Content C'); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'a.txt', sourcePath: fileA }, + { path: 'b.txt', sourcePath: fileB }, + { path: 'c.txt', sourcePath: fileC }, + ], + '', + ); + + expect(result.succeeded).toHaveLength(3); + expect(result.failed).toHaveLength(0); + + const entries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, '', ListDepth.Shallow)); + const fileEntries = entries.filter((e) => e.type === NodeType.File); + expect(fileEntries.map((e) => e.path).sort()).toEqual(['a.txt', 'b.txt', 'c.txt']); + + const distinctTopics = new Set( + fileManager.recordList + .filter((fr) => ['a.txt', 'b.txt', 'c.txt'].includes(fr.path)) + .map((fr) => fr.topic.toString()), + ); + expect(distinctTopics.size).toBe(3); + }); + + it('creates missing folders as needed and batches each touched manifest into a single save', async () => { + const reportFile = writeTempFile('it-uploadmany-report.pdf', 'report content'); + const logoFile = writeTempFile('it-uploadmany-logo.png', 'logo content'); + const readmeFile = writeTempFile('it-uploadmany-readme.md', 'readme content'); + + const folderCreatedEvents: unknown[] = []; + const filesUploadedEvents: unknown[] = []; + const onFolderCreated = (e: unknown): number => folderCreatedEvents.push(e); + const onFilesUploaded = (e: unknown): number => filesUploadedEvents.push(e); + fileManager.emitter.on(FileManagerEvents.FOLDER_CREATED, onFolderCreated); + fileManager.emitter.on(FileManagerEvents.FILES_UPLOADED, onFilesUploaded); + + try { + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'docs/report.pdf', sourcePath: reportFile }, + { path: 'docs/img/logo.png', sourcePath: logoFile }, + { path: 'readme.md', sourcePath: readmeFile }, + ], + '', + ); + + expect(result.failed).toHaveLength(0); + expect(result.succeeded).toHaveLength(3); + expect(folderCreatedEvents).toHaveLength(2); + expect(filesUploadedEvents).toHaveLength(1); + + const rootEntries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, '', ListDepth.Shallow)); + expect(rootEntries.some((e) => e.type === NodeType.File && e.path === 'readme.md')).toBe(true); + expect(rootEntries.some((e) => e.type === NodeType.Folder && e.path.endsWith('docs'))).toBe(true); + + const docsEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'docs', ListDepth.Shallow), + ); + expect(docsEntries.some((e) => e.type === NodeType.File && e.path === 'docs/report.pdf')).toBe(true); + expect(docsEntries.some((e) => e.type === NodeType.Folder && e.path.endsWith('img'))).toBe(true); + + const imgEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'docs/img', ListDepth.Shallow), + ); + expect(imgEntries.some((e) => e.type === NodeType.File && e.path === 'docs/img/logo.png')).toBe(true); + } finally { + fileManager.emitter.off(FileManagerEvents.FOLDER_CREATED, onFolderCreated); + fileManager.emitter.off(FileManagerEvents.FILES_UPLOADED, onFilesUploaded); + } + }); + + it('uploads into an existing folder without duplicating it', async () => { + await fileManager.createFolder(drive.id, ROOT_PATH, 'existing'); + const xFile = writeTempFile('it-uploadmany-x.txt', 'x content'); + + const result = await fileManager.uploadFiles(drive.id, [{ path: 'sub/x.txt', sourcePath: xFile }], 'existing'); + + expect(result.failed).toHaveLength(0); + expect(result.succeeded).toHaveLength(1); + + const existingEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'existing', ListDepth.Shallow), + ); + const subFolders = existingEntries.filter((e) => e.type === NodeType.Folder && e.path.endsWith('sub')); + expect(subFolders).toHaveLength(1); + + const subEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'existing/sub', ListDepth.Shallow), + ); + expect(subEntries.some((e) => e.type === NodeType.File && e.path === 'existing/sub/x.txt')).toBe(true); + + const rootEntries = await fileManager.listFolder(drive.id, '', ListDepth.Shallow); + expect(rootEntries.filter((e) => e.type === NodeType.Folder && e.path.endsWith('existing'))).toHaveLength(1); + }); + + it('round-trips file content exactly through the two-hop ACT-unwrap download path', async () => { + const contentA = 'Round trip content Alpha - '.repeat(50); + const contentB = 'Round trip content Beta !! - '.repeat(37); + const fileA = writeTempFile('it-uploadmany-roundtrip-a.txt', contentA); + const fileB = writeTempFile('it-uploadmany-roundtrip-b.txt', contentB); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'roundtrip-a.txt', sourcePath: fileA }, + { path: 'roundtrip-b.txt', sourcePath: fileB }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const downloadResults = await retryOnPropagationDelay(() => + fileManager.downloadFiles([ + result.succeeded.find((fr) => fr.path === 'roundtrip-a.txt')!, + result.succeeded.find((fr) => fr.path === 'roundtrip-b.txt')!, + ]), + ); + + expect(downloadResults.failed).toEqual([]); + const downloadedA = downloadResults.succeeded.find((d) => d.path === 'roundtrip-a.txt'); + const downloadedB = downloadResults.succeeded.find((d) => d.path === 'roundtrip-b.txt'); + expect(downloadedA).toBeDefined(); + expect(downloadedB).toBeDefined(); + + const bytesA = await streamToUint8Array(downloadedA!.result); + const bytesB = await streamToUint8Array(downloadedB!.result); + expect(Buffer.from(bytesA).toString('utf-8')).toBe(contentA); + expect(Buffer.from(bytesB).toString('utf-8')).toBe(contentB); + }); + + it('fails fast without writing anything when a needed folder path is blocked by an existing file', async () => { + const blockerPath = 'it-uploadmany-blocker'; + writeTempFile(blockerPath, 'blocker content'); + await fileManager.uploadFile(drive.id, { path: blockerPath, sourcePath: blockerPath }); + + const innerFile = writeTempFile('it-uploadmany-inner-src.txt', 'inner content'); + + await expect( + fileManager.uploadFiles(drive.id, [{ path: `${blockerPath}/inner.txt`, sourcePath: innerFile }], ''), + ).rejects.toThrow(/not a folder/i); + + const rootEntries = await fileManager.listFolder(drive.id, '', ListDepth.Shallow); + expect(rootEntries.some((e) => e.path === 'inner.txt')).toBe(false); + expect(rootEntries.some((e) => e.type === NodeType.Folder && e.path.endsWith(blockerPath))).toBe(false); + expect(fileManager.recordList.some((fr) => fr.path.includes('inner.txt'))).toBe(false); + }); + + it('rejects invalid path and empty entries before doing any work', async () => { + const srcFile = writeTempFile('it-uploadmany-validation-src.txt', 'validation content'); + + await expect( + fileManager.uploadFiles(drive.id, [{ path: '../escape.txt', sourcePath: srcFile }], ''), + ).rejects.toThrow(/Invalid path/); + + await expect(fileManager.uploadFiles(drive.id, [], '')).rejects.toThrow(/at least one entry/i); + }); + + it('uploads a nested folder with files and fetches them back', async () => { + const rootFile = writeTempFile('it-init-nested-root.txt', 'Init nested root content'); + const nestedDir = 'it-init-nested-docs'; + const nestedFile = path.join(nestedDir, 'note.txt'); + writeTempDir(nestedDir, { 'note.txt': 'Init nested docs content' }); + + const driveBatchId = await buyStampSerialized( + bee, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + 'initNestedFolderStamp', + ); + await fileManager.createDrive(driveBatchId, 'init-nested-drive'); + const drive = fileManager.driveList.find((d) => d.name === 'init-nested-drive')!; + expect(drive).toBeDefined(); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'root.txt', sourcePath: rootFile }, + { path: 'docs/note.txt', sourcePath: nestedFile }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const rootEntries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, '', ListDepth.Shallow)); + expect(rootEntries.some((e) => e.type === NodeType.File && e.path === 'root.txt')).toBe(true); + expect(rootEntries.some((e) => e.type === NodeType.Folder && e.path.endsWith('docs'))).toBe(true); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(drive.id, '/')); + const downloadedRoot = downloadResults.succeeded.find((d) => d.path === 'root.txt'); + const downloadedNested = downloadResults.succeeded.find((d) => d.path === 'docs/note.txt'); + expect(downloadResults.failed).toEqual([]); + expect(downloadedRoot).toBeDefined(); + expect(downloadedNested).toBeDefined(); + expect(Buffer.from(await streamToUint8Array(downloadedRoot!.result)).toString('utf-8')).toBe( + 'Init nested root content', + ); + expect(Buffer.from(await streamToUint8Array(downloadedNested!.result)).toString('utf-8')).toBe( + 'Init nested docs content', + ); + }); +}); + +describe('updateFile', () => { + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ fileManager, drive } = await setupUserDrive('upload', { stampLabel: 'uploadIntegrationStamp' })); + }); + + afterAll(cleanup); + + it('re-versions a file with new bytes via update(), keeping the topic and advancing the version', async () => { + const name = writeTempFile('it-upload-versions.txt', 'v0'); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: name }); + const firstInfo = fileManager.recordList.find((fr) => fr.path === name)!; + expect(firstInfo).toBeDefined(); + + writeTempFile(name, 'v1'); + await fileManager.updateFile(drive.id, firstInfo, { item: { sourcePath: name } }); + const secondInfo = fileManager.recordList.find((fr) => fr.topic.toString() === firstInfo.topic.toString())!; + expect(secondInfo.topic.toString()).toEqual(firstInfo.topic.toString()); + expect(secondInfo.version).toEqual(new FeedIndex(firstInfo.version!).next().toString()); + + writeTempFile(name, 'v2'); + await fileManager.updateFile(drive.id, secondInfo, { item: { sourcePath: name } }); + const thirdInfo = fileManager.recordList.find((fr) => fr.topic.toString() === firstInfo.topic.toString())!; + expect(thirdInfo.version).toEqual(new FeedIndex(secondInfo.version!).next().toString()); + }); + + it('metadata-only update() keeps the same content ref across versions', async () => { + const name = writeTempFile('it-upload-metadata.txt', 'Metadata Content'); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: name }); + const firstInfo = fileManager.recordList.find((fr) => fr.path === name)!; + expect(firstInfo).toBeDefined(); + + await fileManager.updateFile(drive.id, firstInfo, { customMetadata: { tag: 'v1' } }); + const secondInfo = fileManager.recordList.find((fr) => fr.topic.toString() === firstInfo.topic.toString())!; + expect(secondInfo.content).toEqual(firstInfo.content); + expect(secondInfo.customMetadata).toMatchObject({ tag: 'v1' }); + + await fileManager.updateFile(drive.id, secondInfo, { customMetadata: { tag: 'v2' } }); + const thirdInfo = fileManager.recordList.find((fr) => fr.topic.toString() === firstInfo.topic.toString())!; + expect(thirdInfo.content).toEqual(firstInfo.content); + expect(thirdInfo.customMetadata).toMatchObject({ tag: 'v2' }); + }); + + it('should upload a single file and update the file record list', async () => { + const tempFile = writeTempFile('it-upload-single-file.txt', 'Single File Content'); + await fileManager.uploadFile(drive.id, { + path: tempFile, + sourcePath: tempFile, + }); + const recordList = fileManager.recordList; + const uploadedInfo = recordList.find((fr) => fr.path === tempFile); + expect(uploadedInfo).toBeDefined(); + }); + + it('does not create a second record when bumping to a new version', async () => { + const name = writeTempFile('it-upload-bump.txt', 'Bump Content'); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: name }); + const original = fileManager.recordList.find((fr) => fr.path === name)!; + expect(original).toBeDefined(); + + await fileManager.updateFile(drive.id, original, { item: { sourcePath: name } }); + + const entries = fileManager.recordList.filter((fr) => fr.topic.toString() === original.topic.toString()); + expect(entries).toHaveLength(1); + expect(BigInt(entries[0].version!.toString())).toBeGreaterThan(BigInt(original.version?.toString() || '0')); + }); + + it('rejects a directory as the update() content source', async () => { + const name = writeTempFile('it-update-dir-src.txt', 'Src Content'); + const dirPath = writeTempDir('it-update-dir-src', {}); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: name }); + const record = fileManager.recordList.find((fr) => fr.path === name)!; + + await expect(fileManager.updateFile(drive.id, record, { item: { sourcePath: dirPath } })).rejects.toThrow( + 'Cannot upload a directory - use uploadFiles', + ); + }); +}); + +describe('downloadFile and downloadFiles', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ bee, fileManager, drive } = await setupUserDrive('downloaddrive', { stampLabel: 'downloadIntegration' })); + }); + + afterAll(cleanup); + + it('downloads all file contents from the drive when no paths are given', async () => { + const fileA = writeTempFile('it-download-all-a.txt', 'Download All A'); + const fileB = writeTempFile('it-download-all-b.txt', 'Download All B'); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'all-a.txt', sourcePath: fileA }, + { path: 'all-b.txt', sourcePath: fileB }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(drive.id, '/')); + expect(downloadResults.failed).toEqual([]); + expect(downloadResults.succeeded.map((d) => d.path).sort()).toEqual(['all-a.txt', 'all-b.txt']); + + const downloadedA = downloadResults.succeeded.find((d) => d.path === 'all-a.txt'); + const downloadedB = downloadResults.succeeded.find((d) => d.path === 'all-b.txt'); + expect(Buffer.from(await streamToUint8Array(downloadedA!.result)).toString('utf-8')).toBe('Download All A'); + expect(Buffer.from(await streamToUint8Array(downloadedB!.result)).toString('utf-8')).toBe('Download All B'); + }); + + it('downloadFile fetches a single file by its record', async () => { + const fileC = writeTempFile('it-download-only-c.txt', 'Download Only C'); + const fileD = writeTempFile('it-download-only-d.txt', 'Download Only D'); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'only-c.txt', sourcePath: fileC }, + { path: 'only-d.txt', sourcePath: fileD }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const recC = result.succeeded.find((fr) => fr.path === 'only-c.txt')!; + const downloadResult = await retryOnPropagationDelay(() => fileManager.downloadFile(recC)); + expect(downloadResult.path).toBe('only-c.txt'); + expect(Buffer.from(await streamToUint8Array(downloadResult.result)).toString('utf-8')).toBe('Download Only C'); + }); + + it('returns an empty array when the drive has no files', async () => { + const emptyBatchId = await buyStampSerialized( + bee, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + 'downloadEmptyIntegration', + ); + await fileManager.createDrive(emptyBatchId, 'download-empty-drive'); + const emptyDrive = fileManager.driveList.find((d) => d.name === 'download-empty-drive')!; + expect(emptyDrive).toBeDefined(); + + const downloadResults = await fileManager.downloadFolder(emptyDrive.id, '/'); + expect(downloadResults.succeeded).toEqual([]); + expect(downloadResults.failed).toEqual([]); + }); +}); + +describe('move', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let driveA: DriveInfo; + let driveB: DriveInfo; + const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + bee = beeDev; + const batchIdA = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'moveIntegrationA'); + const batchIdB = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'moveIntegrationB'); + fileManager = await createInitializedFileManager(bee, ownerStamp); + + await fileManager.createDrive(batchIdA, 'move-a'); + const tmpDriveA = fileManager.driveList.find((d) => d.name === 'move-a'); + expect(tmpDriveA).toBeDefined(); + driveA = tmpDriveA!; + + await fileManager.createDrive(batchIdB, 'move-b'); + const tmpDriveB = fileManager.driveList.find((d) => d.name === 'move-b'); + expect(tmpDriveB).toBeDefined(); + driveB = tmpDriveB!; + }); + + afterAll(cleanup); + + it('renames a file within the drive root, preserving content and bumping the version', async () => { + const fileA = writeTempFile('it-move-a.txt', 'Move Content A'); + await fileManager.uploadFile(driveA.id, { path: fileA, sourcePath: fileA }); + + const before = fileManager.recordList.find((fr) => fr.path === fileA)!; + expect(before).toBeDefined(); + const beforeVersion = BigInt((before.version ?? '0').toString()); + const topic = before.topic.toString(); + + await fileManager.move(fileA, 'it-move-b.txt', driveA.id); + + const rootEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); + expect(rootEntries.some((e) => e.path === fileA)).toBe(false); + expect(rootEntries.some((e) => e.type === NodeType.File && e.path === 'it-move-b.txt')).toBe(true); + + const moved = fileManager.recordList.find((fr) => fr.topic.toString() === topic)!; + expect(moved).toBeDefined(); + expect(moved.path).toBe('it-move-b.txt'); + expect(BigInt(moved.version!.toString())).toBe(beforeVersion + 1n); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveA.id, '/')); + const downloaded = downloadResults.succeeded.find((d) => d.path === 'it-move-b.txt'); + expect(downloaded).toBeDefined(); + expect(downloadResults.failed).toEqual([]); + expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Move Content A'); + }); + + it('moves a root file into a newly created folder', async () => { + const docFile = writeTempFile('it-move-doc.txt', 'Archive Me'); + await fileManager.uploadFile(driveA.id, { path: docFile, sourcePath: docFile }); + await fileManager.createFolder(driveA.id, ROOT_PATH, 'archive'); + + await fileManager.move(docFile, 'archive/doc.txt', driveA.id); + + const rootEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); + expect(rootEntries.some((e) => e.path === docFile)).toBe(false); + + const archiveEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(driveA.id, 'archive', ListDepth.Shallow), + ); + expect(archiveEntries.some((e) => e.type === NodeType.File && e.path === 'archive/doc.txt')).toBe(true); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveA.id, 'archive')); + const downloaded = downloadResults.succeeded.find((d) => d.path === 'archive/doc.txt'); + expect(downloaded).toBeDefined(); + expect(downloadResults.failed).toEqual([]); + expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Archive Me'); + }); + + it('moves a nested file back out to the drive root', async () => { + const folderName = 'it-move-inbox'; + await fileManager.createFolder(driveA.id, ROOT_PATH, folderName); + + writeTempDir(folderName, { 'note.txt': 'Inbox Note' }); + const inboxFilePath = path.join(folderName, 'note.txt'); + + await fileManager.uploadFile(driveA.id, { path: inboxFilePath, sourcePath: inboxFilePath }); + + await fileManager.move(inboxFilePath, 'note.txt', driveA.id); + + const rootEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); + expect(rootEntries.some((e) => e.type === NodeType.File && e.path === 'note.txt')).toBe(true); + + const folderEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(driveA.id, folderName, ListDepth.Shallow), + ); + expect(folderEntries.some((e) => e.path === inboxFilePath)).toBe(false); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveA.id, '/')); + const downloaded = downloadResults.succeeded.find((d) => d.path === 'note.txt'); + expect(downloaded).toBeDefined(); + expect(downloadResults.failed).toEqual([]); + expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Inbox Note'); + }); + + it('moves a file across drives, updating driveId and remaining downloadable from the target', async () => { + const xFile = writeTempFile('it-move-x.txt', 'Cross Drive Content'); + await fileManager.uploadFile(driveA.id, { path: xFile, sourcePath: xFile }); + + await fileManager.move(xFile, xFile, driveA.id, driveB.id); + + const driveAEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); + expect(driveAEntries.some((e) => e.path === xFile)).toBe(false); + + const driveBEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveB.id, '', ListDepth.Shallow)); + expect(driveBEntries.some((e) => e.type === NodeType.File && e.path === xFile)).toBe(true); + + const moved = fileManager.recordList.find((fr) => fr.path === xFile && fr.driveId === driveB.id.toString()); + expect(moved).toBeDefined(); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveB.id, '/')); + const downloaded = downloadResults.succeeded.find((d) => d.path === xFile); + expect(downloaded).toBeDefined(); + expect(downloadResults.failed).toEqual([]); + expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Cross Drive Content'); + }); + + it('rejects invalid move calls', async () => { + await expect(fileManager.move('it-move-nonexistent.txt', 'dest.txt', driveA.id)).rejects.toThrow(/not found/i); + + const sameFile = writeTempFile('it-move-same.txt', 'Same Path Content'); + await fileManager.uploadFile(driveA.id, { path: sameFile, sourcePath: sameFile }); + await expect(fileManager.move(sameFile, sameFile, driveA.id)).rejects.toThrow(/identical/i); + + await expect(fileManager.move(sameFile, 'nosuchfolder/dest.txt', driveA.id)).rejects.toThrow(/not found/i); + }); + + it('rejects a move onto an existing destination, leaving both files intact and downloadable', async () => { + const f1 = writeTempFile('it-move-collide-1.txt', 'Collide One'); + const f2 = writeTempFile('it-move-collide-2.txt', 'Collide Two'); + await fileManager.uploadFile(driveA.id, { path: f1, sourcePath: f1 }); + await fileManager.uploadFile(driveA.id, { path: f2, sourcePath: f2 }); + + await expect(fileManager.move(f1, f2, driveA.id)).rejects.toThrow(/already exists/i); + + const entries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); + expect(entries.some((e) => e.type === NodeType.File && e.path === f1)).toBe(true); + expect(entries.some((e) => e.type === NodeType.File && e.path === f2)).toBe(true); + + const rec2 = fileManager.recordList.find((fr) => fr.path === f2)!; + const downloaded = await retryOnPropagationDelay(() => fileManager.downloadFile(rec2)); + expect(Buffer.from(await streamToUint8Array(downloaded.result)).toString('utf-8')).toBe('Collide Two'); + }); + + it('after a move the file downloads from the new path and is gone from the old path', async () => { + const srcFile = writeTempFile('it-move-oldnew.txt', 'Old New Content'); + const up = await fileManager.uploadFiles(driveA.id, [{ path: 'oldp/f.txt', sourcePath: srcFile }], ''); + expect(up.failed).toHaveLength(0); + + await fileManager.createFolder(driveA.id, ROOT_PATH, 'newp'); + await fileManager.move('oldp/f.txt', 'newp/f.txt', driveA.id); + + const newDownloads = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveA.id, 'newp')); + expect(newDownloads.failed).toEqual([]); + const got = newDownloads.succeeded.find((d) => d.path === 'newp/f.txt'); + expect(got).toBeDefined(); + expect(Buffer.from(await streamToUint8Array(got!.result)).toString('utf-8')).toBe('Old New Content'); + + const oldDownloads = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveA.id, 'oldp')); + expect(oldDownloads.failed).toEqual([]); + expect(oldDownloads.succeeded).toEqual([]); + }); +}); diff --git a/tests/integration/fileManager.spec.ts b/tests/integration/fileManager.spec.ts deleted file mode 100644 index 7463b68..0000000 --- a/tests/integration/fileManager.spec.ts +++ /dev/null @@ -1,1886 +0,0 @@ -import { - BatchId, - Bee, - Bytes, - FeedIndex, - Identifier, - MantarayNode, - PostageBatch, - PrivateKey, - PublicKey, - RedundancyLevel, - Reference, - Topic, -} from '@ethersphere/bee-js'; -import * as fs from 'fs'; -import path from 'path'; -import { setTimeout } from 'timers'; - -import { createInitializedFileManager, MOCK_BATCH_ID } from '../mockHelpers'; -import { - createWrappedData, - DEFAULT_BATCH_AMOUNT, - DEFAULT_BATCH_DEPTH, - dowloadAndCompareFiles, - getTestFile, - OTHER_BEE_URL, - OTHER_MOCK_SIGNER, - readFilesOrDirectory, -} from '../utils'; - -import { ensureUniqueSignerWithStamp } from './testSetupHelpers'; - -import { FileManagerBase } from '@/fileManager'; -import { DriveInfo, FileInfo, FileStatus } from '@/types'; -import { StateTopicInfo } from '@/types/utils'; -import { - ADMIN_STAMP_LABEL, - DriveError, - FileError, - FileInfoError, - FILEMANAGER_STATE_TOPIC, - FileManagerEvents, - StampError, -} from '@/utils'; -import { assertStateTopicInfo } from '@/utils/asserts'; -import { buyStamp, getFeedData } from '@/utils/bee'; -import { FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; - -// TODO: emitter test for all events -// TODO: separate IT cases into different files -describe('FileManager initialization', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let actPublisher: PublicKey; - let drive: DriveInfo; - let adminBatchId: BatchId; - let signer: PrivateKey; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp, signer: newSigner } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - adminBatchId = ownerStamp; - signer = newSigner; - fileManager = await createInitializedFileManager(bee, adminBatchId); - actPublisher = (await bee.getNodeAddresses()).publicKey; - }); - - beforeEach(async () => { - jest.resetAllMocks(); - }); - - it('should create and initialize a new instance and check if admin stamp is not found', async () => { - expect(fileManager.fileInfoList).toEqual([]); - - const otherBee = new Bee(OTHER_BEE_URL, { signer: OTHER_MOCK_SIGNER }); - const fm2 = new FileManagerBase(otherBee); - try { - fm2.emitter.on(FileManagerEvents.INITIALIZED, (e) => { - expect(e).toBeTruthy(); - }); - await fm2.initialize(); - await fm2.createDrive(MOCK_BATCH_ID, 'Admin Drive', true, RedundancyLevel.OFF); - } catch (error: any) { - expect(error).toBeInstanceOf(StampError); - expect(error.message).toContain( - `Stamp with batchId: ${MOCK_BATCH_ID.toString().slice(0, 6)}... not found OR not usable`, - ); - } - - expect(fm2.fileInfoList).toEqual([]); - }); - - it('should initialize the admin feed and topic', async () => { - expect(fileManager.fileInfoList).toEqual([]); - - const { payload } = await getFeedData(bee, FILEMANAGER_STATE_TOPIC, signer.publicKey().address(), 0n); - const feedTopicState = payload.toJSON() as StateTopicInfo; - assertStateTopicInfo(feedTopicState); - const topicHex = await bee.downloadData(new Reference(feedTopicState.topicReference), { - actHistoryAddress: new Reference(feedTopicState.historyAddress), - actPublisher, - }); - expect(topicHex).not.toEqual(SWARM_ZERO_ADDRESS); - - await fileManager.initialize(); - const reinitTopicHex = await bee.downloadData(new Reference(feedTopicState.topicReference), { - actHistoryAddress: new Reference(feedTopicState.historyAddress), - actPublisher, - }); - expect(topicHex).toEqual(reinitTopicHex); - }); - - it('should throw an error if someone else than the admin tries to read the admin feed', async () => { - const otherBee = new Bee(OTHER_BEE_URL, { signer: OTHER_MOCK_SIGNER }); - - const { payload } = await getFeedData(bee, FILEMANAGER_STATE_TOPIC, signer.publicKey().address(), 0n); - const feedTopicState = payload.toJSON() as StateTopicInfo; - - try { - await bee.downloadData(new Reference(feedTopicState.topicReference), { - actHistoryAddress: new Reference(feedTopicState.historyAddress), - actPublisher: OTHER_MOCK_SIGNER.publicKey(), - }); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).stack?.includes('404')).toBeTruthy(); - } - - try { - await otherBee.downloadData(new Reference(feedTopicState.topicReference), { - actHistoryAddress: new Reference(feedTopicState.historyAddress), - actPublisher, - }); - } catch (error) { - expect(error).toBeInstanceOf(Error); - expect((error as Error).stack?.includes('500')).toBeTruthy(); - } - }); - - it('should upload to and fetch from swarm a nested folder with files', async () => { - let expNestedPaths = await readFilesOrDirectory(path.join(__dirname, '../fixtures/nested'), 'nested'); - const expFileDataArr: string[][] = []; - const fileDataArr: string[] = []; - for (const f of expNestedPaths) { - fileDataArr.push(getTestFile(`./fixtures/${f}`)); - } - const exptTestFileData = getTestFile('fixtures/test.txt'); - expNestedPaths.concat(await readFilesOrDirectory(path.join(__dirname, '../fixtures/test.txt'), 'test.txt')); - expFileDataArr.push(fileDataArr); - expFileDataArr.push([exptTestFileData]); - - const batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'initstamp'); - - await fileManager.createDrive(batchId, 'initialization', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'initialization'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - - { - await fileManager.upload(drive, { name: 'nested', path: path.join(__dirname, '../fixtures/nested') }); - - await fileManager.upload(drive, { - name: 'test.txt', - path: path.join(__dirname, '../fixtures/test.txt'), - }); - - const fileInfoList = fileManager.fileInfoList; - expect(fileInfoList).toHaveLength(expFileDataArr.length); - await dowloadAndCompareFiles(fileManager, actPublisher.toCompressedHex(), fileInfoList, expFileDataArr); - - const fileList = await fileManager.listFiles(fileInfoList[0], undefined, { - actHistoryAddress: fileInfoList[0].file.historyRef, - actPublisher, - }); - expect(Object.keys(fileList)).toHaveLength(expNestedPaths.length); - Object.keys(fileList).forEach((key, ix) => { - expect(path.basename(key)).toEqual(path.basename(expNestedPaths[ix])); - }); - } - - const fm2 = new FileManagerBase(bee); - await fm2.initialize(); - const fileInfoList = fm2.fileInfoList; - await dowloadAndCompareFiles(fm2, actPublisher.toCompressedHex(), fileInfoList, expFileDataArr); - }); - - it('should verify Bee versions and supported API', async () => { - const versions = await bee.getVersions(); - expect(versions.beeVersion).toBeDefined(); - expect(versions.beeApiVersion).toBeDefined(); - const supported = await bee.isSupportedApiVersion(); - expect(supported).toBeTruthy(); - }); - - it('should not reinitialize if already initialized', async () => { - const fileInfoListBefore = [...fileManager.fileInfoList]; - fileManager.emitter.on(FileManagerEvents.INITIALIZED, (e) => { - expect(e).toEqual(true); - }); - await fileManager.initialize(); - expect(fileManager.fileInfoList).toEqual(fileInfoListBefore); - }); - - it('should maintain isInitialized flag after successful reinitialization', async () => { - expect((fileManager as any).isInitialized).toBe(true); - await fileManager.initialize(); - expect((fileManager as any).isInitialized).toBe(true); - }); - - it('should not clear drives when reinitializing with valid stamp', async () => { - const drivesBefore = fileManager.driveList; - expect(drivesBefore.length).toBeGreaterThan(0); - - await fileManager.initialize(); - - const drivesAfter = fileManager.driveList; - expect(drivesAfter).toEqual(drivesBefore); - }); - - it('should maintain admin stamp reference after reinitialization', async () => { - const adminStampBefore = fileManager.adminStamp; - expect(adminStampBefore).toBeDefined(); - - await fileManager.initialize(); - - const adminStampAfter = fileManager.adminStamp; - expect(adminStampAfter).toBeDefined(); - expect(adminStampAfter?.batchID.toString()).toBe(adminStampBefore?.batchID.toString()); - }); -}); - -describe('FileManager reinitialization', () => { - it('should emit STATE_INVALID after expiry', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - await createInitializedFileManager(beeDev, ownerStamp); - - const originalFn = beeDev.getPostageBatches.bind(beeDev); - const spy = jest.spyOn(beeDev, 'getPostageBatches'); - - spy.mockImplementation(async () => { - await originalFn(); - return []; - }); - - const newFileManager = new FileManagerBase(beeDev); - - newFileManager.emitter.on(FileManagerEvents.STATE_INVALID, (stateInvalidEmitted) => { - expect(stateInvalidEmitted).toBe(true); - }); - - newFileManager.emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { - expect(success).toBe(true); - }); - - await newFileManager.initialize(); - - expect(newFileManager.driveList).toHaveLength(0); - expect(newFileManager.fileInfoList).toHaveLength(0); - - spy.mockRestore(); - }); - - it('should successfully revalidate when admin stamp is still valid', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - const fileManager = await createInitializedFileManager(beeDev, ownerStamp); - - const initialDrives = fileManager.driveList; - const initialFileCount = fileManager.fileInfoList.length; - - expect(initialDrives.length).toBeGreaterThanOrEqual(1); - - let initEventFired = false; - let invalidEventFired = false; - - fileManager.emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { - initEventFired = true; - expect(success).toBe(true); - }); - - fileManager.emitter.on(FileManagerEvents.STATE_INVALID, () => { - invalidEventFired = true; - }); - - await fileManager.initialize(); - - expect(initEventFired).toBe(true); - expect(invalidEventFired).toBe(false); - expect(fileManager.driveList).toEqual(initialDrives); - expect(fileManager.fileInfoList).toHaveLength(initialFileCount); - }); - - it('should preserve user data when creating a new instance with valid stamp', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - const fileManager = await createInitializedFileManager(beeDev, ownerStamp); - - const userBatchId = await buyStamp(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'userDrive'); - await fileManager.createDrive(userBatchId, 'User Drive', false); - - const drivesBeforeReinit = fileManager.driveList; - const userDrive = drivesBeforeReinit.find((d) => d.name === 'User Drive'); - expect(userDrive).toBeDefined(); - - const newFileManager = new FileManagerBase(beeDev); - await newFileManager.initialize(); - - const drivesAfterReinit = newFileManager.driveList; - expect(drivesAfterReinit).toHaveLength(drivesBeforeReinit.length); - const userDriveAfter = drivesAfterReinit.find((d) => d.name === 'User Drive'); - expect(userDriveAfter).toBeDefined(); - expect(userDriveAfter?.id).toBe(userDrive?.id); - }); - - it('should handle multiple sequential reinitializations with valid stamp', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - const fileManager = await createInitializedFileManager(beeDev, ownerStamp); - - const initialDriveCount = fileManager.driveList.length; - - for (let i = 0; i < 3; i++) { - await fileManager.initialize(); - expect(fileManager.driveList).toHaveLength(initialDriveCount); - } - - for (let i = 0; i < 2; i++) { - const freshManager = new FileManagerBase(beeDev); - await freshManager.initialize(); - expect(freshManager.driveList).toHaveLength(initialDriveCount); - } - }); - - it('should allow operations after successful revalidation', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - const fileManager = await createInitializedFileManager(beeDev, ownerStamp); - - await fileManager.initialize(); - - const newBatchId = await buyStamp(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'afterReinit'); - await fileManager.createDrive(newBatchId, 'Post Reinit Drive', false); - - const drives = fileManager.driveList; - const newDrive = drives.find((d) => d.name === 'Post Reinit Drive'); - expect(newDrive).toBeDefined(); - }); - - it('should emit correct events during revalidation failure', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - const originalFn = beeDev.getPostageBatches.bind(beeDev); - const spy = jest.spyOn(beeDev, 'getPostageBatches'); - - spy.mockImplementation(async () => { - const batches = await originalFn(); - return batches.map((b: any) => ({ - ...b, - usable: true, - label: b.label === ADMIN_STAMP_LABEL ? 'admin' : b.label, - })); - }); - - await createInitializedFileManager(beeDev, ownerStamp); - - spy.mockImplementation(async () => { - await originalFn(); - return []; - }); - - const events: string[] = []; - - const newFileManager = new FileManagerBase(beeDev); - newFileManager.emitter.on(FileManagerEvents.STATE_INVALID, () => { - events.push('STATE_INVALID'); - }); - newFileManager.emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { - events.push(`INITIALIZED:${success}`); - }); - - await newFileManager.initialize(); - - expect(events).toContain('STATE_INVALID'); - expect(events).toContain('INITIALIZED:true'); - - spy.mockRestore(); - }); - - it('should not affect other drives when revalidating admin stamp', async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - const fileManager = await createInitializedFileManager(beeDev, ownerStamp); - - const batch1 = await buyStamp(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'drive1'); - const batch2 = await buyStamp(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'drive2'); - - await fileManager.createDrive(batch1, 'Drive 1', false); - await fileManager.createDrive(batch2, 'Drive 2', false); - - const drivesBeforeReinit = fileManager.driveList; - const drive1 = drivesBeforeReinit.find((d) => d.name === 'Drive 1'); - const drive2 = drivesBeforeReinit.find((d) => d.name === 'Drive 2'); - - expect(drive1).toBeDefined(); - expect(drive2).toBeDefined(); - - await fileManager.initialize(); - - const drivesAfterReinit = fileManager.driveList; - expect(drivesAfterReinit.find((d) => d.id === drive1?.id)).toBeDefined(); - expect(drivesAfterReinit.find((d) => d.id === drive2?.id)).toBeDefined(); - }); -}); - -describe('FileManager drive handling', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let ownerBatch: PostageBatch; - let tempDir: string; - let signer: PrivateKey; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp, signer: newSigner } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - signer = newSigner; - const stamp = (await bee.getPostageBatches()).find((s) => s.batchID.toString() === ownerStamp.toString()); - - expect(stamp).toBeDefined(); - expect(stamp?.batchID.toString() === ownerStamp.toString()).toBeTruthy(); - ownerBatch = stamp!; - - fileManager = await createInitializedFileManager(bee, ownerStamp); - - tempDir = path.join(__dirname, 'tmpDriveFolder'); - fs.mkdirSync(tempDir, { recursive: true }); - fs.writeFileSync(path.join(tempDir, 'a.txt'), 'Content A'); - }); - - afterAll(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('should create a drive and retrieve it', async () => { - const batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'createDriveStamp'); - - await fileManager.createDrive(batchId, 'Test Drive', false); - const drives = fileManager.driveList; - expect(drives.length).toBeGreaterThanOrEqual(1); - const testDrive = drives.find((d) => d.name === 'Test Drive'); - expect(testDrive).toBeDefined(); - expect(new Identifier(testDrive!.id)).toHaveLength(Identifier.LENGTH); - expect(testDrive!.batchId).toBe(batchId.toString()); - expect(testDrive!.name).toBe('Test Drive'); - expect(testDrive!.owner).toBe(signer.publicKey().address().toHex()); - expect(testDrive!.redundancyLevel).toBe(RedundancyLevel.OFF); - expect(fileManager.fileInfoList.filter((fi) => fi.driveId === testDrive!.id)).toHaveLength(0); - }); - - it('should throw an error when trying to destroy the admin drive/ stamp', async () => { - await expect( - fileManager.destroyDrive( - { - batchId: ownerBatch.batchID.toString(), - id: 'mockID', - name: 'Admin Drive', - owner: signer.publicKey().address().toString(), - redundancyLevel: RedundancyLevel.OFF, - isAdmin: false, - }, - ownerBatch, - ), - ).rejects.toThrow(new DriveError(`Cannot destroy admin drive / stamp, batchId: ${ownerBatch.batchID.toString()}`)); - - await expect( - fileManager.destroyDrive( - { - batchId: new BatchId('6789'.repeat(16)).toString(), - id: 'mockID', - name: 'Admin Drive', - owner: signer.publicKey().address().toString(), - redundancyLevel: RedundancyLevel.OFF, - isAdmin: true, - }, - ownerBatch, - ), - ).rejects.toThrow(new StampError(`Stamp does not match drive stamp`)); - - await expect( - fileManager.destroyDrive( - { - batchId: ownerBatch.batchID.toString(), - id: 'mockID', - name: 'Admin Drive', - owner: signer.publicKey().address().toString(), - redundancyLevel: RedundancyLevel.OFF, - isAdmin: true, - }, - ownerBatch, - ), - ).rejects.toThrow(new DriveError(`Cannot destroy admin drive / stamp, batchId: ${ownerBatch.batchID.toString()}`)); - }); - - it('should forget a user drive: removes the drive, prunes its files, and persists the change', async () => { - const forgetBatchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'forgetDriveStamp'); - await fileManager.createDrive(forgetBatchId, 'Drive to forget', false); - - const created = fileManager.driveList.find((d) => d.name === 'Drive to forget'); - expect(created).toBeDefined(); - const driveId = created!.id.toString(); - const initialDriveCount = fileManager.driveList.length; - - const now = Date.now(); - const fakeFile = (topic: string, name: string): FileInfo => ({ - batchId: created!.batchId, - owner: signer.publicKey().address().toString(), - topic, - name, - actPublisher: signer.publicKey().toCompressedHex(), - file: { reference: '0xref', historyRef: '0xhref' }, - driveId, - timestamp: now, - version: '0', - redundancyLevel: RedundancyLevel.OFF, - status: FileStatus.Active, - }); - - fileManager.fileInfoList.push(fakeFile('topic-1', 'a.txt')); - fileManager.fileInfoList.push(fakeFile('topic-2', 'b.txt')); - - const eventPromise = new Promise((resolve) => { - const handler = ({ driveInfo }: { driveInfo: DriveInfo }): void => { - try { - expect(driveInfo.id.toString()).toBe(driveId); - resolve(); - } finally { - fileManager.emitter?.off?.(FileManagerEvents.DRIVE_FORGOTTEN, handler); - } - }; - fileManager.emitter.on(FileManagerEvents.DRIVE_FORGOTTEN, handler); - }); - await fileManager.forgetDrive(created!); - await eventPromise; - const afterForgetDrives = fileManager.driveList; - expect(afterForgetDrives).toHaveLength(initialDriveCount - 1); - expect(afterForgetDrives.find((d) => d.id.toString() === driveId)).toBeUndefined(); - - expect(fileManager.fileInfoList.some((fi) => fi.driveId === driveId)).toBe(false); - - const fm2 = await createInitializedFileManager(bee, ownerBatch.batchID); - const drives2 = fm2.driveList; - expect(drives2.find((d) => d.name === 'Drive to forget')).toBeUndefined(); - }); - - it('should throw when trying to forget the admin drive', async () => { - const adminDrive = fileManager.driveList.find((d) => d.isAdmin); - expect(adminDrive).toBeDefined(); - await expect(fileManager.forgetDrive(adminDrive!)).rejects.toThrow(new DriveError('Cannot forget admin drive')); - }); - - it('should throw when trying to forget a non-existent drive', async () => { - const idBytes = new Uint8Array(Identifier.LENGTH); - idBytes.fill(1); - const ghost: any = { - id: new Identifier(idBytes).toString(), - name: 'ghost', - batchId: new BatchId('abcd'.repeat(16)).toString(), - owner: signer.publicKey().address().toString(), - redundancyLevel: RedundancyLevel.OFF, - isAdmin: false, - }; - await expect(fileManager.forgetDrive(ghost)).rejects.toThrow(new DriveError('Drive ghost not found')); - }); -}); - -describe('FileManager listFiles', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let tempDir: string; - let actPublisher: PublicKey; - let drive: DriveInfo; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - - tempDir = path.join(__dirname, 'tmpIntegrationListFiles'); - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'listFilesIntegrationStamp'); - - fileManager = await createInitializedFileManager(bee, ownerStamp); - actPublisher = (await bee.getNodeAddresses()).publicKey; - - await fileManager.createDrive(batchId, 'listFiles', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'listFiles'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - - fs.mkdirSync(tempDir, { recursive: true }); - - fs.writeFileSync(path.join(tempDir, 'a.txt'), 'Content A'); - fs.writeFileSync(path.join(tempDir, 'b.txt'), 'Content B'); - - const subfolder = path.join(tempDir, 'subfolder'); - fs.mkdirSync(subfolder, { recursive: true }); - fs.writeFileSync(path.join(subfolder, 'c.txt'), 'Content C'); - }); - - afterAll(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - it('should return a list of files for the uploaded folder', async () => { - await fileManager.upload(drive, { name: path.basename(tempDir), path: tempDir }); - - const allFileInfos = fileManager.fileInfoList; - const fileInfo = allFileInfos.find((fi) => fi.name === path.basename(tempDir)); - expect(fileInfo).toBeDefined(); - - const fileList = await fileManager.listFiles(fileInfo!, undefined, { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - }); - - const returnedBasenames = Object.keys(fileList).map((filePath) => path.basename(filePath)); - expect(returnedBasenames).toContain('a.txt'); - expect(returnedBasenames).toContain('b.txt'); - expect(returnedBasenames).toContain('c.txt'); - expect(Object.keys(fileList)).toHaveLength(3); - }); - - it('should throw and return an empty file list when uploading an empty folder', async () => { - const emptyDir = path.join(__dirname, 'emptyFolder'); - fs.mkdirSync(emptyDir, { recursive: true }); - - let fileInfo: FileInfo | undefined; - try { - await fileManager.upload(drive, { - name: path.basename(emptyDir), - path: emptyDir, - }); - const allFileInfos = fileManager.fileInfoList; - fileInfo = allFileInfos.find((fi) => fi.name === path.basename(emptyDir)); - } catch (error: any) { - expect(error).toBeInstanceOf(FileError); - expect(String(error.cause)).toMatch(/status code 400/); - fs.rmSync(emptyDir, { recursive: true, force: true }); - return; - } - - expect(fileInfo).toBeDefined(); - const fileList = await fileManager.listFiles(fileInfo!, undefined, { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - }); - expect(Object.keys(fileList)).toHaveLength(0); - - fs.rmSync(emptyDir, { recursive: true, force: true }); - }); - - it('should correctly return nested file paths in a deeply nested folder structure', async () => { - const deepDir = path.join(__dirname, 'deepNestedFolder'); - const level1 = path.join(deepDir, 'level1'); - const level2 = path.join(level1, 'level2'); - const level3 = path.join(level2, 'level3'); - fs.mkdirSync(level3, { recursive: true }); - fs.writeFileSync(path.join(level3, 'd.txt'), 'Content D'); - - await fileManager.upload(drive, { - name: path.basename(deepDir), - path: deepDir, - }); - const allFileInfos = fileManager.fileInfoList; - const fileInfo = allFileInfos.find((fi) => fi.name === path.basename(deepDir)); - expect(fileInfo).toBeDefined(); - - const fileList = await fileManager.listFiles(fileInfo!, undefined, { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - }); - - const returnedBasenames = Object.keys(fileList).map((filePath) => path.basename(filePath)); - expect(returnedBasenames).toContain('d.txt'); - - const expectedFullPath = path.join('level1', 'level2', 'level3', 'd.txt'); - const foundPath = Object.keys(fileList).find((filePath) => filePath === expectedFullPath); - expect(foundPath).toBeDefined(); - - fs.rmSync(deepDir, { recursive: true, force: true }); - }); - - it('should ignore entries with empty paths', async () => { - const folderWithEmpty = path.join(__dirname, 'folderWithEmpty'); - fs.mkdirSync(folderWithEmpty, { recursive: true }); - fs.writeFileSync(path.join(folderWithEmpty, 'valid.txt'), 'Valid Content'); - fs.writeFileSync(path.join(folderWithEmpty, 'empty.txt'), 'Should be ignored'); - - await fileManager.upload(drive, { - name: path.basename(folderWithEmpty), - path: folderWithEmpty, - }); - const allFileInfos = fileManager.fileInfoList; - const fileInfo = allFileInfos.find((fi) => fi.name === path.basename(folderWithEmpty)); - expect(fileInfo).toBeDefined(); - - let fileList = await fileManager.listFiles(fileInfo!, undefined, { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - }); - - const modifiedFileList: Record = {}; - Object.entries(fileList).forEach(([filePath, reference]) => { - if (path.basename(filePath) === 'empty.txt') { - modifiedFileList[''] = reference; - } else { - modifiedFileList[filePath] = reference; - } - }); - - const filteredEntries = Object.entries(modifiedFileList).filter(([filePath]) => filePath !== ''); - const returnedBasenames = filteredEntries.map(([filePath]) => path.basename(filePath)); - expect(returnedBasenames).toContain('valid.txt'); - expect(returnedBasenames).not.toContain('empty.txt'); - - fs.rmSync(folderWithEmpty, { recursive: true, force: true }); - }); -}); - -describe('FileManager upload', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let tempUploadDir: string; - let drive: DriveInfo; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - - tempUploadDir = path.join(__dirname, 'tmpUploadIntegration'); - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'uploadIntegrationStamp'); - fileManager = await createInitializedFileManager(bee, ownerStamp); - - await fileManager.createDrive(batchId, 'upload', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'upload'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - - fs.mkdirSync(tempUploadDir, { recursive: true }); - fs.writeFileSync(path.join(tempUploadDir, 'file1.txt'), 'Upload Content 1'); - fs.writeFileSync(path.join(tempUploadDir, 'file2.txt'), 'Upload Content 2'); - const subfolder = path.join(tempUploadDir, 'subfolder'); - fs.mkdirSync(subfolder, { recursive: true }); - fs.writeFileSync(path.join(subfolder, 'file3.txt'), 'Upload Content 3'); - }); - - afterAll(() => { - fs.rmSync(tempUploadDir, { recursive: true, force: true }); - }); - - it('should upload a directory and update the file info list with different versions', async () => { - await fileManager.upload(drive, { name: path.basename(tempUploadDir), path: tempUploadDir }); - const firstInfo = fileManager.fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - expect(firstInfo).toBeDefined(); - - await fileManager.upload( - drive, - { - name: path.basename(tempUploadDir), - topic: firstInfo?.topic, - path: tempUploadDir, - }, - { - actHistoryAddress: new Reference(firstInfo!.file.historyRef), - }, - ); - const secondInfo = fileManager.fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - const secondVersion = new FeedIndex(firstInfo!.version!).next(); - expect(secondInfo).toBeDefined(); - expect(secondInfo?.topic).toEqual(firstInfo?.topic); - expect(secondInfo?.version).toEqual(secondVersion.toString()); - - // getTopicAndVersion advances the supplied current version by one, so pass the current - // (second) version and expect the next slot to be written. - const thirdVersion = secondVersion.next().toString(); - await fileManager.upload( - drive, - { - name: path.basename(tempUploadDir), - topic: firstInfo?.topic, - version: secondInfo?.version, - path: tempUploadDir, - }, - { - actHistoryAddress: new Reference(firstInfo!.file.historyRef), - }, - ); - const thirdInfo = fileManager.fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - expect(thirdInfo).toBeDefined(); - expect(thirdInfo?.topic).toEqual(firstInfo?.topic); - expect(thirdInfo?.version).toEqual(thirdVersion); - }); - - it('should NOT re-upload the same file but update the metadata', async () => { - await fileManager.upload(drive, { name: path.basename(tempUploadDir), path: tempUploadDir }); - const firstInfo = fileManager.fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - expect(firstInfo).toBeDefined(); - - await fileManager.upload( - drive, - { - name: path.basename(tempUploadDir), - topic: firstInfo?.topic, - file: firstInfo?.file, - path: tempUploadDir, - }, - { - actHistoryAddress: new Reference(firstInfo!.file.historyRef), - }, - ); - const secondInfo = fileManager.fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - expect(secondInfo).toBeDefined(); - expect(secondInfo?.file).toEqual(firstInfo?.file); - - await fileManager.upload( - drive, - { - name: path.basename(tempUploadDir), - topic: firstInfo?.topic, - file: firstInfo?.file, - path: tempUploadDir, - }, - { - actHistoryAddress: new Reference(firstInfo!.file.historyRef), - }, - ); - const thirdInfo = fileManager.fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - expect(thirdInfo).toBeDefined(); - expect(thirdInfo?.file).toEqual(firstInfo?.file); - }); - - it('should upload with previewPath if provided', async () => { - const previewDir = path.join(__dirname, 'tmpUploadPreview'); - fs.mkdirSync(previewDir, { recursive: true }); - fs.writeFileSync(path.join(previewDir, 'preview.txt'), 'Preview Content'); - - await fileManager.upload(drive, { - name: path.basename(tempUploadDir), - path: tempUploadDir, - previewPath: previewDir, - }); - - const fileInfoList = fileManager.fileInfoList; - const uploadedInfo = fileInfoList.find((fi) => fi.name === path.basename(tempUploadDir)); - expect(uploadedInfo).toBeDefined(); - - if (uploadedInfo!.preview !== undefined) { - expect(uploadedInfo!.preview).toBeDefined(); - } else { - console.warn('Preview property is not defined. Your implementation may not store preview info.'); - } - - fs.rmSync(previewDir, { recursive: true, force: true }); - }); - - it('should throw an error if topic and historyRef are not provided together', async () => { - await expect( - fileManager.upload(drive, { - name: path.basename(tempUploadDir), - topic: 'someInfoTopic', - path: tempUploadDir, - }), - ).rejects.toThrow(new FileInfoError('Options topic and historyRef have to be provided at the same time.')); - }); - - it('should upload a single file and update the file info list', async () => { - const tempFile = path.join(__dirname, 'tempFile.txt'); - fs.writeFileSync(tempFile, 'Single File Content'); - await fileManager.upload(drive, { - name: path.basename(tempFile), - path: tempFile, - }); - const fileInfoList = fileManager.fileInfoList; - const uploadedInfo = fileInfoList.find((fi) => fi.name === path.basename(tempFile)); - expect(uploadedInfo).toBeDefined(); - fs.rmSync(tempFile, { force: true }); - }); - - it('does not create a second fileInfo when bumping to a new version', async () => { - const dirName = path.basename(tempUploadDir); - - await fileManager.upload(drive, { name: dirName, path: tempUploadDir }); - const original = fileManager.fileInfoList.find((fi) => fi.name === dirName)!; - expect(original).toBeDefined(); - - await fileManager.upload( - drive, - { - name: dirName, - topic: original.topic, - path: tempUploadDir, - }, - { - actHistoryAddress: new Reference(original.file.historyRef), - }, - ); - - const entries = fileManager.fileInfoList.filter((fi) => fi.name === dirName && fi.topic === original.topic); - expect(entries).toHaveLength(1); - - const bumped = entries[0]; - expect(BigInt(bumped.version!)).toBeGreaterThan(BigInt(original.version! || '0')); - }); -}); - -describe('FileManager download', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let tempDownloadDir: string; - const expectedContents: Record = {}; - let actPublisher: PublicKey; - let drive: DriveInfo; - let signer: PrivateKey; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp, signer: newSigner } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - tempDownloadDir = path.join(__dirname, 'tmpDownloadIntegration'); - signer = newSigner; - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'downloadFilesIntegrationStamp'); - fileManager = await createInitializedFileManager(bee, ownerStamp); - actPublisher = (await bee.getNodeAddresses()).publicKey; - - await fileManager.createDrive(batchId, 'download', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'download'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - - fs.mkdirSync(tempDownloadDir, { recursive: true }); - - const file1Path = path.join(tempDownloadDir, 'alpha.txt'); - const file2Path = path.join(tempDownloadDir, 'beta.txt'); - fs.writeFileSync(file1Path, 'Download Content Alpha'); - fs.writeFileSync(file2Path, 'Download Content Beta'); - expectedContents['alpha.txt'] = 'Download Content Alpha'; - expectedContents['beta.txt'] = 'Download Content Beta'; - - const subfolder = path.join(tempDownloadDir, 'subfolder'); - fs.mkdirSync(subfolder, { recursive: true }); - const file3Path = path.join(subfolder, 'gamma.txt'); - fs.writeFileSync(file3Path, 'Download Content Gamma'); - expectedContents['gamma.txt'] = 'Download Content Gamma'; - - await fileManager.upload(drive, { - name: path.basename(tempDownloadDir), - path: tempDownloadDir, - }); - }); - - afterAll(() => { - fs.rmSync(tempDownloadDir, { recursive: true, force: true }); - }); - - it('should download all file contents from the uploaded manifest', async () => { - const allFileInfos = fileManager.fileInfoList; - const fileInfo = allFileInfos.find((fi) => fi.name === path.basename(tempDownloadDir)); - expect(fileInfo).toBeDefined(); - - const fileContents = (await fileManager.download(fileInfo!, undefined, { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - })) as Bytes[]; - const expectedArray = Object.values(expectedContents); - const fileContentsAsStrings = fileContents.map((item) => (item as Bytes).toUtf8()); - expect(fileContentsAsStrings.sort()).toEqual(expectedArray.sort()); - }); - - it('should download only the specified fork(s)', async () => { - const allFileInfos = fileManager.fileInfoList; - const fileInfo = allFileInfos.find((fi) => fi.name === path.basename(tempDownloadDir)); - expect(fileInfo).toBeDefined(); - - let fileContents = (await fileManager.download(fileInfo!, ['alpha.txt'], { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - })) as Bytes[]; - let fileContentsAsStrings = fileContents.map((item) => item.toUtf8()); - expect(fileContentsAsStrings).toEqual([expectedContents['alpha.txt']]); - - fileContents = (await fileManager.download(fileInfo!, ['alpha.txt', 'beta.txt'], { - actHistoryAddress: fileInfo!.file.historyRef, - actPublisher, - })) as Bytes[]; - const fileContentsArr: string[][] = []; - fileContents.forEach((item) => fileContentsArr.push([item.toUtf8()])); - expect(fileContentsArr).toEqual([[expectedContents['alpha.txt']], [expectedContents['beta.txt']]]); - }); - - it('should return an empty array when the manifest is empty', async () => { - const wrappedDataObject = await createWrappedData(bee, batchId, new MantarayNode()); - - const files = await fileManager.download( - { - batchId, - name: 'name', - file: wrappedDataObject, - owner: signer.publicKey().address(), - actPublisher, - } as FileInfo, - undefined, - { - actHistoryAddress: wrappedDataObject.historyRef, - actPublisher, - }, - ); - expect(files).toHaveLength(0); - }); -}); - -describe('FileManager file operations', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let testFi: FileInfo; - let drive: DriveInfo; - let testFilePath: string; - const TEST_NAME = 'trash-restore-forget.txt'; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'fileOpsIntegration'); - fileManager = await createInitializedFileManager(bee, ownerStamp); - - await fileManager.createDrive(batchId, 'fileoperations', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'fileoperations'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - - testFilePath = path.join(__dirname, '../fixtures', TEST_NAME); - fs.writeFileSync(testFilePath, 'file ops content'); - await fileManager.upload(drive, { name: TEST_NAME, path: testFilePath }); - - testFi = fileManager.fileInfoList.find((fi) => fi.name === TEST_NAME)!; - expect(testFi).toBeDefined(); - expect(testFi.status).toBe(FileStatus.Active); - }); - - afterAll(() => { - fs.rmSync(testFilePath, { force: true }); - }); - - it('should trash a file (soft-delete)', async () => { - const initial = fileManager.fileInfoList.find((fi) => fi.name === TEST_NAME)!; - const beforeVersion = BigInt(initial.version ?? '0'); - - await fileManager.trashFile(initial); - expect(initial.status).toBe(FileStatus.Trashed); - - const fm2 = new FileManagerBase(bee); - await fm2.initialize(); - - const fi2 = fm2.fileInfoList.find((fi) => fi.name === TEST_NAME)!; - expect(fi2.status).toBe(FileStatus.Trashed); - expect(BigInt(fi2.version!)).toBe(beforeVersion + 1n); - }); - - it('should recover a previously trashed file', async () => { - if (testFi.status !== FileStatus.Trashed) { - await fileManager.trashFile(testFi); - expect(testFi.status).toBe(FileStatus.Trashed); - } else { - expect(testFi.status).toBe(FileStatus.Trashed); - } - const beforeVersion = BigInt(testFi.version!); - - await fileManager.recoverFile(testFi); - - const fm2 = new FileManagerBase(bee); - await fm2.initialize(); - - const fi2 = fm2.fileInfoList.find((fi) => fi.name === TEST_NAME)!; - expect(fi2.status).toBe(FileStatus.Active); - expect(BigInt(fi2.version!)).toBe(beforeVersion + 1n); - }); - - it('should forget (hard-delete) a file', async () => { - await fileManager.forgetFile(testFi); - expect(fileManager.fileInfoList.find((fi) => fi.name === TEST_NAME)).toBeUndefined(); - - const fm2 = new FileManagerBase(bee); - await fm2.initialize(); - - expect(fm2.fileInfoList.find((fi) => fi.name === TEST_NAME)).toBeUndefined(); - }); - - it('should never duplicate FileInfo entries when trashing/recovering', async () => { - const fp = path.join(__dirname, '../fixtures', TEST_NAME); - await fileManager.upload(drive, { name: TEST_NAME, path: fp }); - - const freshFi = fileManager.fileInfoList.find((fi) => fi.name === TEST_NAME)!; - const topic = freshFi.topic.toString(); - expect(fileManager.fileInfoList.filter((fi) => fi.topic.toString() === topic)).toHaveLength(1); - - await fileManager.trashFile(freshFi); - expect(freshFi.status).toBe(FileStatus.Trashed); - - await expect(fileManager.trashFile(freshFi)).rejects.toThrow(/File already Thrashed/i); - - await fileManager.recoverFile(freshFi); - expect(freshFi.status).toBe(FileStatus.Active); - - await expect(fileManager.recoverFile(freshFi)).rejects.toThrow(/Non-Thrashed files cannot be restored/i); - - expect(fileManager.fileInfoList.filter((fi) => fi.topic.toString() === topic)).toHaveLength(1); - }); - - it('fileInfoList should never gain duplicate topics when trash/restoring', async () => { - const fm = new FileManagerBase(bee); - await fm.initialize(); - - const fi0 = fm.fileInfoList.find((fi) => fi.name === TEST_NAME)!; - const topic = fi0.topic.toString(); - const beforeVer = BigInt(fi0.version!); - - if (fi0.status !== FileStatus.Trashed) { - await fm.trashFile(fi0); - } - await fm.recoverFile(fi0); - - const fm2 = new FileManagerBase(bee); - await fm2.initialize(); - const fi2 = fm2.fileInfoList.find((fi) => fi.topic.toString() === topic)!; - - expect(BigInt(fi2.version!)).toBe(beforeVer + 2n); - }); -}); - -describe('FileManager version control', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let drive: DriveInfo; - let signer: PrivateKey; - - // helper to ensure at least one base FileInfo exists - const ensureBase = async (name = `versioned-file-${Date.now()}`, di: DriveInfo = drive): Promise => { - const existing = fileManager.fileInfoList.find((f) => f.name === name); - if (existing) return existing; - const tmp = path.join(__dirname, 'seed.txt'); - fs.writeFileSync(tmp, 'seed'); - await fileManager.upload(di, { name, path: tmp }); - fs.unlinkSync(tmp); - return fileManager.fileInfoList.at(-1)!; - }; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp, signer: newSigner } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - signer = newSigner; - - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'versioningStamp'); - fileManager = await createInitializedFileManager(bee, ownerStamp); - - await fileManager.createDrive(batchId, 'versioncontrol', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'versioncontrol'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - }); - - it('throws on invalid version index', async () => { - const base = await ensureBase(); - await expect(fileManager.getVersion(base, BigInt(999).toString())).rejects.toThrow(); - await expect(fileManager.getVersion(base, BigInt(-1).toString())).rejects.toThrow(); - }); - - it('handles sequential uploads with proper slot indices', async () => { - const tmpDir = fs.mkdtempSync(path.join(__dirname, 'par-')); - try { - const name = `parallel-${Date.now()}`; - const p0 = path.join(tmpDir, 'f0.txt'); - fs.writeFileSync(p0, 'v0'); - await fileManager.upload(drive, { name, path: p0 }); - const base = fileManager.fileInfoList.at(-1)!; - - let latestVersion = BigInt(base.version!); - let latest = await fileManager.getVersion(base, FeedIndex.fromBigInt(latestVersion)); - - for (const i of [1, 2, 3]) { - const fn = path.join(tmpDir, `f${i}.txt`); - fs.writeFileSync(fn, `v${i}`); - await fileManager.upload( - drive, - { name, topic: base.topic.toString(), path: fn }, - { - actHistoryAddress: new Reference(latest.file.historyRef), - }, - ); - - latestVersion = BigInt(i); - } - - expect(latestVersion).toBe(BigInt(base.version!) + 3n); - - for (let i = 0n; i < latestVersion; i++) { - const fi = await fileManager.getVersion(base, FeedIndex.fromBigInt(i)); - expect(fi.version).toBe(FeedIndex.fromBigInt(i).toString()); - } - - // Fetch the current head without specifying an index - const newLatest = await fileManager.getVersion(base); - expect(newLatest.version).toBe(FeedIndex.fromBigInt(latestVersion).toString()); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('getVersion + download returns the correct bytes subset', async () => { - const dir = path.join(__dirname, 'coll'); - try { - fs.mkdirSync(dir, { recursive: true }); - fs.writeFileSync(path.join(dir, 'a.txt'), 'A'); - fs.writeFileSync(path.join(dir, 'b.txt'), 'B'); - - const name = `coll-${Date.now()}`; - await fileManager.upload(drive, { name, path: dir }); - const base = fileManager.fileInfoList.at(-1)!; - - const versionedFi = await fileManager.getVersion(base, FEED_INDEX_ZERO.toString()); - const dl = await fileManager.download(versionedFi, ['a.txt']); - expect((dl as Bytes[])[0].toUtf8()).toBe('A'); - } finally { - fs.rmSync(dir, { recursive: true, force: true }); - } - }); - - it('returns the cached FileInfo for the current head without refetching', async () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const spyGetFeedData = jest.spyOn(require('@/utils/bee'), 'getFeedData'); - - const base = await ensureBase('cache-test'); - - const cached = fileManager.fileInfoList.find((f) => f.topic === base.topic)!; - expect(cached).toBeDefined(); - - spyGetFeedData.mockClear(); - - const headSlot = FeedIndex.fromBigInt(BigInt(base.version!)); - const result = await fileManager.getVersion(base, headSlot); - - expect(result).toBe(cached); - - expect(spyGetFeedData).not.toHaveBeenCalled(); - }); - - it('uploads multiple versions, counts them, fetches an old version and downloads it', async () => { - const tmpDir = path.join(__dirname, 'versioningTmp'); - try { - fs.mkdirSync(tmpDir, { recursive: true }); - const filePath = path.join(tmpDir, 'file.txt'); - const NAME = `versioned-file-${Date.now()}`; - - const content = 'Version 0 content'; - fs.writeFileSync(filePath, content); - await fileManager.upload(drive, { name: NAME, path: filePath }); - const v0Fi = fileManager.fileInfoList.at(-1)!; - const topic = v0Fi.topic.toString(); - const hist0 = v0Fi.file.historyRef; - - fs.writeFileSync(filePath, 'Version 1 content'); - await fileManager.upload( - drive, - { name: NAME, topic: topic, path: filePath }, - { - actHistoryAddress: new Reference(hist0), - }, - ); - - const countAfterV1 = await getFeedData(bee, new Topic(v0Fi.topic), signer.publicKey().address().toString()); - const latestFi = await fileManager.getVersion(v0Fi, countAfterV1.feedIndex); - fs.writeFileSync(filePath, 'Version 2 content'); - await fileManager.upload( - drive, - { name: NAME, topic: topic, path: filePath }, - { - actHistoryAddress: new Reference(latestFi.file.historyRef), - }, - ); - - const count = await getFeedData(bee, new Topic(v0Fi.topic), signer.publicKey().address().toString()); - expect(count.feedIndexNext.toBigInt()).toBeGreaterThanOrEqual(3n); - - const v0 = await fileManager.getVersion(v0Fi, FEED_INDEX_ZERO); - expect(v0.version).toBeDefined(); - expect(v0.version).toBe(FEED_INDEX_ZERO.toString()); - - const actPublisher = (await bee.getNodeAddresses()).publicKey.toCompressedHex(); - const dl0 = (await fileManager.download(v0, undefined, { - actHistoryAddress: v0.file.historyRef, - actPublisher, - })) as Bytes[]; - expect(dl0[0].toUtf8()).toBe(content); - } finally { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } - }); - - it('can restore a prior version and make it the new head', async () => { - const tmp = path.join(__dirname, 'restore.txt'); - try { - fs.writeFileSync(tmp, 'first'); - - const base = await ensureBase('restore-file'); - const initialVersion = BigInt(base.version!); - const firstRef = base.file.reference; - - fs.writeFileSync(tmp, 'second'); - await fileManager.upload( - drive, - { name: base.name, topic: base.topic.toString(), path: tmp }, - { - actHistoryAddress: new Reference(base.file.historyRef), - }, - ); - - await fileManager.restoreVersion(base); - - const { feedIndex: current } = await getFeedData( - bee, - new Topic(base.topic), - signer.publicKey().address().toString(), - ); - - expect(BigInt(current.toBigInt())).toBe(initialVersion + 2n); - - const restored = await fileManager.getVersion(base, current); - - expect(restored.file.reference).toBe(firstRef); - expect(BigInt(restored.version!)).toBe(initialVersion + 2n); - } finally { - fs.unlinkSync(tmp); - } - }); - - it('restoring the current head does nothing', async () => { - const tmp = path.join(__dirname, 'noop-restore.txt'); - try { - fs.writeFileSync(tmp, 'A'); - const base = await ensureBase('noop-restore'); - fs.writeFileSync(tmp, 'B'); - await fileManager.upload( - drive, - { name: base.name, topic: base.topic.toString(), path: tmp }, - { - actHistoryAddress: new Reference(base.file.historyRef), - }, - ); - - const currentHead = await fileManager.getVersion(base, base.version!); - - await fileManager.restoreVersion(currentHead); - - const reHead = await fileManager.getVersion(base, base.version!); - expect(reHead.version).toBe(currentHead.version); - expect(reHead.file.reference).toBe(currentHead.file.reference); - } finally { - fs.unlinkSync(tmp); - } - }); - - it('restoreVersion() on a single version file reaffirms the head', async () => { - const base = await ensureBase('noop-default'); - const headIdx = FeedIndex.fromBigInt(BigInt(base.version!)); - const before = await fileManager.getVersion(base, headIdx); - - await fileManager.restoreVersion(before); - - const after = await fileManager.getVersion(base, headIdx); - expect(after.version).toBe(before.version); - expect(after.file.reference).toBe(before.file.reference); - }); -}); - -describe('FileManager End-to-End User Workflow', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let tempBaseDir: string; - let actPublisher: PublicKey; - let drive: DriveInfo; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - tempBaseDir = path.join(__dirname, 'e2eTestSession'); - fileManager = await createInitializedFileManager(bee, ownerStamp); - fs.mkdirSync(tempBaseDir, { recursive: true }); - actPublisher = (await bee.getNodeAddresses()).publicKey; - - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'e2eStamp'); - await fileManager.createDrive(batchId, 'e2e', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'e2e'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - }); - - afterAll(() => { - fs.rmSync(tempBaseDir, { recursive: true, force: true }); - }); - - it('should simulate a complete workflow - in-place folder update simulation', async () => { - const singleFilePath = path.join(tempBaseDir, 'initial.txt'); - fs.writeFileSync(singleFilePath, 'Hello, this is the initial file.'); - await fileManager.upload(drive, { name: path.basename(singleFilePath), path: singleFilePath }); - let fileInfos = fileManager.fileInfoList.filter((fi) => fi.driveId === drive.id.toString()); - expect(fileInfos.find((fi) => fi.name === path.basename(singleFilePath))).toBeDefined(); - - fileInfos.forEach((fi) => { - expect(fi.driveId).toBe(drive.id.toString()); - expect(fi.batchId).toBe(drive.batchId.toString()); - expect(fi.redundancyLevel).toBe(drive.redundancyLevel); - }); - - const projectFolder = path.join(tempBaseDir, 'projectFolder'); - fs.mkdirSync(projectFolder, { recursive: true }); - fs.writeFileSync(path.join(projectFolder, 'doc1.txt'), 'Project document 1'); - fs.writeFileSync(path.join(projectFolder, 'doc2.txt'), 'Project document 2'); - const assetsFolder = path.join(projectFolder, 'assets'); - fs.mkdirSync(assetsFolder, { recursive: true }); - fs.writeFileSync(path.join(assetsFolder, 'image.png'), 'Fake image content'); - await fileManager.upload(drive, { name: path.basename(projectFolder), path: projectFolder }); - fileInfos = fileManager.fileInfoList.filter((fi) => fi.driveId === drive.id.toString()); - const projectInfo = fileInfos.find((fi) => fi.name === path.basename(projectFolder))!; - expect(projectInfo).toBeDefined(); - - fileInfos.forEach((fi) => { - expect(fi.driveId).toBe(drive.id.toString()); - expect(fi.batchId).toBe(drive.batchId.toString()); - expect(fi.redundancyLevel).toBe(drive.redundancyLevel); - }); - - fs.writeFileSync(path.join(projectFolder, 'readme.txt'), 'This is the project readme.'); - await new Promise((r) => setTimeout(r, 1000)); - await fileManager.upload(drive, { name: path.basename(projectFolder), path: projectFolder }); - - const listedFiles = await fileManager.listFiles(projectInfo, undefined, { - actHistoryAddress: new Reference(projectInfo.file.historyRef), - actPublisher, - }); - const basenames = Object.keys(listedFiles).map((filePath) => path.basename(filePath)); - // Since in-place updates aren’t supported, we expect the manifest to contain only the original files. - expect(basenames).toContain('doc1.txt'); - expect(basenames).toContain('doc2.txt'); - expect(basenames).toContain('image.png'); - expect(basenames).not.toContain('readme.txt'); - expect(Object.keys(listedFiles)).toHaveLength(3); - }); - - it('should simulate a complete workflow - new version folder upload', async () => { - const singleFilePath = path.join(tempBaseDir, 'initial.txt'); - fs.writeFileSync(singleFilePath, 'Hello, this is the initial file.'); - await fileManager.upload(drive, { name: path.basename(singleFilePath), path: singleFilePath }); - let fileInfos = fileManager.fileInfoList.filter((fi) => fi.driveId === drive.id.toString()); - expect(fileInfos.find((fi) => fi.name === path.basename(singleFilePath))).toBeDefined(); - - const projectFolder = path.join(tempBaseDir, 'projectFolder'); - fs.mkdirSync(projectFolder, { recursive: true }); - fs.writeFileSync(path.join(projectFolder, 'doc1.txt'), 'Project document 1'); - fs.writeFileSync(path.join(projectFolder, 'doc2.txt'), 'Project document 2'); - const assetsFolder = path.join(projectFolder, 'assets'); - fs.mkdirSync(assetsFolder, { recursive: true }); - fs.writeFileSync(path.join(assetsFolder, 'image.png'), 'Fake image content'); - await fileManager.upload(drive, { name: path.basename(projectFolder), path: projectFolder }); - fileInfos = fileManager.fileInfoList.filter((fi) => fi.driveId === drive.id.toString()); - const projectInfo = fileInfos.find((fi) => fi.name === path.basename(projectFolder)); - expect(projectInfo).toBeDefined(); - - fileInfos.forEach((fi) => { - expect(fi.driveId).toBe(drive.id.toString()); - expect(fi.batchId).toBe(drive.batchId.toString()); - expect(fi.redundancyLevel).toBe(drive.redundancyLevel); - }); - - const projectFolderNew = path.join(tempBaseDir, 'projectFolder_new'); - fs.mkdirSync(projectFolderNew, { recursive: true }); - fs.writeFileSync(path.join(projectFolderNew, 'doc1.txt'), 'Project document 1'); - fs.writeFileSync(path.join(projectFolderNew, 'doc2.txt'), 'Project document 2'); - const assetsFolderNew = path.join(projectFolderNew, 'assets'); - fs.mkdirSync(assetsFolderNew, { recursive: true }); - fs.writeFileSync(path.join(assetsFolderNew, 'image.png'), 'Fake image content'); - fs.writeFileSync(path.join(projectFolderNew, 'readme.txt'), 'This is the project readme.'); - - const nestedFolder = path.join(projectFolderNew, 'nested'); - fs.mkdirSync(nestedFolder, { recursive: true }); - fs.writeFileSync(path.join(nestedFolder, 'subdoc.txt'), 'Nested document content'); - await new Promise((resolve) => setTimeout(resolve, 1000)); - await fileManager.upload(drive, { name: path.basename(projectFolderNew), path: projectFolderNew }); - fileInfos = fileManager.fileInfoList.filter((fi) => fi.driveId === drive.id.toString()); - const newVersionInfo = fileInfos.find((fi) => fi.name === path.basename(projectFolderNew)); - expect(newVersionInfo).toBeDefined(); - - fileInfos.forEach((fi) => { - expect(fi.driveId).toBe(drive.id.toString()); - expect(fi.batchId).toBe(drive.batchId.toString()); - expect(fi.redundancyLevel).toBe(drive.redundancyLevel); - }); - - const listedFiles_newVersion = await fileManager.listFiles(newVersionInfo!, undefined, { - actHistoryAddress: new Reference(newVersionInfo!.file.historyRef), - actPublisher, - }); - const basenames_newVersion = Object.keys(listedFiles_newVersion).map((filePath) => path.basename(filePath)); - const fullPaths_newVersion = Object.keys(listedFiles_newVersion); - expect(basenames_newVersion).toContain('doc1.txt'); - expect(basenames_newVersion).toContain('doc2.txt'); - expect(basenames_newVersion).toContain('image.png'); - expect(basenames_newVersion).toContain('readme.txt'); - expect(basenames_newVersion).toContain('subdoc.txt'); - expect(fullPaths_newVersion).toContain('nested/subdoc.txt'); - expect(Object.keys(listedFiles_newVersion)).toHaveLength(5); - - const downloadedContents = (await fileManager.download(newVersionInfo!, undefined, { - actHistoryAddress: new Reference(newVersionInfo!.file.historyRef), - actPublisher, - })) as Bytes[]; - expect(downloadedContents[1].toUtf8()).toContain('Project document 1'); - expect(downloadedContents[2].toUtf8()).toContain('Project document 2'); - expect(downloadedContents[0].toUtf8()).toContain('Fake image content'); - expect(downloadedContents[4].toUtf8()).toContain('This is the project readme.'); - expect(downloadedContents[3].toUtf8()).toContain('Nested document content'); - }); - - it('should list files with correct relative paths reflecting folder structure', async () => { - const complexFolder = path.join(tempBaseDir, 'complexFolder'); - fs.mkdirSync(complexFolder, { recursive: true }); - fs.writeFileSync(path.join(complexFolder, 'root.txt'), 'Root file content'); - const level1 = path.join(complexFolder, 'level1'); - fs.mkdirSync(level1, { recursive: true }); - fs.writeFileSync(path.join(level1, 'level1.txt'), 'Level1 file content'); - const level2 = path.join(level1, 'level2'); - fs.mkdirSync(level2, { recursive: true }); - fs.writeFileSync(path.join(level2, 'level2.txt'), 'Level2 file content'); - - await fileManager.upload(drive, { name: path.basename(complexFolder), path: complexFolder }); - const fileInfos = fileManager.fileInfoList; - const complexInfo = fileInfos.find((fi) => fi.name === path.basename(complexFolder)); - expect(complexInfo).toBeDefined(); - - const listedFiles = await fileManager.listFiles(complexInfo!, undefined, { - actHistoryAddress: new Reference(complexInfo!.file.historyRef), - actPublisher, - }); - const fullPaths = Object.keys(listedFiles); - expect(fullPaths).toContain('root.txt'); - expect(fullPaths).toContain('level1/level1.txt'); - expect(fullPaths).toContain('level1/level2/level2.txt'); - }); -}); - -describe('FileManager AbortController', () => { - let bee: Bee; - let fileManager: FileManagerBase; - let batchId: BatchId; - let tempDir: string; - let drive: DriveInfo; - let largeFilePath: string; - - beforeAll(async () => { - const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); - bee = beeDev; - - fileManager = await createInitializedFileManager(bee, ownerStamp); - - // Create a test drive - batchId = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'abortControllerStamp'); - await fileManager.createDrive(batchId, 'abort-test', false); - const tmpDrive = fileManager.driveList.find((d) => d.name === 'abort-test'); - expect(tmpDrive).toBeDefined(); - drive = tmpDrive!; - - // Create temp directory with test files - tempDir = path.join(__dirname, 'tmpAbortControllerTest'); - fs.mkdirSync(tempDir, { recursive: true }); - - // Create a larger file for abort testing (1MB to ensure upload takes time) - largeFilePath = path.join(tempDir, 'large-file.bin'); - const largeData = Buffer.alloc(1 * 1024 * 1024, 'x'); // 1MB - fs.writeFileSync(largeFilePath, largeData); - }); - - afterAll(() => { - fs.rmSync(tempDir, { recursive: true, force: true }); - }); - - describe('upload', () => { - it('should throw error with Request aborted message when upload is aborted with pre-aborted signal', async () => { - const controller = new AbortController(); - controller.abort(); // Pre-abort - - // upload wraps the underlying abort error in a FileError; the abort reason is on `cause` - const error: any = await fileManager - .upload(drive, { name: 'test-abort-file.txt', path: path.join(tempDir, 'large-file.bin') }, undefined, { - signal: controller.signal, - }) - .catch((e) => e); - expect(error).toBeInstanceOf(FileError); - expect(String(error.cause)).toContain('Request aborted'); - }); - - it('should throw BeeResponseError when upload is cancelled mid-flight', async () => { - const controller = new AbortController(); - - // Start upload and abort after a short delay - const uploadPromise = fileManager.upload(drive, { name: 'test-mid-abort.bin', path: largeFilePath }, undefined, { - signal: controller.signal, - }); - - setTimeout(() => { - controller.abort(); - }, 50); - - await expect(uploadPromise).rejects.toThrow(); - - // Verify the error is related to abort (the underlying reason is wrapped as `cause`) - try { - await uploadPromise; - } catch (error: any) { - const cause = error.cause; - expect(cause?.statusText === 'ERR_CANCELED' || String(cause).toLowerCase().includes('abort')).toBe(true); - } - }); - - it('should complete upload successfully when signal is not aborted', async () => { - const controller = new AbortController(); - const testContent = 'This file should upload successfully'; - const testFilePath = path.join(tempDir, 'success-file.txt'); - fs.writeFileSync(testFilePath, testContent); - - // Upload with signal that is NOT aborted - await fileManager.upload(drive, { name: 'success-file.txt', path: testFilePath }, undefined, { - signal: controller.signal, - }); - - // Verify file was uploaded - const uploadedFile = fileManager.fileInfoList.find((fi) => fi.name === 'success-file.txt'); - expect(uploadedFile).toBeDefined(); - expect(uploadedFile?.driveId).toBe(drive.id.toString()); - }); - - it('should handle multiple uploads with different abort controllers', async () => { - const controller1 = new AbortController(); - const controller2 = new AbortController(); - controller1.abort(); // Pre-abort first one - - const file1Path = path.join(tempDir, 'file1.txt'); - const file2Path = path.join(tempDir, 'file2.txt'); - fs.writeFileSync(file1Path, 'Content 1'); - fs.writeFileSync(file2Path, 'Content 2'); - - // First upload should fail (aborted) — abort reason is wrapped on the FileError's `cause` - const error: any = await fileManager - .upload(drive, { name: 'file1-abort.txt', path: file1Path }, undefined, { - signal: controller1.signal, - }) - .catch((e) => e); - expect(error).toBeInstanceOf(FileError); - expect(String(error.cause)).toContain('Request aborted'); - - // Second upload should succeed (not aborted) - await fileManager.upload(drive, { name: 'file2-success.txt', path: file2Path }, undefined, { - signal: controller2.signal, - }); - - const uploadedFile = fileManager.fileInfoList.find((fi) => fi.name === 'file2-success.txt'); - expect(uploadedFile).toBeDefined(); - }); - }); - - describe('download', () => { - let uploadedFileInfo: FileInfo; - let actPublisher: PublicKey; - - beforeAll(async () => { - // Upload the large file to download later (1MB file for reliable abort timing) - await fileManager.upload(drive, { name: 'large-download-test.bin', path: largeFilePath }); - const fileInfo = fileManager.fileInfoList.find((fi) => fi.name === 'large-download-test.bin'); - expect(fileInfo).toBeDefined(); - uploadedFileInfo = fileInfo!; - - actPublisher = (await bee.getNodeAddresses()).publicKey; - }); - - it('should throw error when download is aborted with pre-aborted signal', async () => { - const controller = new AbortController(); - controller.abort(); // Pre-abort - - await expect( - fileManager.download( - uploadedFileInfo, - undefined, - { - actHistoryAddress: uploadedFileInfo.file.historyRef, - actPublisher, - }, - { signal: controller.signal }, - ), - ).rejects.toThrow(); - }); - - it('should throw error when download is cancelled mid-flight', async () => { - const controller = new AbortController(); - - // Start download and abort after a short delay - const downloadPromise = fileManager.download( - uploadedFileInfo, - undefined, - { - actHistoryAddress: uploadedFileInfo.file.historyRef, - actPublisher, - }, - { signal: controller.signal }, - ); - - setTimeout(() => { - controller.abort(); - }, 1); - - await expect(downloadPromise).rejects.toThrow(); - }); - - it('should complete download successfully when signal is not aborted', async () => { - const controller = new AbortController(); - - const result = await fileManager.download( - uploadedFileInfo, - undefined, - { - actHistoryAddress: uploadedFileInfo.file.historyRef, - actPublisher, - }, - { signal: controller.signal }, - ); - - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - - it('should handle multiple downloads with different abort controllers', async () => { - const controller1 = new AbortController(); - const controller2 = new AbortController(); - controller1.abort(); // Pre-abort first one - - // First download should fail (aborted) - await expect( - fileManager.download( - uploadedFileInfo, - undefined, - { - actHistoryAddress: uploadedFileInfo.file.historyRef, - actPublisher, - }, - { signal: controller1.signal }, - ), - ).rejects.toThrow(); - - // Second download should succeed (not aborted) - const result = await fileManager.download( - uploadedFileInfo, - undefined, - { - actHistoryAddress: uploadedFileInfo.file.historyRef, - actPublisher, - }, - { signal: controller2.signal }, - ); - - expect(result).toBeDefined(); - expect(Array.isArray(result)).toBe(true); - }); - }); - - describe('listFiles', () => { - let uploadedFolderInfo: FileInfo; - let actPublisher: PublicKey; - - beforeAll(async () => { - // Upload a folder to list later - const folderPath = path.join(tempDir, 'list-test-folder'); - fs.mkdirSync(folderPath, { recursive: true }); - fs.writeFileSync(path.join(folderPath, 'file1.txt'), 'File 1'); - fs.writeFileSync(path.join(folderPath, 'file2.txt'), 'File 2'); - fs.writeFileSync(path.join(folderPath, 'file3.txt'), 'File 3'); - - await fileManager.upload(drive, { name: 'list-test-folder', path: folderPath }); - const fileInfo = fileManager.fileInfoList.find((fi) => fi.name === 'list-test-folder'); - expect(fileInfo).toBeDefined(); - uploadedFolderInfo = fileInfo!; - - actPublisher = (await bee.getNodeAddresses()).publicKey; - }); - - it('should throw error when listFiles is aborted with pre-aborted signal', async () => { - const controller = new AbortController(); - controller.abort(); // Pre-abort - - await expect( - fileManager.listFiles( - uploadedFolderInfo, - undefined, - { - actHistoryAddress: uploadedFolderInfo.file.historyRef, - actPublisher, - }, - { signal: controller.signal }, - ), - ).rejects.toThrow(); - }); - - it('should throw error when listFiles is cancelled mid-flight', async () => { - const controller = new AbortController(); - - // Start listFiles and abort after a short delay - const listPromise = fileManager.listFiles( - uploadedFolderInfo, - undefined, - { - actHistoryAddress: uploadedFolderInfo.file.historyRef, - actPublisher, - }, - { signal: controller.signal }, - ); - - setTimeout(() => { - controller.abort(); - }, 1); - - await expect(listPromise).rejects.toThrow(); - }); - - it('should complete listFiles successfully when signal is not aborted', async () => { - const controller = new AbortController(); - - const result = await fileManager.listFiles( - uploadedFolderInfo, - undefined, - { - actHistoryAddress: uploadedFolderInfo.file.historyRef, - actPublisher, - }, - { signal: controller.signal }, - ); - - expect(result).toBeDefined(); - expect(typeof result).toBe('object'); - expect(Object.keys(result).length).toBeGreaterThan(0); - }); - - it('should handle multiple listFiles calls with different abort controllers', async () => { - const controller1 = new AbortController(); - const controller2 = new AbortController(); - controller1.abort(); // Pre-abort first one - - // First listFiles should fail (aborted) - await expect( - fileManager.listFiles( - uploadedFolderInfo, - undefined, - { - actHistoryAddress: uploadedFolderInfo.file.historyRef, - actPublisher, - }, - { signal: controller1.signal }, - ), - ).rejects.toThrow(); - - // Second listFiles should succeed (not aborted) - const result = await fileManager.listFiles( - uploadedFolderInfo, - undefined, - { - actHistoryAddress: uploadedFolderInfo.file.historyRef, - actPublisher, - }, - { signal: controller2.signal }, - ); - - expect(result).toBeDefined(); - expect(typeof result).toBe('object'); - expect(Object.keys(result).length).toBeGreaterThan(0); - }); - }); -}); diff --git a/tests/integration/folder.spec.ts b/tests/integration/folder.spec.ts new file mode 100644 index 0000000..765e68b --- /dev/null +++ b/tests/integration/folder.spec.ts @@ -0,0 +1,181 @@ +import { BatchId, Identifier } from '@ethersphere/bee-js'; + +import { + buyStampSerialized, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + retryOnPropagationDelay, + streamToUint8Array, +} from '../utils'; + +import { ensureUniqueSignerWithStamp, setupUserDrive, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, ListDepth, NodeType } from '@/types'; +import { ROOT_PATH } from '@/utils/constants'; + +describe('Folder operations', () => { + let fileManager: FileManagerBase; + let drive: DriveInfo; + const { writeTempFile, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ fileManager, drive } = await setupUserDrive('folders', { stampLabel: 'folders' })); + }); + + afterAll(cleanup); + + describe('listFolder', () => { + it('returns entries for every file uploaded into a folder', async () => { + const fileA = writeTempFile('it-listfolder-a.txt', 'A content'); + const fileB = writeTempFile('it-listfolder-b.txt', 'B content'); + + const result = await fileManager.uploadFiles( + new Identifier(drive.id), + [ + { path: 'gallery/a.txt', sourcePath: fileA }, + { path: 'gallery/b.txt', sourcePath: fileB }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'gallery', ListDepth.Shallow), + ); + const fileEntries = entries.filter((e) => e.type === NodeType.File); + expect(fileEntries.map((e) => e.path).sort()).toEqual(['gallery/a.txt', 'gallery/b.txt']); + }); + + it('returns an empty array for an empty folder', async () => { + await fileManager.createFolder(drive.id, ROOT_PATH, 'empty-folder'); + + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'empty-folder', ListDepth.Shallow), + ); + expect(entries).toEqual([]); + }); + + it('correctly composes nested paths in a deep listing', async () => { + const fileA = writeTempFile('it-listfolder-deep-a.txt', 'Deep A content'); + const fileB = writeTempFile('it-listfolder-deep-b.txt', 'Deep B content'); + + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'level1/level2/a.txt', sourcePath: fileA }, + { path: 'level1/level2/level3/b.txt', sourcePath: fileB }, + ], + '', + ); + expect(result.failed).toHaveLength(0); + + const entries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, 'level1', ListDepth.Deep)); + const fileEntries = entries.filter((e) => e.type === NodeType.File); + expect(fileEntries.map((e) => e.path).sort()).toEqual(['level1/level2/a.txt', 'level1/level2/level3/b.txt']); + }); + + it('rejects an entry with an empty path and leaves the folder listing unaffected', async () => { + const fileGood = writeTempFile('it-listfolder-guard-good.txt', 'Good content'); + const fileBad = writeTempFile('it-listfolder-guard-bad.txt', 'Should not upload'); + + const seed = await fileManager.uploadFiles(drive.id, [{ path: 'guarded/good.txt', sourcePath: fileGood }], ''); + expect(seed.failed).toHaveLength(0); + + await expect(fileManager.uploadFiles(drive.id, [{ path: '', sourcePath: fileBad }], 'guarded')).rejects.toThrow( + /Invalid path/, + ); + + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'guarded', ListDepth.Shallow), + ); + const fileEntries = entries.filter((e) => e.type === NodeType.File); + expect(fileEntries.map((e) => e.path)).toEqual(['guarded/good.txt']); + }); + }); + + describe('downloadFolder', () => { + it('composes destinationPath with a relative item path — placement differs from destination and source', async () => { + const srcFile = writeTempFile('it-downloadFolder-dest-src.txt', 'destination compose content'); + + await fileManager.createFolder(drive.id, '', 'inbox'); + + // destinationPath ('inbox') + relative item path ('reports/q1.txt') → placed at + // 'inbox/reports/q1.txt', which equals neither the destination nor the on-disk source name. + const result = await fileManager.uploadFiles( + drive.id, + [{ path: 'reports/q1.txt', sourcePath: srcFile }], + 'inbox', + ); + expect(result.failed).toHaveLength(0); + + const entries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, 'inbox', ListDepth.Deep)); + const filePaths = entries.filter((e) => e.type === NodeType.File).map((e) => e.path); + expect(filePaths).toContain('inbox/reports/q1.txt'); + expect(filePaths).not.toContain('reports/q1.txt'); + expect(filePaths).not.toContain('it-downloadFolder-dest-src.txt'); + + const downloads = await retryOnPropagationDelay(() => fileManager.downloadFolder(drive.id, '/')); + const got = downloads.succeeded.find((d) => d.path === 'inbox/reports/q1.txt'); + expect(downloads.failed).toEqual([]); + expect(got).toBeDefined(); + expect(Buffer.from(await streamToUint8Array(got!.result)).toString('utf-8')).toBe('destination compose content'); + }); + }); + + describe('move', () => { + let moveBatchId: BatchId; + + beforeAll(async () => { + const { bee: beeDev } = await ensureUniqueSignerWithStamp(); + + moveBatchId = await buyStampSerialized(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'movestamp'); + }); + + it('moves a folder as a unit, composing correct descendant paths at read time', async () => { + await fileManager.createDrive(moveBatchId, 'move-folder-a'); + const tmpDriveA = fileManager.driveList.find((d) => d.name === 'move-folder-a'); + expect(tmpDriveA).toBeDefined(); + const driveA = tmpDriveA!; + + const innerFile = writeTempFile('it-move-src-inner.txt', 'Inner File Content'); + const uploadResult = await fileManager.uploadFiles( + driveA.id, + [{ path: 'src/inner.txt', sourcePath: innerFile }], + '', + ); + expect(uploadResult.failed).toHaveLength(0); + const originalTopic = uploadResult.succeeded[0].topic.toString(); + + await fileManager.createFolder(driveA.id, ROOT_PATH, 'backup'); + + await fileManager.move('src', 'backup/src', driveA.id); + + const rootEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); + expect(rootEntries.some((e) => e.type === NodeType.Folder && e.path.replace(/^\//, '') === 'src')).toBe(false); + + const backupEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(driveA.id, 'backup', ListDepth.Shallow), + ); + expect(backupEntries.some((e) => e.type === NodeType.Folder && e.path === 'backup/src')).toBe(true); + + const srcEntries = await retryOnPropagationDelay(() => + fileManager.listFolder(driveA.id, 'backup/src', ListDepth.Shallow), + ); + const innerEntry = srcEntries.find((e) => e.type === NodeType.File); + expect(innerEntry).toBeDefined(); + expect(innerEntry!.topic).toBe(originalTopic); + expect(innerEntry!.path).toBe('backup/src/inner.txt'); + + const movedFi = fileManager.recordList.find((fr) => fr.topic.toString() === originalTopic)!; + expect(movedFi).toBeDefined(); + expect(movedFi.path).toBe('backup/src/inner.txt'); + + const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveA.id, 'backup/src')); + expect(downloadResults.failed).toEqual([]); + const downloaded = downloadResults.succeeded.find((d) => d.path === 'backup/src/inner.txt'); + expect(downloaded).toBeDefined(); + expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Inner File Content'); + }); + }); +}); diff --git a/tests/integration/init.spec.ts b/tests/integration/init.spec.ts new file mode 100644 index 0000000..998341a --- /dev/null +++ b/tests/integration/init.spec.ts @@ -0,0 +1,342 @@ +import { BatchId, Bee, BeeResponseError, PrivateKey, PublicKey, RedundancyLevel, Reference } from '@ethersphere/bee-js'; + +import { + buyStampSerialized, + createInitializedFileManager, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + OTHER_BEE_URL, + OTHER_MOCK_SIGNER, + retryOnPropagationDelay, +} from '../utils'; + +import { ensureUniqueSignerWithStamp } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { ActReferences } from '@/types'; +import { ADMIN_STAMP_LABEL, FILEMANAGER_STATE_TOPIC, FileManagerEvents, StampError } from '@/utils'; +import { assertActReferences } from '@/utils/asserts'; +import { getFeedData } from '@/utils/bee'; +import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { generateRandomBytes } from '@/utils/crypto'; + +describe('Initialization and construction', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let actPublisher: PublicKey; + let adminBatchId: BatchId; + let signer: PrivateKey; + + beforeAll(async () => { + const { bee: beeDev, ownerStamp, signer: newSigner } = await ensureUniqueSignerWithStamp(); + bee = beeDev; + adminBatchId = ownerStamp; + signer = newSigner; + fileManager = await createInitializedFileManager(bee, adminBatchId); + actPublisher = (await bee.getNodeAddresses()).publicKey; + }); + + beforeEach(async () => { + jest.resetAllMocks(); + }); + + it('should create and initialize a new instance and check if admin stamp is not found', async () => { + expect(fileManager.recordList).toEqual([]); + + const unpurchasedBatchId = new BatchId(generateRandomBytes(BatchId.LENGTH)); + const otherBee = new Bee(OTHER_BEE_URL, { signer: OTHER_MOCK_SIGNER }); + const fm2 = new FileManagerBase(otherBee); + try { + fm2.emitter.on(FileManagerEvents.INITIALIZED, (e) => { + expect(e).toBeTruthy(); + }); + await fm2.initialize(); + await fm2.createAdminDrive(unpurchasedBatchId, RedundancyLevel.OFF); + } catch (error: any) { + expect(error).toBeInstanceOf(StampError); + expect(error.message).toContain( + `Stamp with batchId: ${unpurchasedBatchId.toString().slice(0, 6)}... not found OR not usable`, + ); + } + + expect(fm2.recordList).toEqual([]); + }); + + it('should initialize the admin feed and topic', async () => { + expect(fileManager.recordList).toEqual([]); + + const { payload } = await retryOnPropagationDelay(() => + getFeedData(bee, FILEMANAGER_STATE_TOPIC, signer.publicKey().address(), 0n), + ); + const feedTopicState = payload.toJSON() as ActReferences; + assertActReferences(feedTopicState); + const topicHex = await bee.downloadData(new Reference(feedTopicState.reference), { + actHistoryAddress: new Reference(feedTopicState.historyRef), + actPublisher, + }); + expect(topicHex).not.toEqual(SWARM_ZERO_ADDRESS); + + await fileManager.initialize(); + const reinitTopicHex = await bee.downloadData(new Reference(feedTopicState.reference), { + actHistoryAddress: new Reference(feedTopicState.historyRef), + actPublisher, + }); + expect(topicHex).toEqual(reinitTopicHex); + }); + + it('should throw an error if someone else than the admin tries to read the admin feed', async () => { + const otherBee = new Bee(OTHER_BEE_URL, { signer: OTHER_MOCK_SIGNER }); + + const { payload } = await retryOnPropagationDelay(() => + getFeedData(bee, FILEMANAGER_STATE_TOPIC, signer.publicKey().address(), 0n), + ); + const feedTopicState = payload.toJSON() as ActReferences; + + try { + await bee.downloadData(new Reference(feedTopicState.reference), { + actHistoryAddress: new Reference(feedTopicState.historyRef), + actPublisher: OTHER_MOCK_SIGNER.publicKey(), + }); + } catch (error) { + expect(error).toBeInstanceOf(BeeResponseError); + expect((error as BeeResponseError).status).toBe(404); + } + + try { + await retryOnPropagationDelay(() => + otherBee.downloadData(new Reference(feedTopicState.reference), { + actHistoryAddress: new Reference(feedTopicState.historyRef), + actPublisher, + }), + ); + } catch (error) { + expect(error).toBeInstanceOf(BeeResponseError); + expect((error as BeeResponseError).status).toBe(404); + } + }); + + it('should not reinitialize if already initialized', async () => { + const recordListBefore = [...fileManager.recordList]; + fileManager.emitter.on(FileManagerEvents.INITIALIZED, (e) => { + expect(e).toEqual(true); + }); + await fileManager.initialize(); + expect(fileManager.recordList).toEqual(recordListBefore); + }); + + it('should maintain isInitialized flag after successful reinitialization', async () => { + expect((fileManager as any).isInitialized).toBe(true); + await fileManager.initialize(); + expect((fileManager as any).isInitialized).toBe(true); + }); + + it('should not clear drives when reinitializing with valid stamp', async () => { + const drivesBefore = fileManager.driveList; + expect(drivesBefore.length).toBeGreaterThan(0); + + await fileManager.initialize(); + + const drivesAfter = fileManager.driveList; + expect(drivesAfter).toEqual(drivesBefore); + }); + + it('should maintain admin stamp reference after reinitialization', async () => { + const adminStampBefore = fileManager.adminStamp; + expect(adminStampBefore).toBeDefined(); + + await fileManager.initialize(); + + const adminStampAfter = fileManager.adminStamp; + expect(adminStampAfter).toBeDefined(); + expect(adminStampAfter?.batchID.toString()).toBe(adminStampBefore?.batchID.toString()); + }); +}); + +describe('reinitialization', () => { + it('should emit STATE_INVALID after expiry', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + await createInitializedFileManager(beeDev, ownerStamp); + + const originalFn = beeDev.getPostageBatches.bind(beeDev); + const spy = jest.spyOn(beeDev, 'getPostageBatches'); + + spy.mockImplementation(async () => { + await originalFn(); + return []; + }); + + const newFileManager = new FileManagerBase(beeDev); + + newFileManager.emitter.on(FileManagerEvents.STATE_INVALID, (stateInvalidEmitted) => { + expect(stateInvalidEmitted).toBe(true); + }); + + newFileManager.emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { + expect(success).toBe(true); + }); + + await newFileManager.initialize(); + + expect(newFileManager.driveList).toHaveLength(0); + expect(newFileManager.recordList).toHaveLength(0); + + spy.mockRestore(); + }); + + it('should successfully revalidate when admin stamp is still valid', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + const fileManager = await createInitializedFileManager(beeDev, ownerStamp); + + const initialDrives = fileManager.driveList; + const initialFileCount = fileManager.recordList.length; + + expect(initialDrives.length).toBeGreaterThanOrEqual(1); + + let initEventFired = false; + let invalidEventFired = false; + + fileManager.emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { + initEventFired = true; + expect(success).toBe(true); + }); + + fileManager.emitter.on(FileManagerEvents.STATE_INVALID, () => { + invalidEventFired = true; + }); + + await fileManager.initialize(); + + expect(initEventFired).toBe(true); + expect(invalidEventFired).toBe(false); + expect(fileManager.driveList).toEqual(initialDrives); + expect(fileManager.recordList).toHaveLength(initialFileCount); + }); + + it('should preserve user data when creating a new instance with valid stamp', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + const fileManager = await createInitializedFileManager(beeDev, ownerStamp); + + const userBatchId = await buyStampSerialized(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'userDrive'); + await fileManager.createDrive(userBatchId, 'User Drive'); + + const drivesBeforeReinit = fileManager.driveList; + const userDrive = drivesBeforeReinit.find((d) => d.name === 'User Drive'); + expect(userDrive).toBeDefined(); + + const newFileManager = new FileManagerBase(beeDev); + await newFileManager.initialize(); + + const drivesAfterReinit = newFileManager.driveList; + expect(drivesAfterReinit).toHaveLength(drivesBeforeReinit.length); + const userDriveAfter = drivesAfterReinit.find((d) => d.name === 'User Drive'); + expect(userDriveAfter).toBeDefined(); + expect(userDriveAfter?.id).toBe(userDrive?.id); + }); + + it('should handle multiple sequential reinitializations with valid stamp', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + const fileManager = await createInitializedFileManager(beeDev, ownerStamp); + + const initialDriveCount = fileManager.driveList.length; + + for (let i = 0; i < 3; i++) { + await fileManager.initialize(); + expect(fileManager.driveList).toHaveLength(initialDriveCount); + } + + for (let i = 0; i < 2; i++) { + await retryOnPropagationDelay( + async () => { + const freshManager = new FileManagerBase(beeDev); + await freshManager.initialize(); + expect(freshManager.driveList).toHaveLength(initialDriveCount); + }, + 10, + 1000, + ); + } + }); + + it('should allow operations after successful revalidation', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + const fileManager = await createInitializedFileManager(beeDev, ownerStamp); + + await fileManager.initialize(); + + const newBatchId = await buyStampSerialized(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'afterReinit'); + await fileManager.createDrive(newBatchId, 'Post Reinit Drive'); + + const drives = fileManager.driveList; + const newDrive = drives.find((d) => d.name === 'Post Reinit Drive'); + expect(newDrive).toBeDefined(); + }); + + it('should emit correct events during revalidation failure', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + const originalFn = beeDev.getPostageBatches.bind(beeDev); + const spy = jest.spyOn(beeDev, 'getPostageBatches'); + + spy.mockImplementation(async () => { + const batches = await originalFn(); + return batches.map((b) => ({ + ...b, + usable: true, + label: b.label === ADMIN_STAMP_LABEL ? 'admin' : b.label, + })); + }); + + await createInitializedFileManager(beeDev, ownerStamp); + + spy.mockImplementation(async () => { + await originalFn(); + return []; + }); + + await retryOnPropagationDelay( + async () => { + const events: string[] = []; + + const newFileManager = new FileManagerBase(beeDev); + newFileManager.emitter.on(FileManagerEvents.STATE_INVALID, () => { + events.push('STATE_INVALID'); + }); + newFileManager.emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { + events.push(`INITIALIZED:${success}`); + }); + + await newFileManager.initialize(); + + expect(events).toContain('STATE_INVALID'); + expect(events).toContain('INITIALIZED:true'); + }, + 10, + 1000, + ); + + spy.mockRestore(); + }); + + it('should not affect other drives when revalidating admin stamp', async () => { + const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); + const fileManager = await createInitializedFileManager(beeDev, ownerStamp); + + const batch1 = await buyStampSerialized(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'drive1'); + const batch2 = await buyStampSerialized(beeDev, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'drive2'); + + await fileManager.createDrive(batch1, 'Drive 1'); + await fileManager.createDrive(batch2, 'Drive 2'); + + const drivesBeforeReinit = fileManager.driveList; + const drive1 = drivesBeforeReinit.find((d) => d.name === 'Drive 1'); + const drive2 = drivesBeforeReinit.find((d) => d.name === 'Drive 2'); + + expect(drive1).toBeDefined(); + expect(drive2).toBeDefined(); + + await fileManager.initialize(); + + const drivesAfterReinit = fileManager.driveList; + expect(drivesAfterReinit.find((d) => d.id === drive1?.id)).toBeDefined(); + expect(drivesAfterReinit.find((d) => d.id === drive2?.id)).toBeDefined(); + }); +}); diff --git a/tests/integration/setup/jestSetup.ts b/tests/integration/setup/jestSetup.ts new file mode 100644 index 0000000..86063b7 --- /dev/null +++ b/tests/integration/setup/jestSetup.ts @@ -0,0 +1,15 @@ +import { execFileSync } from 'child_process'; + +const BEE_FACTORY_TAG = process.env.BEE_FACTORY_TAG ?? 'v2.8.0'; + +export default async function globalSetup(): Promise { + console.debug(`Starting bee-factory stack (tag: ${BEE_FACTORY_TAG})...`); + + try { + execFileSync('npx', ['bee-factory', 'start', '--tag', BEE_FACTORY_TAG], { stdio: 'inherit' }); + console.debug('bee-factory stack started successfully'); + } catch (error) { + console.error('Error starting bee-factory stack:', error); + process.exit(1); + } +} diff --git a/tests/integration/setup/jestTeardown.ts b/tests/integration/setup/jestTeardown.ts new file mode 100644 index 0000000..fcd5c43 --- /dev/null +++ b/tests/integration/setup/jestTeardown.ts @@ -0,0 +1,13 @@ +import { execFileSync } from 'child_process'; + +export default async function globalTeardown(): Promise { + console.debug('Stopping bee-factory stack...'); + + try { + execFileSync('npx', ['bee-factory', 'stop'], { stdio: 'inherit' }); + console.debug('bee-factory stack stopped successfully'); + } catch (error) { + console.error('Error stopping bee-factory stack:', error); + process.exit(1); + } +} diff --git a/tests/integration/setup/utils.ts b/tests/integration/setup/utils.ts new file mode 100644 index 0000000..653d8fa --- /dev/null +++ b/tests/integration/setup/utils.ts @@ -0,0 +1,111 @@ +import { BatchId, Bee, PrivateKey } from '@ethersphere/bee-js'; +import * as fs from 'fs'; +import path from 'path'; + +import { + BEE_URL, + buyStampSerialized, + createInitializedFileManager, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + DEFAULT_MOCK_SIGNER, +} from '../../utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo } from '@/types'; +import { ADMIN_STAMP_LABEL } from '@/utils/constants'; +import { generateRandomBytes } from '@/utils/crypto'; + +interface BeeWithStampAndSigner { + bee: Bee; + ownerStamp: BatchId; + signer: PrivateKey; +} + +let globalAdminStamp: BatchId | null = null; + +export async function ensureUniqueSignerWithStamp(isNewSigner: boolean = true): Promise { + const signerBytes = generateRandomBytes(PrivateKey.LENGTH); + const signer = isNewSigner ? new PrivateKey(signerBytes) : DEFAULT_MOCK_SIGNER; + + const bee = new Bee(BEE_URL, { signer }); + + if (!globalAdminStamp) { + try { + globalAdminStamp = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, ADMIN_STAMP_LABEL); + } catch (error: any) { + console.error('Failed to create/find owner stamp:', error); + throw error; + } + } + + return { bee, ownerStamp: globalAdminStamp, signer }; +} + +export function resetGlobalStampState(): void { + globalAdminStamp = null; +} + +export interface UserDriveFixture { + bee: Bee; + fileManager: FileManagerBase; + drive: DriveInfo; + ownerStamp: BatchId; + batchId: BatchId; + signer: PrivateKey; +} + +export async function setupUserDrive( + driveName: string, + opts: { stampLabel?: string; reuseOwnerStamp?: boolean } = { reuseOwnerStamp: true }, +): Promise { + const { stampLabel = driveName, reuseOwnerStamp } = opts; + + const { bee, ownerStamp, signer } = await ensureUniqueSignerWithStamp(); + const fileManager = await createInitializedFileManager(bee, ownerStamp); + + const batchId = reuseOwnerStamp + ? ownerStamp + : await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, stampLabel); + + await fileManager.createDrive(batchId, driveName); + const drive = fileManager.driveList.find((d) => d.name === driveName); + expect(drive).toBeDefined(); + + return { bee, fileManager, drive: drive!, ownerStamp, batchId, signer }; +} + +export interface TempFileRegistry { + writeTempFile: (name: string, content: string | Uint8Array) => string; + writeTempDir: (dir: string, files: Record) => string; + cleanup: () => void; +} + +export function tempFileRegistry(): TempFileRegistry { + const paths: string[] = []; + const track = (p: string): string => { + paths.push(p); + return p; + }; + + return { + writeTempFile(name, content) { + fs.writeFileSync(name, content); + return track(name); + }, + writeTempDir(dir, files) { + fs.mkdirSync(dir, { recursive: true }); + for (const [relativePath, content] of Object.entries(files)) { + const full = path.join(dir, relativePath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); + } + return track(dir); + }, + cleanup() { + for (const p of paths) { + fs.rmSync(p, { recursive: true, force: true }); + } + }, + }; +} diff --git a/tests/integration/test-node-setup/jestSetup.ts b/tests/integration/test-node-setup/jestSetup.ts deleted file mode 100644 index 1c58b3d..0000000 --- a/tests/integration/test-node-setup/jestSetup.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { execSync } from 'child_process'; -import Path from 'path'; - -export default async function globalSetup(): Promise { - console.debug('Starting Bee Nodes...'); - const scriptPath = Path.resolve(__dirname, 'runBeeNode.sh'); - - try { - execSync(`chmod +x ${scriptPath}`); - execSync(scriptPath, { stdio: 'inherit' }); - console.debug('Bee Nodes started successfully'); - } catch (error) { - console.error('Error starting Bee Nodes:', error); - process.exit(1); - } -} diff --git a/tests/integration/test-node-setup/jestTeardown.ts b/tests/integration/test-node-setup/jestTeardown.ts deleted file mode 100644 index f844eb0..0000000 --- a/tests/integration/test-node-setup/jestTeardown.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { execSync } from 'child_process'; -import path from 'path'; - -export default async function globalTeardown(): Promise { - console.debug('Stopping Bee Nodes...'); - const scriptPath = path.resolve(__dirname, 'stopBeeNode.sh'); - - // Check if we should keep the bee-dev directory - const keepDirs = process.env.KEEP_BEE_DIRS === 'true' ? 'keep' : ''; - - try { - execSync(`chmod +x ${scriptPath}`); - execSync(`${scriptPath} ${keepDirs}`, { stdio: 'inherit' }); - console.debug('Bee Nodes stopped successfully'); - } catch (error) { - console.error('Error stopping Bee Nodes:', error); - process.exit(1); - } -} diff --git a/tests/integration/test-node-setup/runBeeNode.sh b/tests/integration/test-node-setup/runBeeNode.sh deleted file mode 100755 index 017d122..0000000 --- a/tests/integration/test-node-setup/runBeeNode.sh +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/bash - -# Compute the absolute directory of this script. -SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" -BEE_DIR="$SCRIPT_DIR/bee-dev" -BEE_REPO="https://github.com/Solar-Punk-Ltd/bee.git" -BEE_BRANCH="temp/dev-test" -BEE_BINARY_PATH="$BEE_DIR/dist/bee" -BEE_URL="127.0.0.1:1633" -OTHER_BEE_URL="127.0.0.1:1733" - -# Define separate log and pid files for each node. -LOG_FILE_1733="bee_1733.log" -LOG_FILE_1633="bee_1633.log" -BEE_PID_FILE_1733="bee_1733.pid" -BEE_PID_FILE_1633="bee_1633.pid" - -# Navigate to the directory where this script resides. -cd "$SCRIPT_DIR" || exit - -# Check if Bee binary already exists (from cache) -if [ -f "$BEE_BINARY_PATH" ] && [ -x "$BEE_BINARY_PATH" ]; then - echo "Bee binary found in cache at $BEE_BINARY_PATH, skipping clone and build." -else - # Clone the Bee repository if not already present. - if [ ! -d "$BEE_DIR" ]; then - echo "Cloning Bee repository into $BEE_DIR..." - git clone "$BEE_REPO" "$BEE_DIR" - echo "Repository cloned successfully." - else - echo "Bee repository already exists at $BEE_DIR, reusing..." - fi - - cd "$BEE_DIR" || exit - - # Checkout the desired branch and update. - CURRENT_BRANCH=$(git branch --show-current) - if [ "$CURRENT_BRANCH" != "$BEE_BRANCH" ]; then - echo "Switching to branch $BEE_BRANCH..." - git fetch origin "$BEE_BRANCH" || git fetch origin - git checkout "$BEE_BRANCH" || git checkout -b "$BEE_BRANCH" "origin/$BEE_BRANCH" - else - echo "Already on branch $BEE_BRANCH, pulling latest changes..." - git pull origin "$BEE_BRANCH" || echo "Pull failed or no changes to pull." - fi - - # Build the Bee binary. - if ! make binary; then - echo "Build failed. Exiting." - exit 1 - fi - - # Ensure the Bee binary exists and is executable. - if [ ! -f "$BEE_BINARY_PATH" ]; then - echo "Bee binary not found at $BEE_BINARY_PATH. Exiting." - exit 1 - fi - - chmod +x "$BEE_BINARY_PATH" - echo "Bee binary built successfully." -fi - -cd "$SCRIPT_DIR" || exit - -# --- Start Bee Node on port 1733 --- -echo "Starting Bee node on port 1733..." -nohup "$BEE_BINARY_PATH" dev \ - --api-addr="$OTHER_BEE_URL" \ - --verbosity=5 \ - --cors-allowed-origins="*" > "$LOG_FILE_1733" 2>&1 & -BEE_PID_1733=$! -echo $BEE_PID_1733 > "$BEE_PID_FILE_1733" - -# --- Start Bee Node on port 1633 --- -echo "Starting Bee node on port 1633..." -nohup "$BEE_BINARY_PATH" dev \ - --api-addr="$BEE_URL" \ - --verbosity=5 \ - --cors-allowed-origins="*" > "$LOG_FILE_1633" 2>&1 & -BEE_PID_1633=$! -echo $BEE_PID_1633 > "$BEE_PID_FILE_1633" - -# Wait a few seconds to let both nodes initialize. -for i in {1..10}; do - if curl --silent --fail "http://$OTHER_BEE_URL/health" && curl --silent --fail "http://$BEE_URL/health"; then - echo Both Bee nodes are healthy - break - fi - echo "Waiting for Bee nodes…" - sleep 1 -done - -echo "Both Bee nodes are healthy and ready to process requests." diff --git a/tests/integration/test-node-setup/stopBeeNode.sh b/tests/integration/test-node-setup/stopBeeNode.sh deleted file mode 100755 index ae3b228..0000000 --- a/tests/integration/test-node-setup/stopBeeNode.sh +++ /dev/null @@ -1,77 +0,0 @@ -#!/bin/bash - -# Define pid file names and ports. -BEE_PID_FILE_1733="bee_1733.pid" -BEE_PID_FILE_1633="bee_1633.pid" -LOG_FILE_1733="bee_1733.log" -LOG_FILE_1633="bee_1633.log" -BEE_PORT_1733=1733 -BEE_PORT_1633=1633 - -# Function to stop a Bee node given a pid file and port. -stop_bee_node() { - PID_FILE=$1 - PORT=$2 - - if [ -f "$PID_FILE" ]; then - BEE_PID=$(cat "$PID_FILE") - echo "Stopping Bee node on port $PORT with PID $BEE_PID..." - kill "$BEE_PID" 2>/dev/null || true - - sleep 1 - - if ps -p $BEE_PID > /dev/null 2>&1; then - echo "Force killing Bee node on port $PORT with PID $BEE_PID..." - kill -9 "$BEE_PID" 2>/dev/null || true - fi - - rm -f "$PID_FILE" - - echo "Bee node on port $PORT stopped." - else - echo "Bee node on port $PORT is not running or PID file not found." - fi - - # Ensure no process is still bound to the port. - BEE_PROCESS=$(lsof -t -i:$PORT 2>/dev/null || true) - if [ -n "$BEE_PROCESS" ]; then - # Only kill if it's actually a bee process, not something else - for pid in $BEE_PROCESS; do - PROC_NAME=$(ps -p $pid -o comm= 2>/dev/null || true) - if [[ "$PROC_NAME" == *"bee"* ]] || [[ "$PROC_NAME" == *"Bee"* ]]; then - echo "Killing bee process $pid using port $PORT..." - kill -9 $pid 2>/dev/null || true - else - echo "Skipping non-bee process $pid ($PROC_NAME) on port $PORT" - fi - done - fi -} - -TMP_DIR="$(dirname "$0")" -# Stop both Bee nodes. -stop_bee_node "$TMP_DIR/$BEE_PID_FILE_1733" $BEE_PORT_1733 -stop_bee_node "$TMP_DIR/$BEE_PID_FILE_1633" $BEE_PORT_1633 - -rm -f "$TMP_DIR/$LOG_FILE_1733" "$TMP_DIR/$LOG_FILE_1633" - -# Remove Bee repository and any associated data (if desired). -BEE_DIR="$TMP_DIR/bee-dev" -BEE_DATA_DIR="$TMP_DIR/bee-data" - -# Check if we should keep the directories (pass "keep" as first argument) -KEEP_DIRS="$1" - -if [ "$KEEP_DIRS" != "keep" ]; then - if [ -d "$BEE_DIR" ]; then - echo "Deleting Bee repository folder..." - rm -rf "$BEE_DIR" - fi - if [ -d "$BEE_DATA_DIR" ]; then - echo "Deleting Bee data directory..." - rm -rf "$BEE_DATA_DIR" - fi - echo "Cleanup completed." -else - echo "Keeping Bee directories for reuse in next test run." -fi diff --git a/tests/integration/testSetupHelpers.ts b/tests/integration/testSetupHelpers.ts deleted file mode 100644 index 9775364..0000000 --- a/tests/integration/testSetupHelpers.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { BatchId, Bee, PrivateKey } from '@ethersphere/bee-js'; - -import { BEE_URL, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, DEFAULT_MOCK_SIGNER } from '../utils'; - -import { buyStamp } from '@/utils/bee'; -import { ADMIN_STAMP_LABEL } from '@/utils/constants'; -import { generateRandomBytes } from '@/utils/crypto'; - -interface BeeWithStampAndSigner { - bee: Bee; - ownerStamp: BatchId; - signer: PrivateKey; -} - -let globalAdminStamp: BatchId | null = null; - -export async function ensureUniqueSignerWithStamp(isNewSigner: boolean = true): Promise { - const signerBytes = generateRandomBytes(PrivateKey.LENGTH); - const signer = isNewSigner ? new PrivateKey(signerBytes) : DEFAULT_MOCK_SIGNER; - - const bee = new Bee(BEE_URL, { signer }); - - if (!globalAdminStamp) { - try { - globalAdminStamp = await buyStamp(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, ADMIN_STAMP_LABEL); - } catch (error: any) { - console.error('Failed to create/find owner stamp:', error); - throw error; - } - } - - return { bee, ownerStamp: globalAdminStamp, signer }; -} - -export function resetGlobalStampState(): void { - globalAdminStamp = null; -} diff --git a/tests/integration/trash.spec.ts b/tests/integration/trash.spec.ts new file mode 100644 index 0000000..383165c --- /dev/null +++ b/tests/integration/trash.spec.ts @@ -0,0 +1,175 @@ +import { BatchId, Bee, Identifier } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, retryOnPropagationDelay } from '../utils'; + +import { setupUserDrive, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, FileRecord, ListDepth, NodeStatus, NodeType } from '@/types'; +import { ROOT_PATH } from '@/utils/constants'; + +describe('Lifecycle management', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let adminBatch: string | BatchId; + let testFi: FileRecord; + let drive: DriveInfo; + const TEST_NAME = 'trash-restore-forget.txt'; + const { writeTempFile, cleanup } = tempFileRegistry(); + + beforeAll(async () => { + ({ + bee, + fileManager, + drive, + ownerStamp: adminBatch, + } = await setupUserDrive('fileoperations', { stampLabel: 'fileOpsIntegration' })); + + writeTempFile(TEST_NAME, 'file ops content'); + await fileManager.uploadFile(drive.id, { path: TEST_NAME, sourcePath: TEST_NAME }); + + testFi = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; + expect(testFi).toBeDefined(); + expect(testFi.status).toBe(NodeStatus.Active); + }); + + afterAll(cleanup); + + it('should trash a file (soft-delete)', async () => { + const initial = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; + const beforeVersion = BigInt((initial.version ?? '0').toString()); + + await fileManager.trashFile(initial); + expect(initial.status).toBe(NodeStatus.Trashed); + + const fm2 = await createInitializedFileManager(bee, adminBatch); + await fm2.listFolder(new Identifier(drive.id), ROOT_PATH); + + const fi2 = fm2.recordList.find((fr) => fr.path === TEST_NAME)!; + + expect(fi2.status).toBe(NodeStatus.Trashed); + expect(BigInt(fi2.version!.toString())).toBe(beforeVersion); + }); + + it('should recover a previously trashed file', async () => { + if (testFi.status !== NodeStatus.Trashed) { + await fileManager.trashFile(testFi); + expect(testFi.status).toBe(NodeStatus.Trashed); + } else { + expect(testFi.status).toBe(NodeStatus.Trashed); + } + const beforeVersion = BigInt(testFi.version!.toString()); + + await fileManager.recoverFile(testFi); + + const fi2 = await retryOnPropagationDelay(async () => { + const fm2 = await createInitializedFileManager(bee, adminBatch); + await fm2.listFolder(drive.id, ROOT_PATH); + const found = fm2.recordList.find((fr) => fr.path === TEST_NAME)!; + if (found.status !== NodeStatus.Active) { + throw new Error('recover not yet propagated to a fresh instance'); + } + return found; + }); + + expect(fi2.status).toBe(NodeStatus.Active); + expect(BigInt(fi2.version!.toString())).toBe(beforeVersion); + }); + + it('should recover a previously trashed folder', async () => { + const FOLDER_NAME = 'trash-recover-folder'; + const folder = await fileManager.createFolder(drive.id, ROOT_PATH, FOLDER_NAME); + expect(folder.status).toBe(NodeStatus.Active); + + await fileManager.trashFolder(folder); + expect(folder.status).toBe(NodeStatus.Trashed); + + await fileManager.recoverFolder(folder); + expect(folder.status).toBe(NodeStatus.Active); + + const recovered = await retryOnPropagationDelay(async () => { + const fm2 = await createInitializedFileManager(bee, adminBatch); + const entries = await fm2.listFolder(drive.id, ROOT_PATH); + const found = entries.find((e) => e.type === NodeType.Folder && e.topic.toString() === folder.topic.toString()); + if (!found || found.status !== NodeStatus.Active) { + throw new Error('folder recover not yet propagated to a fresh instance'); + } + return found; + }); + + expect(recovered.status).toBe(NodeStatus.Active); + }); + + it('should forget (hard-delete) a file', async () => { + await fileManager.forget(drive.id, TEST_NAME); + expect(fileManager.recordList.find((fr) => fr.path === TEST_NAME)).toBeUndefined(); + + const fm2 = new FileManagerBase(bee); + await fm2.initialize(); + + expect(fm2.recordList.find((fr) => fr.path === TEST_NAME)).toBeUndefined(); + }); + + it('should never duplicate FileRecord entries when trashing/recovering', async () => { + await fileManager.uploadFile(drive.id, { path: TEST_NAME, sourcePath: TEST_NAME }); + + const freshFi = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; + const topic = freshFi.topic.toString(); + expect(fileManager.recordList.filter((fr) => fr.topic.toString() === topic)).toHaveLength(1); + + await fileManager.trashFile(freshFi); + expect(freshFi.status).toBe(NodeStatus.Trashed); + + await expect(fileManager.trashFile(freshFi)).rejects.toThrow(/Already trashed/i); + + await fileManager.recoverFile(freshFi); + expect(freshFi.status).toBe(NodeStatus.Active); + + await expect(fileManager.recoverFile(freshFi)).rejects.toThrow(/Not trashed, cannot recover/i); + + expect(fileManager.recordList.filter((fr) => fr.topic.toString() === topic)).toHaveLength(1); + }); + + it('recordList should never gain duplicate topics when trash/restoring', async () => { + await fileManager.listFolder(drive.id, ROOT_PATH); + + const fi0 = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; + const topic = fi0.topic.toString(); + const beforeVer = BigInt(fi0.version!.toString()); + + if (fi0.status !== NodeStatus.Trashed) { + await fileManager.trashFile(fi0); + } + await fileManager.recoverFile(fi0); + + const fm2 = await createInitializedFileManager(bee, adminBatch); + await fm2.listFolder(drive.id, ROOT_PATH); + const fi2 = fm2.recordList.find((fr) => fr.topic.toString() === topic)!; + + expect(BigInt(fi2.version!.toString())).toBe(beforeVer); + }); + + it('forgets only the targeted file, leaving the same-named file in the other folder', async () => { + const src = writeTempFile('it-forget-dup.txt', 'dup content'); + const up = await fileManager.uploadFiles( + drive.id, + [ + { path: 'fa/dup.txt', sourcePath: src }, + { path: 'fb/dup.txt', sourcePath: src }, + ], + '', + ); + expect(up.failed).toHaveLength(0); + + await fileManager.forget(drive.id, 'fa/dup.txt'); + + expect(fileManager.recordList.find((fr) => fr.path === 'fa/dup.txt')).toBeUndefined(); + expect(fileManager.recordList.find((fr) => fr.path === 'fb/dup.txt')).toBeDefined(); + + const faEntries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, 'fa', ListDepth.Shallow)); + expect(faEntries.some((e) => e.type === NodeType.File)).toBe(false); + + const fbEntries = await retryOnPropagationDelay(() => fileManager.listFolder(drive.id, 'fb', ListDepth.Shallow)); + expect(fbEntries.some((e) => e.type === NodeType.File && e.path === 'fb/dup.txt')).toBe(true); + }); +}); diff --git a/tests/integration/version.spec.ts b/tests/integration/version.spec.ts new file mode 100644 index 0000000..ec7a4c4 --- /dev/null +++ b/tests/integration/version.spec.ts @@ -0,0 +1,305 @@ +import { Bee, FeedIndex, PrivateKey, Topic } from '@ethersphere/bee-js'; + +import { + buyStampSerialized, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + retryOnPropagationDelay, + streamToUint8Array, +} from '../utils'; + +import { setupUserDrive, tempFileRegistry } from './setup/utils'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, FileRecord } from '@/types'; +import { getFeedData } from '@/utils/bee'; +import { FEED_INDEX_ZERO, ROOT_PATH } from '@/utils/constants'; + +describe('Version control', () => { + let bee: Bee; + let fileManager: FileManagerBase; + let drive: DriveInfo; + let signer: PrivateKey; + const { writeTempFile, cleanup } = tempFileRegistry(); + + // helper to ensure at least one base FileRecord exists. + // Flat, cwd-relative name: upload()'s `path` doubles as both the on-disk source and the + // top-level drive manifest fork name, so it must resolve with zero intermediate segments. + const ensureBase = async (name = `versioned-file-${Date.now()}`, di: DriveInfo = drive): Promise => { + const existing = fileManager.recordList.find((f) => f.path === name); + if (existing) return existing; + writeTempFile(name, 'seed'); + await fileManager.uploadFile(di.id, { path: name, sourcePath: name }); + return fileManager.recordList.at(-1)!; + }; + + beforeAll(async () => { + ({ bee, fileManager, drive, signer } = await setupUserDrive('versioncontrol', { stampLabel: 'versioningStamp' })); + }); + + afterAll(cleanup); + + it('throws on invalid version index', async () => { + const base = await ensureBase(); + await expect(fileManager.getFileVersion(base, BigInt(999).toString())).rejects.toThrow(); + await expect(fileManager.getFileVersion(base, BigInt(-1).toString())).rejects.toThrow(); + }); + + it('handles sequential uploads with proper slot indices', async () => { + const name = `parallel-${Date.now()}`; + writeTempFile(name, 'v0'); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: name }); + const base = fileManager.recordList.at(-1)!; + + let latestVersion = BigInt(base.version!.toString()); + + for (const i of [1, 2, 3]) { + writeTempFile(name, `v${i}`); + await fileManager.updateFile(drive.id, base, { item: { sourcePath: name } }); + + latestVersion = BigInt(i); + } + + expect(latestVersion).toBe(BigInt(base.version!.toString()) + 3n); + + for (let i = 0n; i < latestVersion; i++) { + const fr = await fileManager.getFileVersion(base, FeedIndex.fromBigInt(i)); + expect(fr.version).toBe(FeedIndex.fromBigInt(i).toString()); + } + + // Fetch the current head without specifying an index + const newLatest = await fileManager.getFileVersion(base); + expect(newLatest.version).toBe(FeedIndex.fromBigInt(latestVersion).toString()); + }); + + it('updateFile lazy-loads the record on a cold cache (fresh instance, no prior listing)', async () => { + const NAME = `cold-update-${Date.now()}`; + writeTempFile(NAME, 'cold v0'); + await fileManager.uploadFile(drive.id, { path: NAME, sourcePath: NAME }); + const base = fileManager.recordList.find((fr) => fr.path === NAME)!; + expect(base.version).toBe(FEED_INDEX_ZERO.toString()); + + // Fresh instance shares the signer/state but never listed the folder — its record cache is empty. + const fm2 = new FileManagerBase(bee); + await fm2.initialize(); + expect(fm2.recordList.find((fr) => fr.topic === base.topic)).toBeUndefined(); + + writeTempFile(NAME, 'cold v1'); + const updated = await fm2.updateFile(drive.id, base, { item: { sourcePath: NAME } }); + + // Re-versioned via lazy hydration; the record is now present in the fresh instance's cache. + expect(updated.version).toBe(FeedIndex.fromBigInt(1n).toString()); + expect(updated.driveId).toBe(drive.id); + expect(fm2.recordList.filter((fr) => fr.topic === base.topic)).toHaveLength(1); + }); + + it('updateFile throws when the record belongs to a different drive', async () => { + const base = await ensureBase(); + + const otherBatch = await buyStampSerialized( + bee, + DEFAULT_BATCH_AMOUNT, + DEFAULT_BATCH_DEPTH, + `mismatchStamp-${Date.now()}`, + ); + await fileManager.createDrive(otherBatch, `other-drive-${Date.now()}`); + const otherDrive = fileManager.driveList.at(-1)!; + + // base was uploaded into `drive`, not `otherDrive`. + await expect(fileManager.updateFile(otherDrive.id, base, { customMetadata: { x: '1' } })).rejects.toThrow( + /does not belong to drive/, + ); + }); + + it('getFileVersion returns independently downloadable, version-correct bytes', async () => { + const NAME = `version-bytes-${Date.now()}`; + writeTempFile(NAME, 'Version bytes v0'); + await fileManager.uploadFile(drive.id, { path: NAME, sourcePath: NAME }); + const v0Fi = fileManager.recordList.at(-1)!; + + writeTempFile(NAME, 'Version bytes v1'); + await fileManager.updateFile(drive.id, v0Fi, { item: { sourcePath: NAME } }); + + const v0 = await fileManager.getFileVersion(v0Fi, FEED_INDEX_ZERO); + const head = await fileManager.getFileVersion(v0Fi); + + expect(v0.content.reference).not.toBe(head.content.reference); + + const v0Bytes = await retryOnPropagationDelay(async () => { + return streamToUint8Array( + await bee.downloadReadableData(v0.content.reference, { + actHistoryAddress: v0.content.historyRef, + actPublisher: v0.actPublisher, + }), + ); + }); + expect(Buffer.from(v0Bytes).toString('utf-8')).toBe('Version bytes v0'); + + const headBytes = await retryOnPropagationDelay(async () => { + return streamToUint8Array( + await bee.downloadReadableData(head.content.reference, { + actHistoryAddress: head.content.historyRef, + actPublisher: head.actPublisher, + }), + ); + }); + expect(Buffer.from(headBytes).toString('utf-8')).toBe('Version bytes v1'); + }); + + it('returns the cached FileRecord for the current head without refetching', async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const spyGetFeedData = jest.spyOn(require('@/utils/bee'), 'getFeedData'); + + const base = await ensureBase('cache-test'); + + const cached = fileManager.recordList.find((f) => f.topic === base.topic)!; + expect(cached).toBeDefined(); + + spyGetFeedData.mockClear(); + + const headSlot = FeedIndex.fromBigInt(BigInt(base.version!.toString())); + const result = await fileManager.getFileVersion(base, headSlot); + + expect(result).toBe(cached); + + expect(spyGetFeedData).not.toHaveBeenCalled(); + }); + + it('uploads multiple versions, counts them, fetches an old version and downloads it', async () => { + const NAME = `versioned-file-${Date.now()}`; + const content = 'Version 0 content'; + writeTempFile(NAME, content); + await fileManager.uploadFile(drive.id, { path: NAME, sourcePath: NAME }); + const v0Fi = fileManager.recordList.at(-1)!; + const initialVersion = BigInt(v0Fi.version!); + + writeTempFile(NAME, 'Version 1 content'); + await fileManager.updateFile(drive.id, v0Fi, { item: { sourcePath: NAME } }); + + const countAfterV1 = await getFeedData(bee, new Topic(v0Fi.topic), signer.publicKey().address().toString()); + const latestFi = await fileManager.getFileVersion(v0Fi, countAfterV1.feedIndex); + writeTempFile(NAME, 'Version 2 content'); + await fileManager.updateFile(drive.id, latestFi, { item: { sourcePath: NAME } }); + + // Raw feed reads are eventually consistent; under parallel node load the last write may not be + // visible immediately. Retry until the feed reflects all three writes (v0 + two updates). + const count = await retryOnPropagationDelay(async () => { + const c = await getFeedData(bee, new Topic(v0Fi.topic), signer.publicKey().address().toString()); + if (c.feedIndexNext.toBigInt() !== initialVersion + 3n) { + throw new Error(`feed not yet propagated: feedIndexNext=${c.feedIndexNext.toBigInt()}`); + } + return c; + }, 10); + expect(count.feedIndexNext.toBigInt()).toEqual(initialVersion + 3n); + + const v0 = await fileManager.getFileVersion(v0Fi, FEED_INDEX_ZERO); + expect(v0.version).toBeDefined(); + expect(v0.version).toBe(FEED_INDEX_ZERO.toString()); + }); + + it('can restore a prior version and make it the new head', async () => { + // Re-upload must reuse the exact path ensureBase() uploaded with — see ensureBase() comment above. + const NAME = 'restore-file'; + const base = await ensureBase(NAME); + const initialVersion = BigInt(base.version!.toString()); + const firstRef = base.content.reference; + + writeTempFile(NAME, 'second'); + await fileManager.updateFile(drive.id, base, { item: { sourcePath: NAME } }); + + await fileManager.restoreFileVersion(base); + + // Eventually consistent: retry until the restore's new head slot is visible. + const { feedIndex: current } = await retryOnPropagationDelay(async () => { + const fd = await getFeedData(bee, new Topic(base.topic), signer.publicKey().address().toString()); + if (fd.feedIndex.toBigInt() !== initialVersion + 2n) { + throw new Error(`restore not yet propagated: feedIndex=${fd.feedIndex.toBigInt()}`); + } + return fd; + }, 10); + + expect(BigInt(current.toBigInt())).toBe(initialVersion + 2n); + + const restored = await fileManager.getFileVersion(base, current); + + expect(restored.content.reference).toBe(firstRef); + expect(BigInt(restored.version!.toString())).toBe(initialVersion + 2n); + }); + + it('restoring on a single version file reaffirms the head', async () => { + const NAME = 'noop-restore'; + const base = await ensureBase(NAME); + writeTempFile(NAME, 'B'); + await fileManager.updateFile(drive.id, base, { item: { sourcePath: NAME } }); + + const currentHead = await fileManager.getFileVersion(base, base.version); + + await fileManager.restoreFileVersion(currentHead); + + const reHead = await fileManager.getFileVersion(base, base.version!); + expect(reHead.version).toBe(currentHead.version); + expect(reHead.content.reference).toBe(currentHead.content.reference); + }); + + it('restoring the current head does nothing', async () => { + const base = await ensureBase('noop-default'); + const headIdx = FeedIndex.fromBigInt(BigInt(base.version!.toString())); + const before = await fileManager.getFileVersion(base, headIdx); + + await expect(fileManager.restoreFileVersion(before)).rejects.toThrow( + `Head Slot cannot be restored. Please select a version lesser than: ${before.version?.toString()}`, + ); + + const after = await fileManager.getFileVersion(base, headIdx); + expect(after.version).toBe(before.version); + expect(after.content.reference).toBe(before.content.reference); + }); + + it("restoring an old version keeps the current (post-move) location, not the version's recorded path", async () => { + const NAME = 'restore-move-file.txt'; + writeTempFile(NAME, 'Restore Move V0 Content'); + await fileManager.uploadFile(drive.id, { path: NAME, sourcePath: NAME }); + const base = fileManager.recordList.at(-1)!; + const topic = base.topic.toString(); + + writeTempFile(NAME, 'Restore Move V1 Content'); + await fileManager.updateFile(drive.id, base, { item: { sourcePath: NAME } }); + + await fileManager.createFolder(drive.id, ROOT_PATH, 'restore-move-dest'); + const destPath = 'restore-move-dest/restore-move-file.txt'; + await fileManager.move(NAME, destPath, drive.id); + + const v0 = await fileManager.getFileVersion(base, FEED_INDEX_ZERO); + expect(v0.version).toBe(FEED_INDEX_ZERO.toString()); + + const { feedIndex: headBeforeRestore } = await getFeedData( + bee, + new Topic(topic), + signer.publicKey().address().toString(), + ); + + await fileManager.restoreFileVersion(v0); + + const cached = fileManager.recordList.find((f) => f.topic.toString() === topic)!; + expect(cached).toBeDefined(); + // Restoring content must not regress the tree position back to v0's own recorded path. + expect(cached.path).toBe(destPath); + + const { feedIndex: headAfterRestore } = await retryOnPropagationDelay(async () => { + const result = await getFeedData(bee, new Topic(topic), signer.publicKey().address().toString()); + if (!(result.feedIndex.toBigInt() > headBeforeRestore.toBigInt())) { + throw new Error('feed head has not advanced yet'); + } + return result; + }); + expect(headAfterRestore.toBigInt()).toBeGreaterThan(headBeforeRestore.toBigInt()); + + const downloadResults = await retryOnPropagationDelay(() => + fileManager.downloadFolder(drive.id, 'restore-move-dest'), + ); + const downloaded = downloadResults.succeeded.find((d) => d.path === destPath); + expect(downloaded).toBeDefined(); + expect(downloadResults.failed).toEqual([]); + expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Restore Move V0 Content'); + }); +}); diff --git a/tests/unit/abort.spec.ts b/tests/unit/abort.spec.ts new file mode 100644 index 0000000..d5c1701 --- /dev/null +++ b/tests/unit/abort.spec.ts @@ -0,0 +1,170 @@ +import { BatchId, Bee, MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks, createMockDriveInfo, createMockNodeAddresses, seedRecords } from './mock'; + +import { FileRecord, ListDepth, NodeType } from '@/types'; +import { DriveError } from '@/utils'; +import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; + +describe('Abort signal handling', () => { + const otherMockBatchId = new BatchId('4'.repeat(64)); + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); + + beforeEach(async () => { + applyDefaultMocks(); + }); + + it('should throw for a directory upload regardless of an abort signal', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const controller = new AbortController(); + + await expect( + fm.uploadFile(di.id, { path: 'tests', sourcePath: 'tests' }, undefined, { + signal: controller.signal, + }), + ).rejects.toThrow('Cannot upload a directory - use uploadFiles'); + }); + + it('should pass requestOptions with signal to uploadData', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + const controller = new AbortController(); + + await fm.uploadFile(di.id, { path: 'package.json', sourcePath: 'package.json' }, undefined, { + signal: controller.signal, + }); + + const callsWithOptions = uploadDataSpy.mock.calls.filter((call) => call[3] !== undefined); + expect(callsWithOptions.length).toBeGreaterThan(0); + for (const call of callsWithOptions) { + expect(call[3]).toHaveProperty('signal', controller.signal); + } + }); + + it('should not pass signal if requestOptions is undefined', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + + await fm.uploadFile(di.id, { path: 'package.json', sourcePath: 'package.json' }); + + expect(uploadDataSpy).toHaveBeenCalled(); + for (const call of uploadDataSpy.mock.calls) { + expect(call[3]?.signal).toBeUndefined(); + } + }); + + it('should allow upload to proceed when signal is not aborted', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const controller = new AbortController(); + + await expect( + fm.uploadFile(di.id, { path: 'package.json', sourcePath: 'package.json' }, undefined, { + signal: controller.signal, + }), + ).resolves.not.toThrow(); + }); + + it('throw if listFolder is called on a non-existent drive', async () => { + const fm = await createInitializedFileManager(); + const freshDrive = createMockDriveInfo(actPublisher); + + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const { loadMantaray, getAllNodeEntries } = require('@/utils/mantaray'); + loadMantaray.mockResolvedValue(new MantarayNode()); + getAllNodeEntries.mockReturnValue([]); + + const controller = new AbortController(); + + await expect( + fm.listFolder(freshDrive.id, '', ListDepth.Shallow, undefined, { signal: controller.signal }), + ).rejects.toThrow(DriveError); + }); + + it('forwards the abort signal to getMantarayNode downloads in listFolder', async () => { + const fm = await createInitializedFileManager(); + const freshDrive = createMockDriveInfo(actPublisher); + + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const { loadMantaray, getAllNodeEntries } = require('@/utils/mantaray'); + loadMantaray.mockResolvedValue(new MantarayNode()); + getAllNodeEntries.mockReturnValue([]); + (fm as any).driveList.push(freshDrive); + + const downloadDataSpy = jest.spyOn(Bee.prototype, 'downloadData'); + const controller = new AbortController(); + + await fm.listFolder(freshDrive.id, '', ListDepth.Shallow, undefined, { + signal: controller.signal, + }); + + expect(downloadDataSpy).toHaveBeenCalledWith( + freshDrive.manifestRef!.reference, + { actHistoryAddress: freshDrive.manifestRef!.historyRef, actPublisher: expect.anything() }, + { signal: controller.signal }, + ); + expect(loadMantaray).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, { + signal: controller.signal, + }); + }); + + it('should allow listFolder to proceed when signal is not aborted', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const controller = new AbortController(); + + await expect( + fm.listFolder(drive.id, '', ListDepth.Shallow, undefined, { signal: controller.signal }), + ).resolves.not.toThrow(); + }); + + it('forwards the abort signal through downloadFile to the final content fetch', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const rec: FileRecord = { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + topic: Topic.fromString('signal-file').toString(), + driveId: drive.id, + path: 'a.txt', + content: { reference: '1'.repeat(64), historyRef: SWARM_ZERO_ADDRESS.toString() }, + redundancyLevel: RedundancyLevel.OFF, + }; + seedRecords(fm, rec); + + const downloadReadableDataSpy = jest.spyOn(Bee.prototype, 'downloadReadableData'); + const controller = new AbortController(); + + await fm.downloadFile(rec, undefined, { signal: controller.signal }); + + expect(downloadReadableDataSpy).toHaveBeenCalledWith( + '1'.repeat(64), + { actHistoryAddress: SWARM_ZERO_ADDRESS.toString(), actPublisher }, + { signal: controller.signal }, + ); + }); + + it('should allow downloadFolder to proceed when signal is not aborted', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const controller = new AbortController(); + + await expect(fm.downloadFolder(drive.id, '/', undefined, { signal: controller.signal })).resolves.not.toThrow(); + }); +}); diff --git a/tests/unit/drive.spec.ts b/tests/unit/drive.spec.ts new file mode 100644 index 0000000..15e5639 --- /dev/null +++ b/tests/unit/drive.spec.ts @@ -0,0 +1,129 @@ +import { BatchId, Bee, Identifier, RedundancyLevel, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks, createMockDriveInfo, createMockNodeAddresses, seedRecords } from './mock'; + +import { DriveInfo, NodeType } from '@/types'; +import { DriveError, FileManagerEvents } from '@/utils'; +import { ADMIN_STAMP_LABEL, SWARM_ZERO_ADDRESS } from '@/utils/constants'; + +describe('Drive operations', () => { + const otherMockBatchId = new BatchId('4'.repeat(64)); + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); + + beforeEach(async () => { + applyDefaultMocks(); + }); + + describe('createAdminDrive', () => { + it('should create an admin drive', async () => { + const fm = await createInitializedFileManager(); + const di = fm.driveList[0]; + expect(di).toBeDefined(); + expect(di.name).toBe(ADMIN_STAMP_LABEL); + expect(di.batchId).toBe(DUMMY_BATCH_ID.toString()); + expect(di.id).toHaveLength(64); + expect(di.owner).toBe(owner); + expect(di.topic).toBeDefined(); + expect(di.manifestRef).toBeDefined(); + expect(di.isAdmin).toBe(true); + }); + + it('should throw error if an admin drive already exists', async () => { + const fm = await createInitializedFileManager(); + await expect(fm.createAdminDrive('1'.repeat(64))).rejects.toThrow(new DriveError('Admin drive already exists')); + }); + }); + + describe('createDrive', () => { + it('should create a new drive', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + expect(di).toBeDefined(); + expect(di.name).toBe('Test Drive'); + expect(di.batchId).toBe(otherMockBatchId.toString()); + expect(di.id).toHaveLength(64); + expect(di.owner).toBe(owner); + expect(di.topic).toBeDefined(); + expect(di.manifestRef).toBeDefined(); + }); + + it('should throw error if drive with same name or batchId exists', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + await expect(fm.createDrive(otherMockBatchId, 'New Drive')).rejects.toThrow( + new DriveError( + `Drive with name "New Drive" or batchId "${otherMockBatchId.toString().slice(0, 6)}" already exists`, + ), + ); + const newDriveId = 'aa0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51'; + await expect(fm.createDrive(newDriveId, 'Test Drive')).rejects.toThrow( + new DriveError(`Drive with name "Test Drive" or batchId "${newDriveId.slice(0, 6)}" already exists`), + ); + }); + }); + + describe('forgetDrive', () => { + it('should remove a user drive, prune its files, and emit DRIVE_FORGOTTEN', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Drive to forget (unit)'); + const target = fm.driveList.find((d) => d.name === 'Drive to forget (unit)')!; + expect(target).toBeDefined(); + + seedRecords( + fm, + { + type: NodeType.File, + batchId: target.batchId, + owner, + actPublisher, + topic: Topic.fromString('forget-x').toString(), + driveId: target.id, + path: 'x.txt', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + redundancyLevel: RedundancyLevel.OFF, + }, + { + type: NodeType.File, + batchId: target.batchId, + owner, + actPublisher, + topic: Topic.fromString('forget-y').toString(), + driveId: target.id, + path: 'y.txt', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + redundancyLevel: RedundancyLevel.OFF, + }, + ); + + const diluteSpy = jest.spyOn(Bee.prototype, 'diluteBatch'); + + const eventPromise = new Promise((resolve) => { + const handler = ({ driveInfo }: { driveInfo: DriveInfo }): void => { + expect(driveInfo.id).toBe(target.id); + resolve(); + }; + fm.emitter.on(FileManagerEvents.DRIVE_FORGOTTEN, handler); + }); + + await fm.forgetDrive(new Identifier(target.id)); + await eventPromise; + + expect(fm.driveList.find((d) => d.id === target.id)).toBeUndefined(); + expect(fm.recordList.some((fr) => fr.driveId === target.id)).toBe(false); + expect(diluteSpy).not.toHaveBeenCalled(); + }); + + it('should throw when the drive does not exist', async () => { + const fm = await createInitializedFileManager(); + const ghost = createMockDriveInfo(actPublisher, { id: '9'.repeat(64), name: 'ghost', isAdmin: false }); + + await expect(fm.forgetDrive(new Identifier(ghost.id))).rejects.toThrow( + new DriveError(`Drive with id ${ghost.id.slice(0, 6)} not found`), + ); + }); + }); +}); diff --git a/tests/unit/events.spec.ts b/tests/unit/events.spec.ts new file mode 100644 index 0000000..866c801 --- /dev/null +++ b/tests/unit/events.spec.ts @@ -0,0 +1,62 @@ +import { BatchId, Bee, RedundancyLevel } from '@ethersphere/bee-js'; + +import { BEE_URL, createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks } from './mock'; + +import { EventEmitterBase } from '@/eventEmitter'; +import { NodeStatus } from '@/types'; +import { FileManagerEvents } from '@/utils'; + +describe('Events and emitter', () => { + const otherMockBatchId = new BatchId('4'.repeat(64)); + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + + beforeEach(async () => { + applyDefaultMocks(); + }); + + it('emits FILE_UPLOADED with the persisted FileRecord', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + const uploadHandler = jest.fn(); + + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID, emitter); + fm.emitter.on(FileManagerEvents.FILE_UPLOADED, uploadHandler); + const redundancy = RedundancyLevel.MEDIUM; + await fm.createDrive(otherMockBatchId, 'Test Drive', redundancy); + const di = fm.driveList[1]; + + jest.useFakeTimers(); + const fixedNow = 1_755_158_248_500; + jest.setSystemTime(new Date(fixedNow)); + + await fm.uploadFile(di.id, { path: 'package.json', sourcePath: 'package.json' }); + fm.emitter.off(FileManagerEvents.FILE_UPLOADED, uploadHandler); + + expect(uploadHandler).toHaveBeenCalledWith({ + record: expect.objectContaining({ + batchId: otherMockBatchId.toString(), + driveId: di.id, + path: 'package.json', + owner, + redundancyLevel: redundancy, + status: NodeStatus.Active, + timestamp: fixedNow, + topic: expect.any(String), + }), + }); + + jest.useRealTimers(); + }); + + it('emits an INITIALIZED event with true on successful init', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const eventHandler = jest.fn(); + const emitter = new EventEmitterBase(); + emitter.on(FileManagerEvents.INITIALIZED, eventHandler); + await createInitializedFileManager(bee, DUMMY_BATCH_ID, emitter); + + expect(eventHandler).toHaveBeenCalledWith(true); + }); +}); diff --git a/tests/unit/file.spec.ts b/tests/unit/file.spec.ts new file mode 100644 index 0000000..28234d6 --- /dev/null +++ b/tests/unit/file.spec.ts @@ -0,0 +1,539 @@ +import { BatchId, Bee, Bytes, FeedIndex, MantarayNode, RedundancyLevel, Reference, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { + applyDefaultMocks, + createMockDriveInfo, + createMockNodeAddresses, + SeedableFm, + seedDummyFile, + seedRecords, +} from './mock'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, FileRecord, NodeStatus, NodeType } from '@/types'; +import { FileError, FileManagerEvents, FileRecordError } from '@/utils'; +import { getFeedData } from '@/utils/bee'; +import { + FEED_INDEX_ZERO, + MANIFEST_METADATA_FILE_TOPIC, + MANIFEST_METADATA_NODE_TOPIC, + MANIFEST_METADATA_NODE_TYPE, + SWARM_ZERO_ADDRESS, +} from '@/utils/constants'; + +describe('File operations', () => { + const otherMockBatchId = new BatchId('4'.repeat(64)); + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); + + beforeEach(async () => { + applyDefaultMocks(); + }); + + describe('downloadFile', () => { + it('fetches a single held record and returns one result', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const a = seedDummyFile(drive, 'a.txt', '1'.repeat(64), owner, actPublisher); + seedRecords(fm, a, seedDummyFile(drive, 'b.txt', '2'.repeat(64), owner, actPublisher)); + + const downloadReadableDataSpy = jest.spyOn(Bee.prototype, 'downloadReadableData'); + const result = await fm.downloadFile(a); + + expect(downloadReadableDataSpy).toHaveBeenCalledTimes(1); + expect(result.path).toBe('a.txt'); + }); + }); + + describe('downloadFiles', () => { + it('fetches exactly the passed records with no drive traversal', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const records: FileRecord[] = [ + { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + topic: Topic.fromString('dlf-a.txt').toString(), + driveId: drive.id, + path: 'a.txt', + content: { reference: '1'.repeat(64), historyRef: SWARM_ZERO_ADDRESS.toString() }, + redundancyLevel: RedundancyLevel.OFF, + }, + { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + topic: Topic.fromString('dlf-b.txt').toString(), + driveId: drive.id, + path: 'b.txt', + content: { reference: '2'.repeat(64), historyRef: SWARM_ZERO_ADDRESS.toString() }, + redundancyLevel: RedundancyLevel.OFF, + }, + ]; + + const downloadReadableDataSpy = jest.spyOn(Bee.prototype, 'downloadReadableData'); + const listFolderSpy = jest.spyOn(fm, 'listFolder'); + + const results = await fm.downloadFiles(records); + + expect(downloadReadableDataSpy).toHaveBeenCalledWith( + '2'.repeat(64), + { actHistoryAddress: SWARM_ZERO_ADDRESS.toString(), actPublisher }, + undefined, + ); + expect(downloadReadableDataSpy).toHaveBeenCalledTimes(2); + expect(results.succeeded.map((r) => r.path).sort()).toEqual(['a.txt', 'b.txt']); + + expect(listFolderSpy).not.toHaveBeenCalled(); + }); + + it('returns an empty array without touching Bee when given no records', async () => { + const fm = await createInitializedFileManager(); + const downloadDataSpy = jest.spyOn(Bee.prototype, 'downloadData'); + + const results = await fm.downloadFiles([]); + + expect(results.succeeded).toEqual([]); + expect(downloadDataSpy).not.toHaveBeenCalled(); + }); + + it('splits partial results: fetched records land in succeeded, the failing one in failed', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const good = seedDummyFile(drive, 'good.txt', '1'.repeat(64), owner, actPublisher); + const bad = seedDummyFile(drive, 'bad.txt', '2'.repeat(64), owner, actPublisher); + + jest.spyOn(Bee.prototype, 'downloadReadableData').mockImplementation(async (ref: unknown) => { + if (ref === '2'.repeat(64)) { + throw new Error('boom'); + } + return new ReadableStream({ + start(controller) { + controller.enqueue(SWARM_ZERO_ADDRESS.toUint8Array()); + controller.close(); + }, + }); + }); + + const results = await fm.downloadFiles([good, bad]); + + expect(results.succeeded.map((r) => r.path)).toEqual(['good.txt']); + expect(results.failed).toEqual([{ path: 'bad.txt', error: 'boom' }]); + }); + }); + + describe('uploadFile', () => { + it('uploads a new file: adds it to recordList at version 0 and forks it into the drive manifest', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await fm.uploadFile(di.id, { path: 'package.json', sourcePath: 'package.json' }); + + const entries = fm.recordList.filter((fr) => fr.path === 'package.json'); + expect(entries).toHaveLength(1); + expect(entries[0].version).toBe(FEED_INDEX_ZERO.toString()); + expect(entries[0].driveId).toBe(di.id); + expect(entries[0].status).toBe(NodeStatus.Active); + + // Fresh topic is minted (not derived from any input). + expect(entries[0].topic.length).toBeGreaterThan(0); + + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('package.json')).toBeTruthy(); + }); + + it('places the file at `path`, independent of `sourcePath` (rename on upload)', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await fm.uploadFile(di.id, { path: 'renamed.json', sourcePath: 'package.json' }); + + expect(fm.recordList.find((fr) => fr.path === 'renamed.json')).toBeDefined(); + expect(fm.recordList.find((fr) => fr.path === 'package.json')).toBeUndefined(); + + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('renamed.json')).toBeTruthy(); + expect(driveMantaray.find('package.json')).toBeFalsy(); + }); + + it('uploads into a subfolder: forks the file into the folder manifest, not the drive root', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await fm.createFolder(di.id, '', 'tests'); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + await fm.uploadFile(di.id, { path: 'tests/utils.ts', sourcePath: 'tests/utils.ts' }); + + expect(fm.recordList.find((fr) => fr.path === 'tests/utils.ts')).toBeDefined(); + + // The file fork lives under the folder's own manifest — the drive root manifest carries the + // 'tests' folder fork but not the file leaf. + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('tests')).toBeTruthy(); + expect(driveMantaray.find('utils.ts')).toBeFalsy(); + }); + + it('throws when uploading a directory — directories must go through uploadFiles', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await expect(fm.uploadFile(di.id, { path: 'tests', sourcePath: 'tests' })).rejects.toThrow( + 'Cannot upload a directory - use uploadFiles', + ); + }); + + it('throws a FileError instance for a directory upload', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await expect(fm.uploadFile(di.id, { path: 'tests', sourcePath: 'tests' })).rejects.toBeInstanceOf(FileError); + }); + + it('throws for a nested directory path (not just a top-level one)', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await expect(fm.uploadFile(di.id, { path: 'tests/unit', sourcePath: 'tests/unit' })).rejects.toThrow( + 'Path not found: /tests', + ); + }); + + it('does not add a fork or recordList entry when a directory upload is rejected', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await expect(fm.uploadFile(di.id, { path: 'tests', sourcePath: 'tests' })).rejects.toThrow(); + + expect(fm.recordList.find((fr) => fr.path === 'tests')).toBeUndefined(); + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('tests')).toBeFalsy(); + }); + + it('throws when a drive is not found', async () => { + const fm = await createInitializedFileManager(); + const ghost = createMockDriveInfo(actPublisher, { id: '7'.repeat(64), name: 'ghost' }); + + await expect(fm.uploadFile(ghost.id, { path: 'package.json', sourcePath: 'package.json' })).rejects.toThrow( + `Drive with id ${ghost.id.slice(0, 6)} not found`, + ); + }); + }); + + describe('updateFile', () => { + // Seed a real, version-0 record via a fresh upload so update() re-versions an actual file. + async function seedUploadedFile(): Promise<{ fm: FileManagerBase; di: DriveInfo; record: FileRecord }> { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + await fm.uploadFile(di.id, { path: 'package.json', sourcePath: 'package.json' }); + const record = fm.recordList.find((fr) => fr.path === 'package.json')!; + return { fm, di, record }; + } + + it('metadata-only: bumps version, merges customMetadata, reuses the content ref, and does not upload bytes', async () => { + const { fm, di, record } = await seedUploadedFile(); + + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FILE_UPDATED, handler); + + await fm.updateFile(di.id, record, { customMetadata: { note: 'hello' } }); + + const entries = fm.recordList.filter((fr) => fr.topic === record.topic); + expect(entries).toHaveLength(1); + const updated = entries[0]; + expect(updated.version).toBe(FeedIndex.fromBigInt(1n).toString()); + expect(updated.path).toBe(record.path); + expect(updated.customMetadata).toMatchObject({ note: 'hello' }); + // Content ref reused verbatim — no bytes uploaded. + expect(updated.content).toEqual(record.content); + expect(handler).toHaveBeenCalled(); + }); + + it('metadata-only with empty changes re-publishes a new version (content and metadata unchanged)', async () => { + const { fm, di, record } = await seedUploadedFile(); + + await expect(fm.updateFile(di.id, record, {})).rejects.toThrow( + new FileRecordError('Neither a file/path nor customMetadata is provided'), + ); + + const updated = fm.recordList.find((fr) => fr.topic === record.topic)!; + expect(updated.version).toBe(FEED_INDEX_ZERO.toString()); + expect(updated.path).toBe(record.path); + expect(updated.content).toEqual(record.content); + }); + + it('re-saves the parent manifest to sync the fork version when re-versioning', async () => { + const { fm, di, record } = await seedUploadedFile(); + const saveManifestSpy = jest.spyOn((fm as any).store, 'saveMantarayNode'); + await fm.updateFile(di.id, record, { item: { sourcePath: 'package.json' } }); + + // The fork's cached NODE_VERSION must track the feed head, so update now persists the manifest. + expect(saveManifestSpy).toHaveBeenCalledTimes(1); + }); + + it('uploads new bytes: re-versions an existing file and derives ACT history from the record', async () => { + // A real upload seeds the fork; update now syncs that fork's version, so the fork must exist. + const { fm, di, record } = await seedUploadedFile(); + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + + await fm.updateFile(di.id, record, { item: { sourcePath: 'package.json' } }); + // New content bytes are uploaded; updateFile derives actHistoryAddress from record.content. + expect(uploadDataSpy).toHaveBeenCalled(); + + const updated = fm.recordList.find((fr) => fr.topic === record.topic)!; + expect(updated.version).toBe(FeedIndex.fromBigInt(1n).toString()); + expect(updated.path).toBe('package.json'); + }); + + it('does not create a second recordList entry when re-versioning (upsert, not append)', async () => { + const { fm, di, record } = await seedUploadedFile(); + + await fm.updateFile(di.id, record, { item: { sourcePath: 'package.json' } }); + + expect(fm.recordList.filter((fr) => fr.topic === record.topic)).toHaveLength(1); + }); + + it('throws when uploading a directory as the new content source', async () => { + const { fm, di, record } = await seedUploadedFile(); + + await expect(fm.updateFile(di.id, record, { item: { sourcePath: 'tests' } })).rejects.toThrow( + 'Cannot upload a directory - use uploadFiles', + ); + }); + + it('throws when the drive is not found', async () => { + const fm = await createInitializedFileManager(); + const ghost = createMockDriveInfo(actPublisher, { id: '7'.repeat(64), name: 'ghost' }); + const record: FileRecord = { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + redundancyLevel: RedundancyLevel.OFF, + actPublisher, + topic: Topic.fromString('orphan').toString(), + driveId: ghost.id, + path: 'package.json', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + version: FEED_INDEX_ZERO.toString(), + }; + + await expect(fm.updateFile(ghost.id, record, {})).rejects.toThrow( + `Drive with id ${ghost.id.slice(0, 6)} not found`, + ); + }); + + it('lazy-loads the record from its feed on a cache miss, then re-versions it', async () => { + const { fm, di, record } = await seedUploadedFile(); + + const ix = fm.recordList.findIndex((f) => f.topic === record.topic); + (fm as unknown as SeedableFm)._recordList.splice(ix, 1); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FEED_INDEX_ZERO, + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + const getRecordSpy = jest + .spyOn((fm as any).store, 'getRecord') + .mockResolvedValue({ ...record, path: 'package.json' }); + + await fm.updateFile(di.id, record, { customMetadata: { note: 'hi' } }); + + expect(getRecordSpy).toHaveBeenCalledWith(record.topic, record.actPublisher, expect.anything(), undefined); + const rehydrated = fm.recordList.filter((f) => f.topic === record.topic); + expect(rehydrated).toHaveLength(1); + expect(rehydrated[0].version).toBe(FeedIndex.fromBigInt(1n).toString()); + expect(rehydrated[0].customMetadata).toMatchObject({ note: 'hi' }); + + getRecordSpy.mockRestore(); + }); + + it('throws when the resolved record does not belong to the target drive', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const foreign: FileRecord = { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + redundancyLevel: RedundancyLevel.OFF, + actPublisher, + topic: Topic.fromString('foreign-topic').toString(), + driveId: di.id, + path: 'package.json', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + version: FEED_INDEX_ZERO.toString(), + }; + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FEED_INDEX_ZERO, + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + jest.spyOn((fm as any).store, 'getRecord').mockResolvedValue({ ...foreign, driveId: '9'.repeat(64) }); + + await expect(fm.updateFile(di.id, foreign, { customMetadata: { a: '1' } })).rejects.toThrow( + `does not belong to drive "${di.name}"`, + ); + }); + + it('throws a not-found error when the record is absent on a cold cache', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const record: FileRecord = { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + redundancyLevel: RedundancyLevel.OFF, + actPublisher, + topic: Topic.fromString('cold-topic').toString(), + driveId: di.id, + path: 'package.json', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + version: FEED_INDEX_ZERO.toString(), + }; + + await expect(fm.updateFile(di.id, record, { customMetadata: { a: '1' } })).rejects.toThrow( + `File record not found for topic: ${record.topic.slice(0, 6)}`, + ); + }); + }); + + describe('move', () => { + it('renames a file fork in place and bumps the FileRecord version', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const drive = fm.driveList[1]; + + await fm.uploadFile(drive.id, { path: 'package.json', sourcePath: 'package.json' }); + const original = fm.recordList.find((fr) => fr.path === 'package.json')!; + + await fm.move('package.json', 'renamed.json', drive.id); + + const moved = fm.recordList.find((fr) => fr.topic === original.topic)!; + expect(moved.path).toBe('renamed.json'); + expect(moved.version).toBe(FeedIndex.fromBigInt(1n).toString()); + + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + expect(driveMantaray.find('package.json')).toBeFalsy(); + expect(driveMantaray.find('renamed.json')).toBeTruthy(); + }); + + it('refuses to move a trashed node until it is recovered', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const drive = fm.driveList[1]; + + await fm.uploadFile(drive.id, { path: 'package.json', sourcePath: 'package.json' }); + const original = fm.recordList.find((fr) => fr.path === 'package.json')!; + await fm.trashFile(original); + + await expect(fm.move('package.json', 'renamed.json', drive.id)).rejects.toThrow( + 'Cannot move a trashed file/folder; recover it first', + ); + + // The guard fires before any manifest mutation — the fork stays put. + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + expect(driveMantaray.find('package.json')).toBeTruthy(); + expect(driveMantaray.find('renamed.json')).toBeFalsy(); + }); + + it('self-hydrates a file that was never loaded into recordList', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + + const fileTopic = Topic.fromString('cold-file').toString(); + driveMantaray.addFork('cold.txt', new Reference(fileTopic), { + [MANIFEST_METADATA_FILE_TOPIC]: fileTopic, + [MANIFEST_METADATA_NODE_TOPIC]: fileTopic, + [MANIFEST_METADATA_NODE_TYPE]: NodeType.File, + }); + + const coldFileRecord = { + topic: fileTopic, + driveId: drive.id, + path: 'cold.txt', + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + redundancyLevel: RedundancyLevel.OFF, + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + }; + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + jest.spyOn(Bee.prototype, 'downloadData').mockResolvedValue(Bytes.fromUtf8(JSON.stringify(coldFileRecord))); + + expect(fm.recordList.find((f) => f.topic === fileTopic)).toBeUndefined(); + + await fm.move('cold.txt', 'warm.txt', drive.id); + + const moved = fm.recordList.find((f) => f.topic === fileTopic); + expect(moved).toBeDefined(); + expect(moved?.path).toBe('warm.txt'); + }); + + it('throws when the source path is not found in the manifest', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await expect(fm.move('missing.txt', 'x.txt', drive.id)).rejects.toThrow('Path not found: missing.txt'); + }); + + it('throws when source and destination paths are identical', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await expect(fm.move('a.txt', 'a.txt', drive.id)).rejects.toThrow('Source and destination paths are identical'); + }); + + it('rejects a move onto an existing destination and leaves both forks in place', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const drive = fm.driveList[1]; + + await fm.uploadFile(drive.id, { path: 'a.json', sourcePath: 'package.json' }); + await fm.uploadFile(drive.id, { path: 'b.json', sourcePath: 'package.json' }); + + await expect(fm.move('a.json', 'b.json', drive.id)).rejects.toThrow('Destination already exists: b.json'); + + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + expect(driveMantaray.find('a.json')).toBeTruthy(); + expect(driveMantaray.find('b.json')).toBeTruthy(); + }); + }); +}); diff --git a/tests/unit/fileManager.spec.ts b/tests/unit/fileManager.spec.ts deleted file mode 100644 index 3508243..0000000 --- a/tests/unit/fileManager.spec.ts +++ /dev/null @@ -1,1172 +0,0 @@ -import { - BatchId, - Bee, - Bytes, - DownloadOptions, - FeedIndex, - MantarayNode, - RedundancyLevel, - Reference, - Topic, -} from '@ethersphere/bee-js'; - -import { - createInitializedFileManager, - createInitMocks, - createMockFeedWriter, - createMockFileInfo, - createMockMantarayNode, - createMockNodeAddresses, - createUploadDataSpy, - createUploadFilesFromDirectorySpy, - createUploadFileSpy, - MOCK_BATCH_ID, - mockPostageBatch, -} from '../mockHelpers'; -import { BEE_URL, DEFAULT_MOCK_SIGNER } from '../utils'; - -import { EventEmitterBase } from '@/eventEmitter'; -import { FileManagerBase } from '@/fileManager'; -import { DriveInfo, FileInfo, FileStatus } from '@/types'; -import { FeedResultWithIndex, WrappedUploadResult } from '@/types/utils'; -import { DriveError, FileManagerEvents, SignerError } from '@/utils'; -import { fetchStamp, getFeedData } from '@/utils/bee'; -import { ADMIN_STAMP_LABEL, FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; -import { generateRandomBytes } from '@/utils/crypto'; - -jest.mock('@/utils/bee', () => ({ - ...jest.requireActual('@/utils/bee'), - getFeedData: jest.fn(), - fetchStamp: jest.fn(), - getWrappedData: jest.fn(), -})); -jest.mock('@/utils/crypto', () => ({ - generateRandomBytes: jest.fn(), -})); -jest.mock('@/utils/mantaray'); - -describe('FileManager', () => { - let mockSelfAddr: Reference; - const otherMockBatchId = new BatchId('4'.repeat(64)); - - beforeEach(async () => { - jest.resetAllMocks(); - createInitMocks(); - - (getFeedData as jest.Mock).mockResolvedValue({ - feedIndex: FeedIndex.MINUS_ONE, - feedIndexNext: FEED_INDEX_ZERO, - payload: { - toUint8Array: () => SWARM_ZERO_ADDRESS.toUint8Array(), - toJSON: () => ({ - topicReference: SWARM_ZERO_ADDRESS.toString(), - historyAddress: SWARM_ZERO_ADDRESS.toString(), - index: FEED_INDEX_ZERO.toString(), - }), - }, - }); - - const mokcMN = createMockMantarayNode(true); - mockSelfAddr = await mokcMN.calculateSelfAddress(); - - (fetchStamp as jest.Mock).mockResolvedValue({ ...mockPostageBatch }); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { getWrappedData } = require('@/utils/bee'); - getWrappedData.mockResolvedValue({ - uploadFilesRes: mockSelfAddr.toString(), - } as WrappedUploadResult); - - (generateRandomBytes as jest.Mock).mockImplementation(() => new Topic('1'.repeat(64))); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { loadMantaray } = require('@/utils/mantaray'); - loadMantaray.mockResolvedValue(mokcMN); - }); - - describe('constructor', () => { - it('should create new instance of FileManager', async () => { - const fm = await createInitializedFileManager(); - - expect(fm).toBeInstanceOf(FileManagerBase); - }); - - it('should throw error, if Signer is not provided', async () => { - try { - await createInitializedFileManager(); - } catch (error) { - expect(error).toBeInstanceOf(SignerError); - expect((error as any).message).toBe('Signer required'); - } - }); - - it('should initialize FileManager instance with correct values', async () => { - const fm = await createInitializedFileManager(); - - expect(fm.fileInfoList).toEqual([]); - }); - }); - - describe('initialize', () => { - it('should initialize FileManager', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const eventHandler = jest.fn((_) => {}); - const emitter = new EventEmitterBase(); - emitter.on(FileManagerEvents.INITIALIZED, eventHandler); - await createInitializedFileManager(bee, undefined, emitter); - - expect(eventHandler).toHaveBeenCalledWith(true); - }); - - it('should not initialize, if already initialized', async () => { - const eventHandler = jest.fn((_) => {}); - const emitter = new EventEmitterBase(); - emitter.on(FileManagerEvents.INITIALIZED, eventHandler); - - const fm = await createInitializedFileManager( - new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }), - undefined, - emitter, - ); - expect(eventHandler).toHaveBeenCalledWith(true); - await fm.initialize(); - expect(eventHandler).toHaveBeenCalledWith(true); - }); - - it('should not initialize, if currently being initialized', async () => { - const eventHandler = jest.fn((_) => {}); - const emitter = new EventEmitterBase(); - emitter.on(FileManagerEvents.INITIALIZED, eventHandler); - - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const fm = new FileManagerBase(bee, emitter); - const first = fm.initialize(); - const second = fm.initialize(); - await Promise.all([first, second]); - - expect(eventHandler).toHaveBeenCalledWith(true); - }); - }); - - describe('reinitialization', () => { - it('should emit STATE_INVALID when admin stamp becomes unusable during reinitialization', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const emitter = new EventEmitterBase(); - - const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); - getPostageBatchesSpy.mockResolvedValue([ - { - ...mockPostageBatch, - usable: true, - label: ADMIN_STAMP_LABEL, - }, - ]); - - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID, emitter); - expect(fm.adminStamp?.usable).toBe(true); - expect(fm.driveList).toHaveLength(1); - - let reinitFired = false; - emitter.on(FileManagerEvents.INITIALIZED, () => { - reinitFired = true; - }); - - await fm.initialize(); - expect(reinitFired).toBe(true); - expect(fm.driveList).toHaveLength(1); - - getPostageBatchesSpy.mockRestore(); - }); - - it('should successfully revalidate when admin stamp is still valid', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const emitter = new EventEmitterBase(); - - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID, emitter); - const initialDrives = fm.driveList; - const initialFileCount = fm.fileInfoList.length; - - let initEventFired = false; - let invalidEventFired = false; - - emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { - if (success) { - initEventFired = true; - } - }); - - emitter.on(FileManagerEvents.STATE_INVALID, () => { - invalidEventFired = true; - }); - - await fm.initialize(); - - expect(initEventFired).toBe(true); - expect(invalidEventFired).toBe(false); - expect(fm.driveList).toEqual(initialDrives); - expect(fm.fileInfoList).toHaveLength(initialFileCount); - }); - - it('should handle multiple sequential reinitializations with valid stamp', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID); - - const initialDriveCount = fm.driveList.length; - - for (let i = 0; i < 3; i++) { - await fm.initialize(); - expect(fm.driveList).toHaveLength(initialDriveCount); - } - }); - - it('should reset isInitialized flag when admin stamp becomes invalid', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - await createInitializedFileManager(bee, MOCK_BATCH_ID); - - const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); - getPostageBatchesSpy.mockResolvedValue([ - { - ...mockPostageBatch, - usable: false, - label: ADMIN_STAMP_LABEL, - }, - ]); - - const newFm = new FileManagerBase(bee); - await newFm.initialize(); - - expect((newFm as any).isInitialized).toBe(true); - expect(newFm.driveList).toHaveLength(0); - expect(newFm.fileInfoList).toHaveLength(0); - - getPostageBatchesSpy.mockRestore(); - }); - - it('should emit correct events during revalidation failure', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const emitter = new EventEmitterBase(); - - const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); - getPostageBatchesSpy.mockImplementation(async () => [ - { - ...mockPostageBatch, - usable: true, - label: ADMIN_STAMP_LABEL, - }, - ]); - - await createInitializedFileManager(bee, MOCK_BATCH_ID, emitter); - - const events: string[] = []; - emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { - events.push(`INITIALIZED:${success}`); - }); - - const fm2 = new FileManagerBase(bee, emitter); - await fm2.initialize(); - - expect(events).toContain('INITIALIZED:true'); - - getPostageBatchesSpy.mockRestore(); - }); - - it('should maintain isInitialized flag after successful reinitialization', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID); - - expect((fm as any).isInitialized).toBe(true); - - await fm.initialize(); - - expect((fm as any).isInitialized).toBe(true); - }); - - it('should not clear drives when reinitializing with valid stamp', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID); - - const drivesBefore = fm.driveList; - expect(drivesBefore.length).toBeGreaterThan(0); - - await fm.initialize(); - - const drivesAfter = fm.driveList; - expect(drivesAfter).toEqual(drivesBefore); - }); - - it('should maintain admin stamp reference after reinitialization', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID); - - const adminStampBefore = fm.adminStamp; - expect(adminStampBefore).toBeDefined(); - - await fm.initialize(); - - const adminStampAfter = fm.adminStamp; - expect(adminStampAfter).toBeDefined(); - expect(adminStampAfter?.batchID.toString()).toBe(adminStampBefore?.batchID.toString()); - }); - - it('should clear fileInfoList when admin stamp becomes invalid', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - await createInitializedFileManager(bee, MOCK_BATCH_ID); - - const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); - getPostageBatchesSpy.mockResolvedValue([ - { - ...mockPostageBatch, - usable: false, - label: ADMIN_STAMP_LABEL, - }, - ]); - - const newFm = new FileManagerBase(bee); - await newFm.initialize(); - - expect(newFm.fileInfoList).toHaveLength(0); - expect(newFm.driveList).toHaveLength(0); - - getPostageBatchesSpy.mockRestore(); - }); - - it('should not emit STATE_INVALID when admin stamp remains valid', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const emitter = new EventEmitterBase(); - - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID, emitter); - - let invalidEventFired = false; - emitter.on(FileManagerEvents.STATE_INVALID, () => { - invalidEventFired = true; - }); - - await fm.initialize(); - - expect(invalidEventFired).toBe(false); - }); - }); - - describe('download', () => { - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - - beforeEach(() => { - const { getForksMap } = jest.requireActual('@/utils/mantaray'); - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/mantaray'), 'getForksMap').mockImplementation(getForksMap); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should call mantaray.collect()', async () => { - createInitMocks(); - const fm = await createInitializedFileManager(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - const mantarayCollectSpy = jest.spyOn(MantarayNode.prototype, 'collect'); - await fm.download(mockFi); - - expect(mantarayCollectSpy).toHaveBeenCalled(); - }); - - it('should call bee.downloadData with only correct fork reference', async () => { - createInitMocks(); - const fm = await createInitializedFileManager(); - const downloadDataSpy = jest.spyOn(Bee.prototype, 'downloadData'); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - const mockMantarayNode = createMockMantarayNode(false); - jest.spyOn(MantarayNode, 'unmarshal').mockResolvedValue(new MantarayNode()); - jest.spyOn(MantarayNode.prototype, 'collect').mockReturnValue(mockMantarayNode.collect()); - - await fm.download(mockFi, ['/root/2.txt']); - - expect(downloadDataSpy).toHaveBeenCalledWith( - '2'.repeat(64), - { actHistoryAddress: undefined, actPublisher: undefined }, - undefined, - ); - }); - - it('should call download for all of forks', async () => { - const mockForkRef = new Reference('4'.repeat(64)); - createInitMocks(mockForkRef); - const fm = await createInitializedFileManager(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - const downloadDataSpy = jest.spyOn(Bee.prototype, 'downloadData'); - - const { settlePromises } = jest.requireActual('@/utils/common'); - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/common'), 'settlePromises').mockImplementation(settlePromises); - - const fileStrings = await fm.download(mockFi); - - expect(downloadDataSpy).toHaveBeenCalledWith( - '1'.repeat(64), - { actHistoryAddress: undefined, actPublisher: undefined }, - undefined, - ); - expect(downloadDataSpy).toHaveBeenCalledWith( - '2'.repeat(64), - { actHistoryAddress: undefined, actPublisher: undefined }, - undefined, - ); - expect(downloadDataSpy).toHaveBeenCalledWith( - '3'.repeat(64), - { actHistoryAddress: undefined, actPublisher: undefined }, - undefined, - ); - - expect(fileStrings[0]).toEqual(mockForkRef); - expect(fileStrings[1]).toEqual(mockForkRef); - expect(fileStrings[2]).toEqual(mockForkRef); - }); - }); - - describe('listFiles', () => { - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - - beforeEach(() => { - const { getForksMap } = jest.requireActual('@/utils/mantaray'); - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/mantaray'), 'getForksMap').mockImplementation(getForksMap); - }); - - it('should return correct reference and path', async () => { - createInitMocks(); - const fm = await createInitializedFileManager(); - const mockMantarayNode = createMockMantarayNode(false); - jest.spyOn(MantarayNode, 'unmarshal').mockResolvedValue(new MantarayNode()); - jest.spyOn(MantarayNode.prototype, 'collect').mockReturnValue(mockMantarayNode.collect()); - - const mockFi = await createMockFileInfo(owner, actPublisher); - - jest - .spyOn(Bee.prototype, 'downloadData') - .mockResolvedValueOnce(Bytes.fromUtf8(JSON.stringify({ uploadFilesRes: '1'.repeat(64) }))); - - const result = await fm.listFiles(mockFi); - expect(result).toEqual({ '/root/2.txt': '2'.repeat(64) }); - }); - }); - - describe('upload', () => { - it('should call uploadFilesFromDirectory', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - - const uploadFileOrDirectorySpy = createUploadFilesFromDirectorySpy('1'); - createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - - await fm.upload(di, { name: 'tests', path: './tests' }); - expect(uploadFileOrDirectorySpy).toHaveBeenCalled(); - - const fi = fm.fileInfoList.find((fi) => fi.driveId === di.id.toString() && fi.name === 'tests'); - expect(fi).toBeDefined(); - expect(fi?.topic).toBe(new Topic('1'.repeat(64)).toString()); - }); - - it('should call uploadFileOrDirectory if previewPath is provided', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - const uploadFileOrDirectorySpy = createUploadFilesFromDirectorySpy('1'); - const uploadFileOrDirectoryPreviewSpy = createUploadFilesFromDirectorySpy('6'); - createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - - await fm.upload(di, { name: 'tests', path: './tests' }); - - expect(uploadFileOrDirectorySpy).toHaveBeenCalled(); - expect(uploadFileOrDirectoryPreviewSpy).toHaveBeenCalled(); - }); - - it('should throw error if infoTopic and historyRef are not provided at the same time', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - - await expect(async () => { - await fm.upload(di, { - name: 'tests', - topic: 'topic', - path: './tests', - }); - }).rejects.toThrow('Options topic and historyRef have to be provided at the same time.'); - }); - - it('should not add duplicate entries when re-uploading same topic', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - createUploadFilesFromDirectorySpy('1'); - createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - (getFeedData as jest.Mock).mockResolvedValueOnce({ - feedIndex: FeedIndex.MINUS_ONE, - feedIndexNext: FEED_INDEX_ZERO, - payload: SWARM_ZERO_ADDRESS, - }); - - await fm.upload(di, { name: 'hello', path: './tests' }); - expect(fm.fileInfoList.filter((fi) => fi.name === 'hello')).toHaveLength(1); - - const original = fm.fileInfoList[0]; - createUploadFilesFromDirectorySpy('6'); - createUploadDataSpy('7'); - createUploadDataSpy('8'); - createMockFeedWriter('9'); - - await fm.upload( - di, - { - name: 'hello', - topic: original.topic, - version: original.version, - file: original.file, - path: './tests', - }, - { - actHistoryAddress: original.file.historyRef, - }, - ); - - expect(fm.fileInfoList.filter((fi) => fi.name === 'hello')).toHaveLength(1); - - const updated = fm.fileInfoList.find((fi) => fi.name === 'hello')!; - expect(updated.version!).toBe(FeedIndex.fromBigInt(1n).toString()); - }); - }); - - describe('version control', () => { - let fm: FileManagerBase; - - const dummyTopic = Topic.fromString('deadbeef').toString(); - const dummyFi: FileInfo = { - topic: dummyTopic, - file: { historyRef: '00'.repeat(32), reference: '11'.repeat(32) }, - owner: '', - batchId: 'aa'.repeat(32), - driveId: 'bb'.repeat(32), - name: 'x', - actPublisher: 'ff'.repeat(66), - version: '0', - }; - - beforeEach(async () => { - fm = await createInitializedFileManager(); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); - - it('getVersion should call fetchFileInfo and return FileInfo', async () => { - const fakeFi = { ...dummyFi, version: '1' }; - - const rawMock: FeedResultWithIndex = { - feedIndex: FeedIndex.fromBigInt(1n), - feedIndexNext: FeedIndex.fromBigInt(2n), - payload: new Bytes(new Reference('f'.repeat(64)).toUint8Array()), - }; - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/bee'), 'getFeedData').mockResolvedValue(rawMock); - - const spyFetch = jest.spyOn(FileManagerBase.prototype as any, 'fetchFileInfo').mockResolvedValue(fakeFi); - let got = await fm.getVersion(dummyFi, FeedIndex.fromBigInt(1n)); - - expect(spyFetch).toHaveBeenCalledWith(dummyFi, rawMock); - expect(got).toBe(fakeFi); - - got = await fm.getVersion(dummyFi); - expect(spyFetch).toHaveBeenCalledWith(dummyFi, rawMock); - expect(got).toBe(fakeFi); - }); - - it('download via getVersion + download returns the bytes', async () => { - const vFi = { ...dummyFi, topic: dummyTopic, file: dummyFi.file } as any; - jest.spyOn(fm, 'getVersion').mockResolvedValue(vFi); - const spyDl = jest.spyOn(fm, 'download').mockResolvedValue(['mocked bytes'] as any); - - const gotFi = await fm.getVersion(dummyFi, '3'); - const out = await fm.download(gotFi, ['path1'], { actPublisher: 'p', actHistoryAddress: 'h' } as DownloadOptions); - expect(fm.getVersion).toHaveBeenCalledWith(dummyFi, '3'); - expect(spyDl).toHaveBeenCalledWith(vFi, ['path1'], { actPublisher: 'p', actHistoryAddress: 'h' }); - expect(out).toEqual(['mocked bytes']); - }); - - it('getVersion throws if underlying feed is missing', async () => { - jest.restoreAllMocks(); - (getFeedData as jest.Mock).mockResolvedValue({ - feedIndex: FeedIndex.MINUS_ONE, - feedIndexNext: FEED_INDEX_ZERO, - payload: SWARM_ZERO_ADDRESS, - }); - - await expect(fm.getVersion(dummyFi)).rejects.toThrow(`File info not found for topic: ${dummyFi.topic}`); - await expect(fm.getVersion(dummyFi, FEED_INDEX_ZERO)).rejects.toThrow( - `File info not found for topic: ${dummyFi.topic}`, - ); - }); - - it('restoring the current head should simply re‑fetch that version and not emit an event', async () => { - const head = FeedIndex.fromBigInt(5n); - dummyFi.version = head.toString(); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/bee'), 'getFeedData').mockResolvedValue({ - feedIndex: head, - feedIndexNext: FeedIndex.fromBigInt(6n), - payload: SWARM_ZERO_ADDRESS, - }); - - const spyEmit = jest.spyOn(fm.emitter, 'emit'); - - await fm.restoreVersion(dummyFi); - - expect(spyEmit).not.toHaveBeenCalledWith(FileManagerEvents.FILE_VERSION_RESTORED, expect.anything()); - }); - - it('restoreVersion() when versionToRestore.version === headSlot is a no-op', async () => { - const head = FeedIndex.fromBigInt(3n); - const fakeFeedData: FeedResultWithIndex = { - feedIndex: head, - feedIndexNext: FeedIndex.fromBigInt(4n), - payload: SWARM_ZERO_ADDRESS, - }; - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/bee'), 'getFeedData').mockResolvedValueOnce(fakeFeedData); - - const spyEmit = jest.spyOn(fm.emitter, 'emit'); - - const dummyFiWithHead = { - ...dummyFi, - version: head.toString(), - }; - - await fm.restoreVersion(dummyFiWithHead); - - expect(spyEmit).not.toHaveBeenCalledWith(FileManagerEvents.FILE_VERSION_RESTORED, expect.anything()); - }); - }); - // TODO: test resetState - describe('drive handling', () => { - it('createDrive should create an admin drive', async () => { - const fm = await createInitializedFileManager(); - const di = fm.driveList[0]; - expect(di).toBeDefined(); - expect(di.name).toBe(ADMIN_STAMP_LABEL); - expect(di.batchId.toString()).toBe(MOCK_BATCH_ID.toString()); - expect(di.id.toString()).toHaveLength(64); - expect(di.owner).toBe(DEFAULT_MOCK_SIGNER.publicKey().address().toString()); - expect(di.infoFeedList).toStrictEqual([]); - expect(di.isAdmin).toBe(true); - }); - - it('createDrive should create a new drive', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[1]; - expect(di).toBeDefined(); - expect(di.name).toBe('Test Drive'); - expect(di.batchId.toString()).toBe(otherMockBatchId.toString()); - expect(di.id.toString()).toHaveLength(64); - expect(di.owner).toBe(DEFAULT_MOCK_SIGNER.publicKey().address().toString()); - expect(di.infoFeedList).toStrictEqual([]); - }); - - it('createDrive should throw error if drive with same name or batchId exists', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - await expect(fm.createDrive(otherMockBatchId, 'New Drive', false)).rejects.toThrow( - new DriveError(`Drive with name "New Drive" or batchId "${otherMockBatchId.toString()}" already exists`), - ); - await expect( - fm.createDrive('aa0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51', 'Test Drive', false), - ).rejects.toThrow( - new DriveError( - `Drive with name "Test Drive" or batchId "aa0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51" already exists`, - ), - ); - }); - - it('createDrive should throw error if trying to create a new admin drive', async () => { - const fm = await createInitializedFileManager(); - await expect(fm.createDrive(MOCK_BATCH_ID, 'New Drive', true)).rejects.toThrow( - new DriveError(`Admin drive already exists`), - ); - }); - - it('destroyDrive should call diluteBatch with batchId and MAX_DEPTH', async () => { - const diluteSpy = jest.spyOn(Bee.prototype, 'diluteBatch').mockResolvedValue(otherMockBatchId); - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[1]; - - await fm.destroyDrive(di, { ...mockPostageBatch, batchID: otherMockBatchId }); - - const ttlDays = mockPostageBatch.duration.toDays(); - const halvings = Math.floor(Math.log2(ttlDays)); - expect(diluteSpy).toHaveBeenCalledWith(di.batchId, mockPostageBatch.depth + halvings); - }); - - it('destroyDrive should throw error if trying to destroy Admin drive / stamp', async () => { - const fm = await createInitializedFileManager(); - const di = fm.driveList[0]; - - di.isAdmin = false; - await expect(async () => { - await fm.destroyDrive(di, mockPostageBatch); - }).rejects.toThrow(`Cannot destroy admin drive / stamp, batchId: ${MOCK_BATCH_ID.toString()}`); - - di.batchId = MOCK_BATCH_ID; - await expect(async () => { - await fm.destroyDrive(di, { ...mockPostageBatch, batchID: otherMockBatchId }); - }).rejects.toThrow(`Stamp does not match drive stamp`); - - di.isAdmin = true; - await expect(async () => { - await fm.destroyDrive(di, mockPostageBatch); - }).rejects.toThrow(`Cannot destroy admin drive / stamp, batchId: ${MOCK_BATCH_ID.toString()}`); - }); - - it('forgetDrive should remove a user drive, prune its files, persist, and emit DRIVE_FORGOTTEN', async () => { - const genMock = generateRandomBytes as jest.Mock; - genMock.mockReset(); - genMock - .mockImplementationOnce(() => new Topic('a'.repeat(64))) // admin drive id - .mockImplementationOnce(() => new Topic('b'.repeat(64))) // "Drive to forget (unit)" id - .mockImplementation(() => new Topic('c'.repeat(64))); // any further calls - - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Drive to forget (unit)', false); - const target = fm.driveList.find((d) => d.name === 'Drive to forget (unit)')!; - expect(target).toBeDefined(); - - const now = Date.now(); - const mkFi = (topic: string, name: string): FileInfo => ({ - batchId: target.batchId.toString(), - owner: DEFAULT_MOCK_SIGNER.publicKey().address().toString(), - topic, - name, - actPublisher: DEFAULT_MOCK_SIGNER.publicKey().toCompressedHex(), - file: { reference: '0x' + 'aa'.repeat(32), historyRef: '0x' + 'bb'.repeat(32) }, - driveId: target.id.toString(), - timestamp: now, - version: '0', - redundancyLevel: RedundancyLevel.OFF, - status: FileStatus.Active, - }); - - fm.fileInfoList.push(mkFi('topic-x', 'x.txt')); - fm.fileInfoList.push(mkFi('topic-y', 'y.txt')); - - const diluteSpy = jest.spyOn(Bee.prototype, 'diluteBatch'); - const saveSpy = jest.spyOn(fm as any, 'saveDriveList'); - - const eventPromise = new Promise((resolve) => { - const handler = ({ driveInfo }: { driveInfo: DriveInfo }): void => { - try { - expect(driveInfo.id.toString()).toBe(target.id.toString()); - resolve(); - } finally { - fm.emitter?.off?.(FileManagerEvents.DRIVE_FORGOTTEN, handler); - } - }; - fm.emitter.on(FileManagerEvents.DRIVE_FORGOTTEN, handler); - }); - - await fm.forgetDrive(target); - await eventPromise; - - const after = fm.driveList; - expect(after.find((d) => d.id.toString() === target.id.toString())).toBeUndefined(); - - expect(fm.fileInfoList.some((fi) => fi.driveId === target.id.toString())).toBe(false); - - expect(saveSpy).toHaveBeenCalled(); - expect(diluteSpy).not.toHaveBeenCalled(); - }); - - it('forgetDrive should throw when the drive does not exist', async () => { - const fm = await createInitializedFileManager(); - - const ghost: DriveInfo = { - id: '9'.repeat(64), - name: 'ghost', - batchId: otherMockBatchId.toString(), - owner: DEFAULT_MOCK_SIGNER.publicKey().address().toString(), - redundancyLevel: RedundancyLevel.OFF, - isAdmin: false, - } as any; - - await expect(fm.forgetDrive(ghost)).rejects.toThrow(new DriveError('Drive ghost not found')); - }); - }); - - describe('file operations', () => { - let fm: FileManagerBase; - let mockFi: FileInfo; - let drive: DriveInfo; - - beforeEach(async () => { - fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - drive = fm.driveList[0]; - - mockFi = { - batchId: 'aa'.repeat(32), - file: { reference: '11'.repeat(32), historyRef: '00'.repeat(32) }, - name: 'foo', - owner: '', - actPublisher: 'ff'.repeat(66), - topic: 'deadbeef'.repeat(8), - driveId: drive.id.toString(), - }; - - mockFi.status = FileStatus.Active; - mockFi.timestamp = 0; - mockFi.version = FeedIndex.fromBigInt(0n).toString(); - - fm.fileInfoList.push(mockFi); - }); - - it('trashFile should mark a file as trashed, persist and emit FILE_TRASHED', async () => { - expect(mockFi.status).toBe(FileStatus.Active); - expect(mockFi.timestamp).toBe(0); - - const uploadSpy = jest.spyOn(fm as any, 'uploadFileInfo'); - const saveSpy = jest.spyOn(fm as any, 'saveFileInfoFeed'); - const handler = jest.fn(); - fm.emitter.on(FileManagerEvents.FILE_TRASHED, handler); - - await fm.trashFile(mockFi); - - expect(mockFi.status).toBe(FileStatus.Trashed); - expect(mockFi.timestamp!).toBeGreaterThan(0); - - expect(uploadSpy).toHaveBeenCalledWith(mockFi, undefined); - expect(saveSpy).toHaveBeenCalledWith(mockFi); - - expect(handler).toHaveBeenCalledWith({ fileInfo: mockFi }); - }); - - it('recoverFile should mark a trashed file active, persist and emit FILE_RECOVERED', async () => { - await fm.trashFile(mockFi); - expect(mockFi.status).toBe(FileStatus.Trashed); - const beforeTs = mockFi.timestamp!; - - jest.useFakeTimers(); - jest.setSystemTime(new Date(beforeTs + 1)); - - const uploadSpy = jest.spyOn(fm as any, 'uploadFileInfo'); - const saveSpy = jest.spyOn(fm as any, 'saveFileInfoFeed'); - const handler = jest.fn(); - fm.emitter.on(FileManagerEvents.FILE_RECOVERED, handler); - - await fm.recoverFile(mockFi); - - expect(mockFi.status).toBe(FileStatus.Active); - expect(mockFi.timestamp!).toBeGreaterThan(beforeTs); - - expect(uploadSpy).toHaveBeenCalledWith(mockFi, undefined); - expect(saveSpy).toHaveBeenCalledWith(mockFi); - - expect(handler).toHaveBeenCalledWith({ fileInfo: mockFi }); - - jest.useRealTimers(); - }); - - it('forgetFile should remove file from lists, persist owner-feed, and emit FILE_FORGOTTEN', async () => { - createUploadFilesFromDirectorySpy('1'); - const saveOwnerSpy = jest.spyOn(fm as any, 'saveDriveList'); - const handler = jest.fn(); - fm.emitter.on(FileManagerEvents.FILE_FORGOTTEN, handler); - - await fm.upload(drive, { name: 'test-file', path: './tests' }); - const uploadedFile = fm.fileInfoList[fm.fileInfoList.length - 1]; - await fm.forgetFile(uploadedFile); - - expect(fm.fileInfoList).not.toContain(uploadedFile); - expect((fm as any).driveList.infoFeedList).not.toBe([]); - - expect(saveOwnerSpy).toHaveBeenCalled(); - expect(handler).toHaveBeenCalledWith({ fileInfo: uploadedFile }); - }); - }); - - // TODO: test invalid state emit - describe('eventEmitter', () => { - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - - it('should send event after upload happens', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const emitter = new EventEmitterBase(); - const uploadHandler = jest.fn((_args) => {}); - - const fm = await createInitializedFileManager(bee, MOCK_BATCH_ID, emitter); - fm.emitter.on(FileManagerEvents.FILE_UPLOADED, uploadHandler); - const redundancy = RedundancyLevel.MEDIUM; - await fm.createDrive(otherMockBatchId, 'Test Drive', false, redundancy); - const di = fm.driveList[0]; - createUploadFilesFromDirectorySpy('1'); - - (getFeedData as jest.Mock).mockResolvedValueOnce({ - feedIndex: FeedIndex.MINUS_ONE, - feedIndexNext: FEED_INDEX_ZERO, - payload: SWARM_ZERO_ADDRESS, - }); - - // Pin system time so fileInfo.timestamp is deterministic (upload uses Date.now()) - jest.useFakeTimers(); - const fixedNow = 1_755_158_248_500; // any number you like - jest.setSystemTime(new Date(fixedNow)); - - const expectedFileInfo: FileInfo = { - batchId: MOCK_BATCH_ID, - driveId: di.id.toString(), - customMetadata: undefined, - file: { - historyRef: SWARM_ZERO_ADDRESS.toString(), - reference: SWARM_ZERO_ADDRESS.toString(), - }, - actPublisher, - version: FEED_INDEX_ZERO.toString(), - name: 'tests', - owner: DEFAULT_MOCK_SIGNER.publicKey().address().toString(), - preview: undefined, - redundancyLevel: redundancy, - status: FileStatus.Active, - timestamp: fixedNow, // ← was expect.any(Number) - topic: expect.any(String), // leave topic flexible - }; - - await fm.upload(di, { name: 'tests', path: './tests' }); - fm.emitter.off(FileManagerEvents.FILE_UPLOADED, uploadHandler); - - expect(uploadHandler).toHaveBeenCalledWith({ fileInfo: expectedFileInfo }); - - jest.useRealTimers(); - }); - - it('should send an event after the fileManager is initialized', async () => { - const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); - const eventHandler = jest.fn((_) => {}); - const emitter = new EventEmitterBase(); - emitter.on(FileManagerEvents.INITIALIZED, eventHandler); - await createInitializedFileManager(bee, MOCK_BATCH_ID, emitter); - - expect(eventHandler).toHaveBeenCalledWith(true); - }); - }); - - describe('AbortController', () => { - const otherMockBatchId = new BatchId('4'.repeat(64)); - - beforeEach(() => { - const { getForksMap } = jest.requireActual('@/utils/mantaray'); - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - jest.spyOn(require('@/utils/mantaray'), 'getForksMap').mockImplementation(getForksMap); - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('should pass requestOptions with signal to uploadFilesFromDirectory', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - - const uploadFileOrDirectorySpy = createUploadFilesFromDirectorySpy('1'); - createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - - const controller = new AbortController(); - await fm.upload(di, { name: 'tests', path: './tests' }, undefined, { signal: controller.signal }); - - expect(uploadFileOrDirectorySpy).toHaveBeenCalled(); - const callArgs = uploadFileOrDirectorySpy.mock.calls[0]; - expect(callArgs[3]).toHaveProperty('signal', controller.signal); - }); - - it('should pass requestOptions with signal to uploadFile', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - - createUploadFilesFromDirectorySpy('1'); - const uploadFileSpy = createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - - const controller = new AbortController(); - await fm.upload(di, { name: 'test.txt', path: './tests/fixtures/test.txt' }, undefined, { - signal: controller.signal, - }); - - expect(uploadFileSpy).toHaveBeenCalled(); - const callArgs = uploadFileSpy.mock.calls[0]; - expect(callArgs[4]).toHaveProperty('signal', controller.signal); - }); - - it('should not pass signal if requestOptions is undefined', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - - const uploadFileOrDirectorySpy = createUploadFilesFromDirectorySpy('1'); - createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - - await fm.upload(di, { name: 'tests', path: './tests' }); - - expect(uploadFileOrDirectorySpy).toHaveBeenCalled(); - const callArgs = uploadFileOrDirectorySpy.mock.calls[0]; - // When requestOptions is not provided, the options object should not have signal - expect(callArgs[3]?.signal).toBeUndefined(); - }); - - it('should allow upload to proceed when signal is not aborted', async () => { - const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Test Drive', false); - const di = fm.driveList[0]; - - createUploadFilesFromDirectorySpy('1'); - createUploadFileSpy('2'); - createUploadDataSpy('3'); - createUploadDataSpy('4'); - createMockFeedWriter('5'); - - const controller = new AbortController(); - - // Should not throw when signal is not aborted - await expect( - fm.upload(di, { name: 'tests', path: './tests' }, undefined, { signal: controller.signal }), - ).resolves.not.toThrow(); - }); - - it('should pass requestOptions with signal to getWrappedData in listFiles', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { getWrappedData } = require('@/utils/bee'); - - const controller = new AbortController(); - await fm.listFiles(mockFi, undefined, undefined, { signal: controller.signal }); - - expect(getWrappedData).toHaveBeenCalled(); - const callArgs = getWrappedData.mock.calls[0]; - expect(callArgs[5]).toHaveProperty('signal', controller.signal); - }); - - it('should pass requestOptions with signal to loadMantaray in listFiles', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { loadMantaray } = require('@/utils/mantaray'); - - const controller = new AbortController(); - await fm.listFiles(mockFi, undefined, undefined, { signal: controller.signal }); - - expect(loadMantaray).toHaveBeenCalled(); - const callArgs = loadMantaray.mock.calls[0]; - expect(callArgs[3]).toHaveProperty('signal', controller.signal); - }); - - it('should not pass signal in listFiles if requestOptions is undefined', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { getWrappedData } = require('@/utils/bee'); - - await fm.listFiles(mockFi); - - expect(getWrappedData).toHaveBeenCalled(); - const callArgs = getWrappedData.mock.calls[0]; - expect(callArgs[5]).toBeUndefined(); - }); - - it('should allow listFiles to proceed when signal is not aborted', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - const controller = new AbortController(); - - await expect(fm.listFiles(mockFi, undefined, undefined, { signal: controller.signal })).resolves.not.toThrow(); - }); - - it('should pass requestOptions with signal through download to listFiles', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { getWrappedData } = require('@/utils/bee'); - - const controller = new AbortController(); - await fm.download(mockFi, undefined, undefined, { signal: controller.signal }); - - // download calls listFiles internally, so getWrappedData should be called with signal - expect(getWrappedData).toHaveBeenCalled(); - const callArgs = getWrappedData.mock.calls[0]; - expect(callArgs[5]).toHaveProperty('signal', controller.signal); - }); - - it('should not pass signal in download if requestOptions is undefined', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef - const { getWrappedData } = require('@/utils/bee'); - - await fm.download(mockFi); - - expect(getWrappedData).toHaveBeenCalled(); - const callArgs = getWrappedData.mock.calls[0]; - expect(callArgs[5]).toBeUndefined(); - }); - - it('should allow download to proceed when signal is not aborted', async () => { - const fm = await createInitializedFileManager(); - const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); - const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); - const mockFi = await createMockFileInfo(owner, actPublisher, mockSelfAddr.toString()); - - const controller = new AbortController(); - - await expect(fm.download(mockFi, undefined, undefined, { signal: controller.signal })).resolves.not.toThrow(); - }); - }); -}); diff --git a/tests/unit/folder.spec.ts b/tests/unit/folder.spec.ts new file mode 100644 index 0000000..903ae47 --- /dev/null +++ b/tests/unit/folder.spec.ts @@ -0,0 +1,268 @@ +import { BatchId, Bee, Bytes, FeedIndex, Identifier, MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks, createMockDriveInfo, createMockNodeAddresses, seedDummyFile, seedRecords } from './mock'; + +import { ListDepth, NodeHeader, NodeType } from '@/types'; +import { FileManagerEvents } from '@/utils'; +import { getFeedData } from '@/utils/bee'; +import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; + +describe('Folder operations', () => { + const otherMockBatchId = new BatchId('4'.repeat(64)); + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); + + beforeEach(async () => { + applyDefaultMocks(); + }); + + describe('downloadFolder', () => { + it('downloadFolder downloads every hydrated file belonging to the drive', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + seedRecords( + fm, + seedDummyFile(drive, 'a.txt', '1'.repeat(64), owner, actPublisher), + seedDummyFile(drive, 'b.txt', '2'.repeat(64), owner, actPublisher), + ); + + const downloadReadableDataSpy = jest.spyOn(Bee.prototype, 'downloadReadableData'); + + const results = await fm.downloadFolder(drive.id, '/'); + + expect(downloadReadableDataSpy).toHaveBeenCalledWith( + '1'.repeat(64), + { actHistoryAddress: SWARM_ZERO_ADDRESS.toString(), actPublisher }, + undefined, + ); + expect(downloadReadableDataSpy).toHaveBeenCalledWith( + '2'.repeat(64), + { actHistoryAddress: SWARM_ZERO_ADDRESS.toString(), actPublisher }, + undefined, + ); + + expect(downloadReadableDataSpy).toHaveBeenCalledTimes(2); + expect(results.succeeded.map((r) => r.path).sort()).toEqual(['a.txt', 'b.txt']); + }); + + it('downloadFolder does not download files belonging to a different drive', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const otherDrive = createMockDriveInfo(actPublisher, { id: Identifier.fromString('other-drive').toString() }); + seedRecords(fm, seedDummyFile(drive, 'mine.txt', '1'.repeat(64), owner, actPublisher)); + seedRecords(fm, seedDummyFile(otherDrive, 'not-mine.txt', '2'.repeat(64), owner, actPublisher)); + + const downloadReadableDataSpy = jest.spyOn(Bee.prototype, 'downloadReadableData'); + + const downloadResults = await fm.downloadFolder(drive.id, '/'); + + expect(downloadReadableDataSpy).toHaveBeenCalledTimes(1); + expect(downloadResults.failed).toEqual([]); + expect(downloadResults.succeeded).toHaveLength(1); + expect(downloadResults.succeeded[0].path).toBe('mine.txt'); + }); + }); + + describe('listFolder', () => { + it('returns shallow entries and hydrates newly discovered files exactly once', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + const topicA = Topic.fromString('list-a').toString(); + const topicB = Topic.fromString('list-b').toString(); + const entryA: NodeHeader = { path: 'a.txt', type: NodeType.File, topic: topicA, rawMetadata: {} }; + const entryB: NodeHeader = { path: 'b.txt', type: NodeType.File, topic: topicB, rawMetadata: {} }; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const { getAllNodeEntries } = require('@/utils/mantaray'); + getAllNodeEntries.mockReturnValue([entryA, entryB]); + + // b.txt is already hydrated -> must be skipped during this call + seedRecords(fm, { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + redundancyLevel: RedundancyLevel.OFF, + topic: topicB, + driveId: drive.id, + path: 'b.txt', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + }); + + // Clear the calls made by createInitializedFileManager()'s own bootstrap so the count below + // reflects only this listFolder() invocation. + (getFeedData as jest.Mock).mockClear(); + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + jest.spyOn(Bee.prototype, 'downloadData').mockResolvedValue( + Bytes.fromUtf8( + JSON.stringify({ + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + topic: topicA, + driveId: drive.id, + path: 'a.txt', + redundancyLevel: RedundancyLevel.OFF, + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + }), + ), + ); + + // listFolder now returns hydrated FileRecords (not raw headers): a.txt fetched, b.txt from cache. + const results = await fm.listFolder(drive.id, ''); + + expect(results).toHaveLength(2); + const byTopic = Object.fromEntries(results.map((r) => [r.topic, r])); + expect(byTopic[topicA].type).toBe(NodeType.File); + expect(byTopic[topicA].path).toBe('a.txt'); + expect(byTopic[topicB].path).toBe('b.txt'); + expect(fm.recordList.filter((f) => f.topic === topicA)).toHaveLength(1); + expect(fm.recordList.filter((f) => f.topic === topicB)).toHaveLength(1); + // Only a.txt triggers a feed lookup; b.txt is served from the cache. + expect(getFeedData).toHaveBeenCalledTimes(1); + }); + + it('throws when a segment of the folder path does not exist', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await expect(fm.listFolder(drive.id, 'missing-folder')).rejects.toThrow('Path not found: /missing-folder'); + }); + + it('stops expanding after maxDepth levels when depth is Deep', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + const folderTopic = Topic.fromString('sub-folder').toString(); + const folderEntry: NodeHeader = { path: 'sub', type: NodeType.Folder, topic: folderTopic, rawMetadata: {} }; + + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const { getAllNodeEntries } = require('@/utils/mantaray'); + getAllNodeEntries.mockReturnValue([folderEntry]); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + const results = await fm.listFolder(drive.id, '', ListDepth.Deep, 1); + + // Resolved into a hydrated FolderInfo; maxDepth=1 stops before recursing into it. + expect(results).toHaveLength(1); + expect(results[0].type).toBe(NodeType.Folder); + expect(results[0].topic).toBe(folderTopic); + expect(results[0].path).toBe('sub'); + }); + }); + + describe('createFolder', () => { + it('creates a new folder fork under the drive root and updates the drive manifestRef', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + const folderInfo = await fm.createFolder(drive.id, '', 'Documents'); + + expect(folderInfo.path).toBe('Documents'); + expect(folderInfo.driveId).toBe(drive.id); + + const updatedDrive = fm.driveList.find((d) => d.id === drive.id)!; + expect(updatedDrive.manifestRef).toBeDefined(); + + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + expect(driveMantaray.find('Documents')).toBeTruthy(); + }); + + it('builds a nested folder path without a leading slash', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await fm.createFolder(drive.id, '', 'Documents'); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + const nested = await fm.createFolder(drive.id, 'Documents', 'Reports'); + + expect(nested.path).toBe('Documents/Reports'); + }); + + it('emits FOLDER_CREATED with the created folder info', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FOLDER_CREATED, handler); + + const folderInfo = await fm.createFolder(drive.id, '', 'Documents'); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ folderInfo }); + }); + + it('throws on an invalid folder name containing a slash', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await expect(fm.createFolder(drive.id, '', 'a/b')).rejects.toThrow('Invalid folder name'); + }); + }); + + describe('move', () => { + it('refreshes trashed descendants overlay paths on a same-drive folder move', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + await fm.createFolder(drive.id, '', 'Docs'); + + const descendantTopic = Topic.fromString('doc-a').toString(); + drive.trashedNodes = [{ topic: descendantTopic, type: NodeType.File, path: 'Docs/a.txt' }]; + + await fm.move('Docs', 'Archive', drive.id); + + expect(drive.trashedNodes).toEqual([{ topic: descendantTopic, type: NodeType.File, path: 'Archive/a.txt' }]); + }); + + it('relocates trashed descendants to the target drive on a cross-drive folder move', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Target Drive'); + const source = fm.driveList[0]; + const target = fm.driveList[1]; + await fm.createFolder(source.id, '', 'Docs'); + + const descendantTopic = Topic.fromString('doc-a').toString(); + source.trashedNodes = [{ topic: descendantTopic, type: NodeType.File, path: 'Docs/a.txt' }]; + + await fm.move('Docs', 'Archive', source.id, target.id); + + expect(source.trashedNodes).toEqual([]); + expect(target.trashedNodes).toContainEqual({ + topic: descendantTopic, + type: NodeType.File, + path: 'Archive/a.txt', + }); + }); + + it('throws when trying to move the drive root', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await expect(fm.move('/', 'x.txt', drive.id)).rejects.toThrow('Cannot move root folder'); + }); + }); +}); diff --git a/tests/unit/init.spec.ts b/tests/unit/init.spec.ts new file mode 100644 index 0000000..595960c --- /dev/null +++ b/tests/unit/init.spec.ts @@ -0,0 +1,255 @@ +import { Bee } from '@ethersphere/bee-js'; + +import { BEE_URL, createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks, mockPostageBatch } from './mock'; + +import { EventEmitterBase } from '@/eventEmitter'; +import { FileManagerBase } from '@/fileManager'; +import { FileManagerEvents, SignerError } from '@/utils'; +import { ADMIN_STAMP_LABEL } from '@/utils/constants'; + +describe('Initialization and construction', () => { + beforeEach(async () => { + applyDefaultMocks(); + }); + + describe('constructor', () => { + it('should create new instance of FileManager', async () => { + const fm = await createInitializedFileManager(); + + expect(fm).toBeInstanceOf(FileManagerBase); + }); + + it('should throw error, if Signer is not provided', () => { + expect(() => new FileManagerBase(new Bee(BEE_URL))).toThrow(SignerError); + expect(() => new FileManagerBase(new Bee(BEE_URL))).toThrow('Signer required'); + }); + + it('should initialize FileManager instance with correct values', async () => { + const fm = await createInitializedFileManager(); + + expect(fm.recordList).toEqual([]); + }); + }); + + describe('initialize', () => { + it('should initialize FileManager', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const eventHandler = jest.fn(); + const emitter = new EventEmitterBase(); + emitter.on(FileManagerEvents.INITIALIZED, eventHandler); + await createInitializedFileManager(bee, undefined, emitter); + + expect(eventHandler).toHaveBeenCalledWith(true); + }); + + it('should not initialize, if already initialized', async () => { + const logSpy = jest.spyOn(console, 'debug'); + const eventHandler = jest.fn(); + const emitter = new EventEmitterBase(); + emitter.on(FileManagerEvents.INITIALIZED, eventHandler); + + const fm = await createInitializedFileManager( + new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }), + undefined, + emitter, + ); + expect(eventHandler).toHaveBeenCalledWith(true); + await fm.initialize(); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('FileManager is already initialized')); + }); + + it('should not initialize, if currently being initialized', async () => { + const logSpy = jest.spyOn(console, 'debug'); + const eventHandler = jest.fn(); + const emitter = new EventEmitterBase(); + emitter.on(FileManagerEvents.INITIALIZED, eventHandler); + + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const fm = new FileManagerBase(bee, emitter); + fm.initialize(); + fm.initialize(); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('FileManager is being initialized')); + }); + + it('does not eagerly load any file records — hydration is lazy', async () => { + const fm = await createInitializedFileManager(); + + expect(fm.driveList.length).toBeGreaterThan(0); + expect(fm.recordList).toHaveLength(0); + }); + }); + + describe('reinitialization', () => { + it('should emit STATE_INVALID when admin stamp becomes unusable during reinitialization', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + + const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); + getPostageBatchesSpy.mockResolvedValue([ + { + ...mockPostageBatch, + usable: true, + label: ADMIN_STAMP_LABEL, + }, + ]); + + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID, emitter); + expect(fm.adminStamp?.usable).toBe(true); + expect(fm.driveList).toHaveLength(1); + + let reinitFired = false; + emitter.on(FileManagerEvents.INITIALIZED, () => { + reinitFired = true; + }); + + await fm.initialize(); + expect(reinitFired).toBe(true); + expect(fm.driveList).toHaveLength(1); + + getPostageBatchesSpy.mockRestore(); + }); + + it('should successfully revalidate when admin stamp is still valid', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID, emitter); + const initialDrives = fm.driveList; + const initialFileCount = fm.recordList.length; + + let initEventFired = false; + let invalidEventFired = false; + + emitter.on(FileManagerEvents.INITIALIZED, (success: boolean) => { + if (success) { + initEventFired = true; + } + }); + + emitter.on(FileManagerEvents.STATE_INVALID, () => { + invalidEventFired = true; + }); + + await fm.initialize(); + + expect(initEventFired).toBe(true); + expect(invalidEventFired).toBe(false); + expect(fm.driveList).toEqual(initialDrives); + expect(fm.recordList).toHaveLength(initialFileCount); + }); + + it('should handle multiple sequential reinitializations with valid stamp', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID); + + const initialDriveCount = fm.driveList.length; + + for (let i = 0; i < 3; i++) { + await fm.initialize(); + expect(fm.driveList).toHaveLength(initialDriveCount); + } + }); + + it('should reset isInitialized flag when admin stamp becomes invalid', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + await createInitializedFileManager(bee, DUMMY_BATCH_ID); + + const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); + getPostageBatchesSpy.mockResolvedValue([ + { + ...mockPostageBatch, + usable: false, + label: ADMIN_STAMP_LABEL, + }, + ]); + + const newFm = new FileManagerBase(bee); + await newFm.initialize(); + + expect((newFm as any).isInitialized).toBe(true); + expect(newFm.driveList).toHaveLength(0); + expect(newFm.recordList).toHaveLength(0); + + getPostageBatchesSpy.mockRestore(); + }); + + it('should maintain isInitialized flag after successful reinitialization', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID); + + expect((fm as any).isInitialized).toBe(true); + + await fm.initialize(); + + expect((fm as any).isInitialized).toBe(true); + }); + + it('should not clear drives when reinitializing with valid stamp', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID); + + const drivesBefore = fm.driveList; + expect(drivesBefore.length).toBeGreaterThan(0); + + await fm.initialize(); + + const drivesAfter = fm.driveList; + expect(drivesAfter).toEqual(drivesBefore); + }); + + it('should maintain admin stamp reference after reinitialization', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID); + + const adminStampBefore = fm.adminStamp; + expect(adminStampBefore).toBeDefined(); + + await fm.initialize(); + + const adminStampAfter = fm.adminStamp; + expect(adminStampAfter).toBeDefined(); + expect(adminStampAfter?.batchID.toString()).toBe(adminStampBefore?.batchID.toString()); + }); + + it('should clear recordList when admin stamp becomes invalid', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + await createInitializedFileManager(bee, DUMMY_BATCH_ID); + + const getPostageBatchesSpy = jest.spyOn(Bee.prototype, 'getPostageBatches'); + getPostageBatchesSpy.mockResolvedValue([ + { + ...mockPostageBatch, + usable: false, + label: ADMIN_STAMP_LABEL, + }, + ]); + + const newFm = new FileManagerBase(bee); + await newFm.initialize(); + + expect(newFm.recordList).toHaveLength(0); + expect(newFm.driveList).toHaveLength(0); + + getPostageBatchesSpy.mockRestore(); + }); + + it('should not emit STATE_INVALID when admin stamp remains valid', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID, emitter); + + let invalidEventFired = false; + emitter.on(FileManagerEvents.STATE_INVALID, () => { + invalidEventFired = true; + }); + + await fm.initialize(); + + expect(invalidEventFired).toBe(false); + }); + }); +}); diff --git a/tests/mockHelpers.ts b/tests/unit/mock.ts similarity index 67% rename from tests/mockHelpers.ts rename to tests/unit/mock.ts index c3aa5af..6faec01 100644 --- a/tests/mockHelpers.ts +++ b/tests/unit/mock.ts @@ -5,6 +5,7 @@ import { Bytes, Duration, EthAddress, + FeedIndex, FeedReader, FeedWriter, Identifier, @@ -22,15 +23,13 @@ import { } from '@ethersphere/bee-js'; import { Optional } from 'cafe-utility'; -import { BEE_URL, DEFAULT_MOCK_SIGNER } from './utils'; +import { DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; -import { EventEmitter } from '@/eventEmitter/eventEmitter'; import { FileManagerBase } from '@/fileManager'; -import { DriveInfo, FileInfo } from '@/types'; -import { FileManagerEvents } from '@/utils'; -import { ADMIN_STAMP_LABEL, SWARM_ZERO_ADDRESS } from '@/utils/constants'; - -export const MOCK_BATCH_ID = 'ee0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51'; +import { DriveInfo, FileRecord, NodeType } from '@/types'; +import { fetchStamp, getFeedData } from '@/utils/bee'; +import { ADMIN_STAMP_LABEL, FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { getAllNodeEntries, loadMantaray } from '@/utils/mantaray'; export function createMockMantarayNode(all = true): MantarayNode { const mn = new MantarayNode(); @@ -46,30 +45,6 @@ export function createMockMantarayNode(all = true): MantarayNode { return mn; } -export async function createInitializedFileManager( - bee: Bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }), - batchId?: string | BatchId, - emitter?: EventEmitter, -): Promise { - const fm = new FileManagerBase(bee, emitter); - - let isFirstInit = true; - fm.emitter.on(FileManagerEvents.INITIALIZED, (ok: boolean) => { - if (isFirstInit) { - expect(ok).toBe(true); - isFirstInit = false; - } - }); - - await fm.initialize(); - - if (!fm.driveList.some((d) => d.isAdmin)) { - await fm.createDrive(batchId ?? MOCK_BATCH_ID, ADMIN_STAMP_LABEL, true, RedundancyLevel.MEDIUM); - } - - return fm; -} - export function createMockNodeAddresses(): NodeAddresses { return { overlay: new PeerAddress('1'.repeat(64)), @@ -84,34 +59,41 @@ export async function createMockFileInfo( owner: string, actPublisher: string, ref: string = SWARM_ZERO_ADDRESS.toString(), -): Promise { + overrides?: Partial, +): Promise { return { - batchId: MOCK_BATCH_ID, - name: 'john doe', - topic: Topic.fromString('1'), + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + path: '/john doe', + topic: Topic.fromString('file-1').toString(), driveId: Identifier.fromString('123').toString(), - owner: owner, + owner, actPublisher, - file: { + content: { reference: ref, historyRef: SWARM_ZERO_ADDRESS.toString(), }, + redundancyLevel: RedundancyLevel.OFF, + ...overrides, }; } -export function createMockDriveInfo(): DriveInfo { +export function createMockDriveInfo(actPublisher: string, overrides?: Partial): DriveInfo { return { - id: Identifier.fromString('123'), - batchId: MOCK_BATCH_ID, + type: NodeType.Drive, + id: Identifier.fromString('123').toString(), + batchId: DUMMY_BATCH_ID, owner: DEFAULT_MOCK_SIGNER.publicKey().address().toString(), name: 'Test Drive', + topic: Topic.fromString('drive-topic-1').toString(), redundancyLevel: RedundancyLevel.MEDIUM, - infoFeedList: [ - { - topic: Topic.fromString('1'), - }, - ], + manifestRef: { + reference: new Reference('1'.repeat(64)).toString(), + historyRef: new Reference('2'.repeat(64)).toString(), + }, isAdmin: false, + actPublisher, + ...overrides, }; } @@ -151,6 +133,15 @@ export function createInitMocks(data?: Reference): any { jest.spyOn(Bee.prototype, 'getNodeAddresses').mockResolvedValue(createMockNodeAddresses()); loadStampListMock(); jest.spyOn(Bee.prototype, 'downloadData').mockResolvedValue(new Bytes(data || SWARM_ZERO_ADDRESS)); + jest.spyOn(Bee.prototype, 'downloadFile').mockResolvedValue({ data: new Bytes(SWARM_ZERO_ADDRESS) }); + jest.spyOn(Bee.prototype, 'downloadReadableData').mockResolvedValue( + new ReadableStream({ + start(controller) { + controller.enqueue((data || SWARM_ZERO_ADDRESS).toUint8Array()); + controller.close(); + }, + }), + ); jest.spyOn(Bee.prototype, 'uploadData').mockResolvedValue({ reference: data || SWARM_ZERO_ADDRESS, historyAddress: Optional.of(data || SWARM_ZERO_ADDRESS), @@ -160,20 +151,6 @@ export function createInitMocks(data?: Reference): any { jest.spyOn(Bee.prototype, 'getPostageBatches').mockResolvedValue(loadStampListMock()); } -export function createUploadFilesFromDirectorySpy(char: string): jest.SpyInstance { - return jest.spyOn(Bee.prototype, 'uploadFilesFromDirectory').mockResolvedValueOnce({ - reference: new Reference(char.repeat(64)), - historyAddress: Optional.of(SWARM_ZERO_ADDRESS), - }); -} - -export function createUploadFileSpy(char: string): jest.SpyInstance { - return jest.spyOn(Bee.prototype, 'uploadFile').mockResolvedValueOnce({ - reference: new Reference(char.repeat(64)), - historyAddress: Optional.of(SWARM_ZERO_ADDRESS), - }); -} - export function createUploadDataSpy(char: string): jest.SpyInstance { return jest.spyOn(Bee.prototype, 'uploadData').mockResolvedValueOnce({ reference: new Reference(char.repeat(64)), @@ -182,7 +159,7 @@ export function createUploadDataSpy(char: string): jest.SpyInstance { } export const mockPostageBatch: PostageBatch = { - batchID: new BatchId(MOCK_BATCH_ID), + batchID: new BatchId(DUMMY_BATCH_ID), utilization: 2, usable: true, usageText: '2%', @@ -246,3 +223,53 @@ export function loadStampListMock(): PostageBatch[] { }, ]; } + +export type SeedableFm = { _recordList: FileRecord[] }; +export const seedRecords = (fm: FileManagerBase, ...records: FileRecord[]): void => { + (fm as unknown as SeedableFm)._recordList.push(...records); +}; + +export function applyDefaultMocks(): void { + jest.resetAllMocks(); + createInitMocks(); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.MINUS_ONE, + feedIndexNext: FEED_INDEX_ZERO, + payload: { + toUint8Array: () => SWARM_ZERO_ADDRESS.toUint8Array(), + toJSON: () => ({ + reference: SWARM_ZERO_ADDRESS.toString(), + historyRef: SWARM_ZERO_ADDRESS.toString(), + }), + }, + }); + + (fetchStamp as jest.Mock).mockResolvedValue({ ...mockPostageBatch }); + + (loadMantaray as jest.Mock).mockResolvedValue(new MantarayNode()); + (getAllNodeEntries as jest.Mock).mockReturnValue([]); +} + +export const seedDummyFile = ( + drive: DriveInfo, + path: string, + ref: string, + owner: string, + actPublisher: string, +): FileRecord => { + return { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + topic: Topic.fromString(`dl-${path}`).toString(), + driveId: drive.id, + path, + content: { + reference: ref, + historyRef: SWARM_ZERO_ADDRESS.toString(), + }, + redundancyLevel: RedundancyLevel.OFF, + }; +}; diff --git a/tests/unit/setup.ts b/tests/unit/setup.ts new file mode 100644 index 0000000..f627748 --- /dev/null +++ b/tests/unit/setup.ts @@ -0,0 +1,13 @@ +jest.mock('@/utils/bee', () => ({ + ...jest.requireActual('@/utils/bee'), + getFeedData: jest.fn(), + fetchStamp: jest.fn(), +})); + +jest.mock('@/utils/mantaray', () => ({ + ...jest.requireActual('@/utils/mantaray'), + loadMantaray: jest.fn(), + getAllNodeEntries: jest.fn(), +})); + +export {}; diff --git a/tests/unit/trash.spec.ts b/tests/unit/trash.spec.ts new file mode 100644 index 0000000..68fff54 --- /dev/null +++ b/tests/unit/trash.spec.ts @@ -0,0 +1,223 @@ +import { Bytes, FeedIndex, Identifier, MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks, createMockNodeAddresses, seedRecords } from './mock'; + +import { FileManagerBase } from '@/fileManager'; +import { DriveInfo, FileRecord, FolderInfo, NodeStatus, NodeType } from '@/types'; +import { FileManagerEvents } from '@/utils'; +import { getFeedData } from '@/utils/bee'; +import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; + +describe('Lifecycle management', () => { + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); + + let fm: FileManagerBase; + let drive: DriveInfo; + let fileRecord: FileRecord; + + beforeEach(async () => { + applyDefaultMocks(); + + fm = await createInitializedFileManager(); + drive = fm.driveList[0]; + await fm.uploadFile(drive.id, { path: 'notes.txt', sourcePath: 'package.json' }); + fileRecord = fm.recordList.find((f) => f.path === 'notes.txt')!; + }); + + describe('trashFile', () => { + it('records the file in the drive trash overlay without a version bump, and emits FILE_TRASHED', async () => { + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FILE_TRASHED, handler); + const versionBefore = fileRecord.version; + + await fm.trashFile(fileRecord); + + expect(fileRecord.status).toBe(NodeStatus.Trashed); + expect(fileRecord.version).toBe(versionBefore); + expect(drive.trashedNodes).toEqual([ + { topic: fileRecord.topic, type: NodeType.File, path: fileRecord.path, version: versionBefore }, + ]); + expect(handler).toHaveBeenCalledWith({ record: fileRecord }); + }); + + it('throws if the file is already trashed', async () => { + await fm.trashFile(fileRecord); + await expect(fm.trashFile(fileRecord)).rejects.toThrow(`Already trashed: ${fileRecord.path}`); + }); + + it('trashFile throws when the drive is not found', async () => { + const ghost: FileRecord = { ...fileRecord, driveId: Identifier.fromString('ghost-drive').toString() }; + await expect(fm.trashFile(ghost)).rejects.toThrow(`Drive with id ${ghost.driveId!.slice(0, 6)} not found`); + }); + }); + + describe('recoverFile', () => { + it('removes the file from the overlay and emits FILE_RECOVERED', async () => { + await fm.trashFile(fileRecord); + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FILE_RECOVERED, handler); + + await fm.recoverFile(fileRecord); + + expect(fileRecord.status).toBe(NodeStatus.Active); + expect(drive.trashedNodes).toEqual([]); + expect(handler).toHaveBeenCalledWith({ record: fileRecord }); + }); + + it('throws if the file was never trashed', async () => { + await expect(fm.recoverFile(fileRecord)).rejects.toThrow(`Not trashed, cannot recover: ${fileRecord.path}`); + }); + }); + + describe('recoverFolder', () => { + it('removes the folder from the overlay and emits FOLDER_RECOVERED', async () => { + const folder: FolderInfo = { + type: NodeType.Folder, + owner, + actPublisher, + topic: Topic.fromString('docs-folder').toString(), + driveId: drive.id, + path: 'Docs', + batchId: DUMMY_BATCH_ID, + redundancyLevel: RedundancyLevel.OFF, + }; + + await fm.trashFolder(folder); + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FOLDER_RECOVERED, handler); + + await fm.recoverFolder(folder); + + expect(folder.status).toBe(NodeStatus.Active); + expect(drive.trashedNodes).toEqual([]); + expect(handler).toHaveBeenCalledWith({ folder }); + }); + }); + + describe('trashFolder', () => { + it('records a folder in the overlay and emits FOLDER_TRASHED', async () => { + const folder: FolderInfo = { + type: NodeType.Folder, + owner, + actPublisher, + topic: Topic.fromString('docs-folder').toString(), + driveId: drive.id, + path: 'Docs', + batchId: DUMMY_BATCH_ID, + redundancyLevel: RedundancyLevel.OFF, + }; + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FOLDER_TRASHED, handler); + + await fm.trashFolder(folder); + + expect(folder.status).toBe(NodeStatus.Trashed); + expect(drive.trashedNodes).toContainEqual({ topic: folder.topic, type: NodeType.Folder, path: folder.path }); + expect(handler).toHaveBeenCalledWith({ folder }); + }); + }); + + describe('listTrash', () => { + it('hydrates the overlay into trashed NodeEntries', async () => { + await fm.trashFile(fileRecord); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: new Bytes(SWARM_ZERO_ADDRESS.toUint8Array()), + }); + const spyFetch = jest + .spyOn((fm as any).store, 'getRecord') + .mockResolvedValue({ ...fileRecord, status: undefined }); + + const trashed = await fm.listTrash(drive.id); + + expect(trashed).toHaveLength(1); + expect(trashed[0].topic).toBe(fileRecord.topic); + expect(trashed[0].status).toBe(NodeStatus.Trashed); + expect(trashed[0].path).toBe(fileRecord.path); + + spyFetch.mockRestore(); + }); + }); + + describe('forget', () => { + it('throws when attempting to forget the drive root', async () => { + await expect(fm.forget(drive.id, '/')).rejects.toThrow('Cannot forget drive root'); + await expect(fm.forget(drive.id, '')).rejects.toThrow('Cannot forget drive root'); + }); + + it('removes a file fork and its recordList entry, emitting FILE_FORGOTTEN', async () => { + await fm.uploadFile(drive.id, { path: 'package.json', sourcePath: 'package.json' }); + const uploaded = fm.recordList.find((f) => f.path === 'package.json')!; + expect(uploaded).toBeDefined(); + + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FILE_FORGOTTEN, handler); + + await fm.forget(drive.id, 'package.json'); + + expect(fm.recordList.find((f) => f.path === 'package.json')).toBeUndefined(); + expect(handler).toHaveBeenCalledWith({ record: uploaded, path: 'package.json' }); + + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + expect(driveMantaray.find('package.json')).toBeFalsy(); + }); + + it('removes a folder fork and purges all descendant recordList entries', async () => { + await fm.createFolder(drive.id, '', 'Docs'); + + seedRecords(fm, { + type: NodeType.File, + batchId: DUMMY_BATCH_ID, + owner, + actPublisher, + topic: Topic.fromString('doc-a').toString(), + driveId: drive.id, + path: 'Docs/a.txt', + content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, + redundancyLevel: RedundancyLevel.OFF, + }); + + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FOLDER_FORGOTTEN, handler); + + await fm.forget(drive.id, 'Docs'); + + expect(fm.recordList.some((f) => f.path.startsWith('Docs/'))).toBe(false); + expect(handler).toHaveBeenCalledWith({ driveInfo: drive, path: 'Docs' }); + }); + + it('forgets only the targeted file when a same-named file exists in another folder', async () => { + await fm.createFolder(drive.id, '', 'A'); + await fm.createFolder(drive.id, '', 'B'); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + await fm.uploadFile(drive.id, { path: 'A/dup.txt', sourcePath: 'package.json' }); + await fm.uploadFile(drive.id, { path: 'B/dup.txt', sourcePath: 'package.json' }); + + const inB = fm.recordList.find((f) => f.path === 'B/dup.txt')!; + expect(fm.recordList.find((f) => f.path === 'A/dup.txt')).toBeDefined(); + expect(inB).toBeDefined(); + + await fm.trashFile(inB); + expect(drive.trashedNodes?.some((n) => n.path === 'B/dup.txt')).toBe(true); + + await fm.forget(drive.id, 'A/dup.txt'); + + expect(fm.recordList.find((f) => f.path === 'A/dup.txt')).toBeUndefined(); + expect(fm.recordList.find((f) => f.path === 'B/dup.txt')).toBeDefined(); + expect(drive.trashedNodes?.some((n) => n.path === 'B/dup.txt')).toBe(true); + }); + }); +}); diff --git a/tests/unit/version.spec.ts b/tests/unit/version.spec.ts new file mode 100644 index 0000000..f52de58 --- /dev/null +++ b/tests/unit/version.spec.ts @@ -0,0 +1,134 @@ +import { Bytes, FeedIndex, Identifier, PublicKey, RedundancyLevel, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; + +import { applyDefaultMocks, createMockNodeAddresses, seedRecords } from './mock'; + +import { FileManagerBase } from '@/fileManager'; +import { FileRecord, NodeType } from '@/types'; +import { FeedResultWithIndex } from '@/types/utils'; +import { FileManagerEvents } from '@/utils'; +import { getFeedData } from '@/utils/bee'; +import { FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; + +describe('Version control', () => { + const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); + const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); + let fm: FileManagerBase; + + const dummyTopic = Topic.fromString('deadbeef').toString(); + const dummyFi: FileRecord = { + type: NodeType.File, + topic: dummyTopic, + content: { historyRef: SWARM_ZERO_ADDRESS.toString(), reference: SWARM_ZERO_ADDRESS.toString() }, + owner, + batchId: DUMMY_BATCH_ID, + driveId: Identifier.fromString('version-drive').toString(), + path: 'x.txt', + actPublisher, + version: FeedIndex.fromBigInt(0n).toString(), + redundancyLevel: RedundancyLevel.OFF, + }; + + beforeEach(async () => { + applyDefaultMocks(); + + fm = await createInitializedFileManager(); + }); + + describe('getFileVersion', () => { + it('calls store.getRecord with the topic and compressed actPublisher', async () => { + const fakeFi = { ...dummyFi, version: '1' }; + + const rawMock: FeedResultWithIndex = { + feedIndex: FeedIndex.fromBigInt(1n), + feedIndexNext: FeedIndex.fromBigInt(2n), + payload: new Bytes(SWARM_ZERO_ADDRESS.toUint8Array()), + }; + (getFeedData as jest.Mock).mockResolvedValue(rawMock); + + const spyFetch = jest.spyOn((fm as any).store, 'getRecord').mockResolvedValue(fakeFi); + + const got = await fm.getFileVersion(dummyFi, FeedIndex.fromBigInt(1n)); + + expect(spyFetch).toHaveBeenCalledWith( + dummyFi.topic, + new PublicKey(actPublisher).toCompressedHex(), + rawMock, + undefined, + ); + expect(got).toBe(fakeFi); + + spyFetch.mockRestore(); + }); + + it('returns the cached head without a feed lookup when the requested version matches', async () => { + const cachedVersion = FeedIndex.fromBigInt(5n).toString(); + seedRecords(fm, { ...dummyFi, version: cachedVersion }); + + // Clear calls made by createInitializedFileManager()'s own bootstrap in the outer beforeEach. + (getFeedData as jest.Mock).mockClear(); + + const got = await fm.getFileVersion(dummyFi, FeedIndex.fromBigInt(5n)); + + expect(got.version).toBe(cachedVersion); + expect(getFeedData).not.toHaveBeenCalled(); + }); + + it('throws if the underlying feed is missing', async () => { + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.MINUS_ONE, + feedIndexNext: FEED_INDEX_ZERO, + payload: SWARM_ZERO_ADDRESS, + }); + + await expect(fm.getFileVersion(dummyFi)).rejects.toThrow( + `File feed not found for topic: ${dummyFi.topic.slice(0, 6)}`, + ); + }); + }); + + describe('restoreFileVersion', () => { + it('restoring the current head is a no-op and throws', async () => { + const head = FeedIndex.fromBigInt(5n); + const headFi = { ...dummyFi, driveId: fm.driveList[0].id, version: head.toString() }; + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: head, + feedIndexNext: FeedIndex.fromBigInt(6n), + payload: SWARM_ZERO_ADDRESS, + }); + + const spyEmit = jest.spyOn(fm.emitter, 'emit'); + + await expect(fm.restoreFileVersion(headFi)).rejects.toThrow( + `Head Slot cannot be restored. Please select a version lesser than: ${head.toString()}`, + ); + + expect(spyEmit).not.toHaveBeenCalledWith(FileManagerEvents.FILE_VERSION_RESTORED, expect.anything()); + }); + + it('throws when the underlying feed is missing', async () => { + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.MINUS_ONE, + feedIndexNext: FEED_INDEX_ZERO, + payload: SWARM_ZERO_ADDRESS, + }); + + await expect(fm.restoreFileVersion({ ...dummyFi, driveId: fm.driveList[0].id, version: '2' })).rejects.toThrow( + 'Record feed not found', + ); + }); + + it('fails fast when the drive is not found, without touching the feed', async () => { + (getFeedData as jest.Mock).mockClear(); + + expect(dummyFi).toBeDefined(); + await expect(fm.restoreFileVersion({ ...dummyFi, version: '2' })).rejects.toThrow( + `Drive with id ${dummyFi.driveId!.slice(0, 6)} not found`, + ); + + expect(getFeedData).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/tests/utils.ts b/tests/utils.ts index c9d9947..639d74c 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,17 +1,20 @@ -import { BatchId, Bee, Bytes, MantarayNode, PrivateKey } from '@ethersphere/bee-js'; +import { BatchId, Bee, BeeRequestOptions, PrivateKey, RedundancyLevel } from '@ethersphere/bee-js'; import * as fs from 'fs'; import path from 'path'; -import { FileInfo, FileManager } from '@/types'; -import { ReferenceWithHistory, WrappedUploadResult } from '@/types/utils'; -import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { EventEmitter } from '@/eventEmitter'; +import { FileManagerBase } from '@/fileManager'; +import { FileManagerEvents } from '@/utils'; +// bee-factory queen node export const BEE_URL = 'http://127.0.0.1:1633'; -export const OTHER_BEE_URL = 'http://127.0.0.1:1733'; +// bee-factory worker 1 — a non-admin peer +export const OTHER_BEE_URL = 'http://127.0.0.1:1635'; export const DEFAULT_BATCH_DEPTH = 21; export const DEFAULT_BATCH_AMOUNT = '500000000'; export const DEFAULT_MOCK_SIGNER = new PrivateKey('634fb5a872396d9693e5c9f9d7233cfa93f395c093371017ff44aa9ae6564cdd'); export const OTHER_MOCK_SIGNER = new PrivateKey('734fb5a872396d9693e5c9f9d7233cfa93f395c093371017ff44aa9ae6564cd7'); +export const DUMMY_BATCH_ID = 'ee0fec26fdd55a1b8a777cc8c84277a1b16a7da318413fbd4cc4634dd93a2c51'; export function getTestFile(relativePath: string): string { return fs.readFileSync(path.resolve(__dirname, relativePath), 'utf-8'); @@ -45,36 +48,93 @@ export async function readFilesOrDirectory(fullPath: string, name?: string): Pro return relativeFilePaths; } -export async function dowloadAndCompareFiles( - fileManager: FileManager, - publicKey: string, - fiList: FileInfo[], - expArr: string[][], -): Promise { - if (fiList.length !== expArr.length) { - expect(fiList).toHaveLength(expArr.length); - return; +export async function streamToUint8Array(stream: ReadableStream): Promise { + const buffer = await new Response(stream).arrayBuffer(); + + return new Uint8Array(buffer); +} + +export async function retryOnPropagationDelay(fn: () => Promise, attempts = 5, delayMs = 500): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await fn(); + } catch (err: unknown) { + lastError = err; + if (i < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + } } + throw lastError; +} + +async function buyStamp( + bee: Bee, + amount: string | bigint, + depth: number, + label?: string, + requestOptions?: BeeRequestOptions, +): Promise { + const stamp = (await bee.getPostageBatches(requestOptions)).find((b) => b.label === label); + if (stamp && stamp.usable) { + return stamp.batchID; + } + + return await bee.createPostageBatch(amount, depth, { + waitForUsable: true, + label, + }); +} + +// Stamp creation is an on-chain op; a Bee node rejects simultaneous ones +const ON_CHAIN_BUSY = /simultaneous on-chain operations|too many requests|\b429\b/i; - for (const [ix, fi] of fiList.entries()) { - const fetchedFiles = (await fileManager.download(fi, undefined, { - actHistoryAddress: fi.file.historyRef, - actPublisher: publicKey, - })) as Bytes[]; - const fetchedFilesStrings = fetchedFiles.map((f) => f.toUtf8()); - expect(expArr[ix]).toEqual(fetchedFilesStrings); +export async function buyStampSerialized( + bee: Bee, + amount: string | bigint, + depth: number, + label?: string, + requestOptions?: BeeRequestOptions, + attempts = 20, +): Promise { + let lastError: unknown; + for (let i = 0; i < attempts; i++) { + try { + return await buyStamp(bee, amount, depth, label, requestOptions); + } catch (err: unknown) { + lastError = err; + const haystack = `${(err as any)?.message ?? ''} ${(err as any)?.status ?? ''} ${(err as any)?.code ?? ''}`; + if (i === attempts - 1 || !ON_CHAIN_BUSY.test(haystack)) { + throw err; + } + const base = 500 * (i + 1); + await new Promise((resolve) => setTimeout(resolve, base + Math.floor(Math.random() * base))); + } } + throw lastError; } -export async function createWrappedData(bee: Bee, batchId: BatchId, node: MantarayNode): Promise { - const manatarayResult = await node.saveRecursively(bee, batchId); - const wrappedData: WrappedUploadResult = { - uploadFilesRes: manatarayResult.reference.toString(), - uploadPreviewRes: SWARM_ZERO_ADDRESS.toString(), - }; - const wrappedRes = await bee.uploadData(batchId, JSON.stringify(wrappedData), { act: true }); - return { - reference: wrappedRes.reference.toString(), - historyRef: wrappedRes.historyAddress.getOrThrow().toString(), - }; +export async function createInitializedFileManager( + bee: Bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }), + batchId?: string | BatchId, + emitter?: EventEmitter, +): Promise { + const fm = new FileManagerBase(bee, emitter); + + let isFirstInit = true; + fm.emitter.on(FileManagerEvents.INITIALIZED, (ok: boolean) => { + if (isFirstInit) { + expect(ok).toBe(true); + isFirstInit = false; + } + }); + + await fm.initialize(); + + if (!fm.driveList.some((d) => d.isAdmin)) { + await fm.createAdminDrive(batchId ?? DUMMY_BATCH_ID, RedundancyLevel.MEDIUM); + } + + return fm; } diff --git a/tsconfig.json b/tsconfig.json index b3e4aa9..c4c0fb4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,11 @@ { - "include": ["src"], - "exclude": ["node_modules", "dist", "tests"], // TODO: reintroduce tests once v2 is in place + "include": ["src", "tests"], + "exclude": ["node_modules", "dist"], "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ES2022", "moduleResolution": "node", - "ignoreDeprecations": "5.0", // TODO: resolve deprication errors "resolveJsonModule": true, "declaration": true, "emitDeclarationOnly": false, @@ -22,7 +21,7 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "strictPropertyInitialization": true, - "isolatedModules": true, + "isolatedModules": false, "allowSyntheticDefaultImports": true, "noImplicitAny": true, "typeRoots": ["src/@types", "node_modules/@types"],