diff --git a/src/backend/src/filesystem/hl_operations/hl_move.js b/src/backend/src/filesystem/hl_operations/hl_move.js index 340fe089c4..ecbe659421 100644 --- a/src/backend/src/filesystem/hl_operations/hl_move.js +++ b/src/backend/src/filesystem/hl_operations/hl_move.js @@ -147,7 +147,7 @@ class HLMove extends HLFilesystemOperation { if ( await dest.exists() ) { if ( ! values.overwrite && ! values.dedupe_name ) { throw APIError.create('item_with_same_name_exists', null, { - entry_name: target_name, + entry_name: await dest.get('name'), }); } diff --git a/src/backend/src/filesystem/hl_operations/hl_stat.js b/src/backend/src/filesystem/hl_operations/hl_stat.js index 809620c4b5..53419aea3b 100644 --- a/src/backend/src/filesystem/hl_operations/hl_stat.js +++ b/src/backend/src/filesystem/hl_operations/hl_stat.js @@ -29,7 +29,8 @@ class HLStat extends HLFilesystemOperation { const { subject, user, return_subdomains, - return_permissions, + return_permissions, // Deprecated: kept for backwards compatiable with `return_shares` + return_shares, return_versions, return_size, } = this.values; @@ -55,7 +56,9 @@ class HLStat extends HLFilesystemOperation { if (return_size) await subject.fetchSize(user); if (return_subdomains) await subject.fetchSubdomains(user) - if (return_permissions) await subject.fetchShares(); + if (return_shares || return_permissions) { + await subject.fetchShares(); + } if (return_versions) await subject.fetchVersions(); await subject.fetchIsEmpty(); diff --git a/src/backend/src/routers/filesystem_api/stat.js b/src/backend/src/routers/filesystem_api/stat.js index 6680714b78..8c0501b054 100644 --- a/src/backend/src/routers/filesystem_api/stat.js +++ b/src/backend/src/routers/filesystem_api/stat.js @@ -43,6 +43,7 @@ module.exports = eggspress('/stat', { user: req.user, return_subdomains: req.body.return_subdomains, return_permissions: req.body.return_permissions, + return_shares: req.body.return_shares, return_versions: req.body.return_versions, return_size: req.body.return_size, }); diff --git a/tools/api-tester/README.md b/tools/api-tester/README.md index 31616b0480..95d4eaebf5 100644 --- a/tools/api-tester/README.md +++ b/tools/api-tester/README.md @@ -1,5 +1,130 @@ -## It takes 3 steps to run the tests :) +# API Tester -1. run `npm install` -2. copy `example_config.yml` and add the correct values -3. run `node apitest.js --config=your_config_file.yml` +A test framework for testing the backend API of puter. + +## Table of Contents + +- [API Tester](#api-tester) +- [How to use](#how-to-use) + - [Workflow](#workflow) + - [Shorthands](#shorthands) +- [Basic Concepts](#basic-concepts) +- [Behaviors](#behaviors) + - [Isolation of `t.cwd`](#isolation-of-t-cwd) +- [Implementation](#implementation) +- [TODO](#todo) + +## How to use + +### Workflow + +All commands below should be run from the root directory of puter. + +1. (Optional) Start a backend server: + + ```bash + npm start + ``` + +2. Copy `example_config.yml` and add the correct values: + + ```bash + cp ./tools/api-tester/example_config.yml ./tools/api-tester/config.yml + ``` + + Fields: + - url: The endpoint of the backend server. (default: http://api.puter.localhost:4100/) + - username: The username of the admin user. (e.g. admin) + - token: The token of the user. (can be obtained by typing `puter.authToken` in Developer Tools's console) + +3. Run the tests: + + ```bash + node ./tools/api-tester/apitest.js --config=./tools/api-tester/config.yml + ``` + +### Shorthands + +- Run unit tests only: + + ```bash + node ./tools/api-tester/apitest.js --config=./tools/api-tester/config.yml --unit + ``` + +- Filter tests by suite name: + + ```bash + node ./tools/api-tester/apitest.js --config=./tools/api-tester/config.yml --unit --suite=mkdir + ``` + +- Rerun failed tests in the last run: + + ```bash + node ./tools/api-tester/apitest.js --config=./tools/api-tester/config.yml --rerun-failed + ``` + +## Basic Concepts + +A *test case* is a function that tests a specific behavior of the backend API. Test cases can be nested: + +```js +await t.case('normal mkdir', async () => { + const result = await t.mkdir_v2('foo'); + expect(result.name).equal('foo'); + + await t.case('can stat the created directory', async () => { + const stat = await t.stat('foo'); + expect(stat.name).equal('foo'); + }); +}); +``` + +A *test suite* is a collection of test cases. A `.js` file should contain exactly one test suite. + +```js +module.exports = { + name: 'mkdir', + do: async t => { + await t.case('normal mkdir', async () => { + ... + }); + + await t.case('recursive mkdir', async () => { + ... + }); + } +}; +``` + +## Behaviors + +### Isolation of `t.cwd` + +- `t.cwd` is reset at the beginning of each test suite, since a test suite usually doesn't want to be affected by other test suites. +- `t.cwd` will be inherited from the cases in the same test suite, since a leaf case might want to share the context with its parent/sibling cases. + +```js +module.exports = { + name: 'readdir', + do: async t => { + // t.cwd is reset to /admin/api_test + + await t.case('normal mkdir', async () => { + // inherits cwd from parent/sibling cases + + await t.case('mkdir in subdir', async () => { + // inherits cwd from parent/sibling cases + }); + }); + } +}; +``` + +## Implementation + +- Test suites are registered in `tools/api-tester/tests/__entry__.js`. + +## TODO + +- [ ] Update usage of apitest.js. (Is it possible to generate the usage automatically?) +- [ ] Integrate it into CI, optionally running it only in specific scenarios (e.g., when backend code changes). diff --git a/tools/api-tester/apitest.js b/tools/api-tester/apitest.js index 4675954104..b5dcb9d734 100644 --- a/tools/api-tester/apitest.js +++ b/tools/api-tester/apitest.js @@ -9,7 +9,7 @@ const { parseArgs } = require('node:util'); const args = process.argv.slice(2); -let config, report; +let config, report, suiteName; try { const parsed = parseArgs({ @@ -23,6 +23,7 @@ try { onlycase: { type: 'string' }, bench: { type: 'boolean' }, unit: { type: 'boolean' }, + suite: { type: 'string' }, }, allowPositionals: true, }); @@ -33,9 +34,12 @@ try { onlycase, bench, unit, + suite: suiteName, }, positionals: [id] } = parsed); onlycase = onlycase !== undefined ? Number.parseInt(onlycase) : undefined; + // Ensure suiteName is a string or undefined + suiteName = suiteName || undefined; } catch (e) { console.error(e); console.error( @@ -44,6 +48,7 @@ try { 'Options:\n' + ' --config= (required) Path to configuration file\n' + ' --report= (optional) Output file for full test results\n' + + ' --suite= (optional) Run only tests with matching suite name\n' + '' ); process.exit(1); @@ -56,6 +61,7 @@ const main = async () => { const context = { options: { onlycase, + suite: suiteName, } }; const ts = new TestSDK(conf, context); @@ -87,7 +93,7 @@ const main = async () => { } if ( unit ) { - await registry.run_all_tests(); + await registry.run_all_tests(suiteName); } else if ( bench ) { await registry.run_all_benches(); } else { diff --git a/tools/api-tester/lib/TestRegistry.js b/tools/api-tester/lib/TestRegistry.js index d336e2cdd6..5091286a8f 100644 --- a/tools/api-tester/lib/TestRegistry.js +++ b/tools/api-tester/lib/TestRegistry.js @@ -18,8 +18,12 @@ module.exports = class TestRegistry { this.benches[id] = benchDefinition; } - async run_all_tests () { + async run_all_tests(suiteName) { for ( const id in this.tests ) { + if (suiteName && id !== suiteName) { + continue; + } + const testDefinition = this.tests[id]; await this.t.runTestPackage(testDefinition); } diff --git a/tools/api-tester/lib/TestSDK.js b/tools/api-tester/lib/TestSDK.js index e7fef94ab2..57ccafaa37 100644 --- a/tools/api-tester/lib/TestSDK.js +++ b/tools/api-tester/lib/TestSDK.js @@ -96,8 +96,8 @@ module.exports = class TestSDK { async case (id, fn) { this.nameStack.push(id); - // Always reset cwd for top-level cases to prevent them from affecting - // each other. + // Always reset cwd at the beginning of a test suite to prevent it + // from affected by others. if (this.nameStack.length === 1) { this.resetCwd(); } diff --git a/tools/api-tester/tests/stat.js b/tools/api-tester/tests/stat.js index 4603845418..41f8c6c67b 100644 --- a/tools/api-tester/tests/stat.js +++ b/tools/api-tester/tests/stat.js @@ -46,7 +46,23 @@ module.exports = { expect(threw).true; }); - const flags = ['permissions', 'versions']; + await t.case('stat with versions', async () => { + result = await t.stat(TEST_FILENAME, { + return_versions: true, + }); + + await verify_fsentry(t, result); + await t.case('filename is correct', () => { + expect(result.name).equal(`test_stat.txt`); + }); + await t.case(`result has versions array`, () => { + expect(Array.isArray(result.versions)).true; + }); + }) + + // Backend should return 'shares' field when 'return_shares' is true. And + // the backwards compatiable of `return_permissions` is also tested here. + const flags = ['shares', 'permissions']; for ( const flag of flags ) { await t.case('stat with ' + flag, async () => { result = await t.stat(TEST_FILENAME, { @@ -57,8 +73,10 @@ module.exports = { await t.case('filename is correct', () => { expect(result.name).equal(`test_stat.txt`); }); - await t.case(`result has ${flag} array`, () => { - expect(Array.isArray(result[flag])).true; + await t.case(`result has shares (apps and users)`, () => { + expect('shares' in result).true; + expect(Array.isArray(result['shares']['users'])).true; + expect(Array.isArray(result['shares']['apps'])).true; }); }) }