Переглянути джерело

move: api-tester to monorepo

KernelDeimos 4 місяців тому
батько
коміт
405d9b35aa
37 змінених файлів з 2543 додано та 0 видалено
  1. 1 0
      tools/api-tester/.gitignore
  2. 9 0
      tools/api-tester/README.md
  3. 112 0
      tools/api-tester/apitest.js
  4. 121 0
      tools/api-tester/benches/simple.js
  5. 16 0
      tools/api-tester/coverage_models/copy.js
  6. 15 0
      tools/api-tester/coverage_models/move.js
  7. 16 0
      tools/api-tester/coverage_models/write.js
  8. 83 0
      tools/api-tester/doc/cartesian.md
  9. 3 0
      tools/api-tester/example_config.yml
  10. 11 0
      tools/api-tester/lib/Assert.js
  11. 71 0
      tools/api-tester/lib/CoverageModel.js
  12. 3 0
      tools/api-tester/lib/ReportGenerator.js
  13. 27 0
      tools/api-tester/lib/TestFactory.js
  14. 68 0
      tools/api-tester/lib/TestRegistry.js
  15. 378 0
      tools/api-tester/lib/TestSDK.js
  16. 35 0
      tools/api-tester/lib/log_error.js
  17. 5 0
      tools/api-tester/lib/sleep.js
  18. 204 0
      tools/api-tester/package-lock.json
  19. 17 0
      tools/api-tester/package.json
  20. 23 0
      tools/api-tester/test_sdks/puter-rest.js
  21. 13 0
      tools/api-tester/tests/__entry__.js
  22. 226 0
      tools/api-tester/tests/batch.js
  23. 131 0
      tools/api-tester/tests/copy_cart.js
  24. 100 0
      tools/api-tester/tests/delete.js
  25. 78 0
      tools/api-tester/tests/fsentry.js
  26. 69 0
      tools/api-tester/tests/mkdir.js
  27. 94 0
      tools/api-tester/tests/move.js
  28. 103 0
      tools/api-tester/tests/move_cart.js
  29. 39 0
      tools/api-tester/tests/readdir.js
  30. 100 0
      tools/api-tester/tests/stat.js
  31. 14 0
      tools/api-tester/tests/telem_write.js
  32. 69 0
      tools/api-tester/tests/write_and_read.js
  33. 91 0
      tools/api-tester/tests/write_cart.js
  34. 109 0
      tools/api-tester/tools/readdir_profile.js
  35. 73 0
      tools/api-tester/tools/test_read.js
  36. 8 0
      tools/api-tester/toxiproxy/toxiproxy.json
  37. 8 0
      tools/api-tester/toxiproxy/toxiproxy_control.json

+ 1 - 0
tools/api-tester/.gitignore

@@ -0,0 +1 @@
+config.yml

+ 9 - 0
tools/api-tester/README.md

@@ -0,0 +1,9 @@
+## It takes 3 steps to run the tests :)
+
+1. run `npm install`
+2. copy `example_config.yml` and add the correct values
+3. run `node apitest.js --config=your_config_file.yml`
+
+## Here's what it looks like when it's working
+
+![image](https://github.com/HeyPuter/puter-api-test/assets/7225168/115aca70-02ea-4ce1-9d5c-1568feb1f851)

+ 112 - 0
tools/api-tester/apitest.js

@@ -0,0 +1,112 @@
+const YAML = require('yaml');
+
+const TestSDK = require('./lib/TestSDK');
+const log_error = require('./lib/log_error');
+const TestRegistry = require('./lib/TestRegistry');
+
+const fs = require('node:fs');
+const { parseArgs } = require('node:util');
+
+const args = process.argv.slice(2);
+
+let config, report;
+
+try {
+    ({ values: {
+        config,
+        report,
+        bench,
+        unit,
+    }, positionals: [id] } = parseArgs({
+        options: {
+            config: {
+                type: 'string',
+            },
+            report: {
+                type: 'string',
+            },
+            bench: { type: 'boolean' },
+            unit: { type: 'boolean' },
+        },
+        allowPositionals: true,
+    }));
+} catch (e) {
+    if ( args.length < 1 ) {
+        console.error(
+            'Usage: apitest [OPTIONS]\n' +
+            '\n' +
+            'Options:\n' +
+            '  --config=<path>  (required)  Path to configuration file\n' +
+            '  --report=<path>  (optional)  Output file for full test results\n' +
+            ''
+        );
+        process.exit(1);
+    }
+}
+
+
+const conf = YAML.parse(fs.readFileSync(config).toString());
+
+
+const main = async () => {
+    const ts = new TestSDK(conf);
+    try {
+        await ts.delete('api_test', { recursive: true });
+    } catch (e) {
+    }
+    await ts.mkdir('api_test', { overwrite: true });
+    ts.cd('api_test');
+
+    const registry = new TestRegistry(ts);
+
+    registry.add_test_sdk('puter-rest.v1', require('./test_sdks/puter-rest')({
+        config: conf,
+    }));
+
+    require('./tests/__entry__.js')(registry);
+    require('./benches/simple.js')(registry);
+
+    if ( id ) {
+        if ( unit ) {
+            await registry.run_test(id);
+        } else if ( bench ) {
+            await registry.run_bench(id);
+        } else {
+            await registry.run(id);
+        }
+        return;
+    }
+
+    if ( unit ) {
+        await registry.run_all_tests();
+    } else if ( bench ) {
+        await registry.run_all_benches();
+    } else {
+        await registry.run_all();
+    }
+
+
+    // await ts.runTestPackage(require('./tests/write_cart'));
+    // await ts.runTestPackage(require('./tests/move_cart'));
+    // await ts.runTestPackage(require('./tests/copy_cart'));
+    // await ts.runTestPackage(require('./tests/write_and_read'));
+    // await ts.runTestPackage(require('./tests/move'));
+    // await ts.runTestPackage(require('./tests/stat'));
+    // await ts.runTestPackage(require('./tests/readdir'));
+    // await ts.runTestPackage(require('./tests/mkdir'));
+    // await ts.runTestPackage(require('./tests/batch'));
+    // await ts.runTestPackage(require('./tests/delete'));
+    const all = unit && bench;
+    if ( all || unit ) ts.printTestResults();
+    if ( all || bench ) ts.printBenchmarkResults();
+}
+
+const main_e = async () => {
+    try {
+        await main();
+    } catch (e) {
+        log_error(e);
+    }
+}
+
+main_e();

+ 121 - 0
tools/api-tester/benches/simple.js

@@ -0,0 +1,121 @@
+const log_error = require("../lib/log_error");
+
+module.exports = registry => {
+    registry.add_bench('write.tiny', {
+        name: 'write 30 tiny files',
+        do: async t => {
+            for ( let i=0 ; i < 30 ; i++ ) {
+                await t.write(`tiny_${i}.txt`, 'example\n', { overwrite: true });
+            }
+        }
+    });
+
+    registry.add_bench('batch.mkdir-and-write', {
+        name: 'make directories and write',
+        do: async t => {
+            const batch = [];
+            for ( let i=0 ; i < 30 ; i++ ) {
+                batch.push({
+                    op: 'mkdir',
+                    path: t.resolve(`dir_${i}`),
+                });
+                batch.push({
+                    op: 'write',
+                    path: t.resolve(`tiny_${i}.txt`),
+                });
+            }
+            await t.batch('batch', batch, Array(30).fill('example\n'));
+        }
+    });
+
+    registry.add_bench('batch.mkdir-deps.1', {
+        name: 'make directories and write',
+        do: async t => {
+            const batch = [];
+            const blobs = [];
+            for ( let j=0 ; j < 3 ; j++ ) {
+                batch.push({
+                    op: 'mkdir',
+                    path: t.resolve('dir_root'),
+                    as: 'root',
+                })
+                for ( let i=0 ; i < 10 ; i++ ) {
+                    batch.push({
+                        op: 'write',
+                        path: `$root/test_${i}.txt`
+                    });
+                    blobs.push('example\n');
+                }
+            }
+            await t.batch('batch', batch, blobs);
+        }
+    });
+
+    // TODO: write explicit test for multiple directories with the same name
+    // in a batch so that batch can eventually resolve this situation and not
+    // do something incredibly silly.
+    registry.add_bench('batch.mkdir-deps.2', {
+        name: 'make directories and write',
+        do: async t => {
+            const batch = [];
+            const blobs = [];
+            for ( let j=0 ; j < 3 ; j++ ) {
+                batch.push({
+                    op: 'mkdir',
+                    path: t.resolve(`dir_${j}`),
+                    as: `dir_${j}`,
+                })
+                for ( let k=0 ; k < 3 ; k++ ) {
+                    batch.push({
+                        op: 'mkdir',
+                        parent: `$dir_${j}`,
+                        path: `subdir_${k}`,
+                        as: `subdir_${j}-${k}`,
+                    })
+
+                    for ( let i=0 ; i < 5 ; i++ ) {
+                        batch.push({
+                            op: 'write',
+                            path: `$subdir_${j}-${k}/test_${i}.txt`
+                        });
+                        blobs.push('example\n');
+                    }
+                }
+            }
+            try {
+                const response = await t.batch('batch', batch, blobs);
+                console.log('response?', response);
+            } catch (e) {
+                log_error(e);
+            }
+        }
+    });
+
+    registry.add_bench('write.batch.tiny', {
+        name: 'Write 30 tiny files in a batch',
+        do: async t => {
+            const batch = [];
+            for ( let i=0 ; i < 30 ; i++ ) {
+                batch.push({
+                    op: 'write',
+                    path: t.resolve(`tiny_${i}.txt`),
+                });
+            }
+            await t.batch('batch', batch, Array(30).fill('example\n'));
+        }
+    });
+
+    // const fiftyMB = Array(50 * 1024 * 1024).map(() =>
+    //     String.fromCharCode(
+    //         Math.floor(Math.random() * 26) + 97
+    //     ));
+
+    // registry.add_bench('files.mb50', {
+    //     name: 'write 10 50MB files',
+    //     do: async t => {
+    //         for ( let i=0 ; i < 10 ; i++ ) {
+    //             await t.write(`mb50_${i}.txt`, 'example\n', { overwrite: true });
+    //         }
+    //     }
+    // });
+};

+ 16 - 0
tools/api-tester/coverage_models/copy.js

@@ -0,0 +1,16 @@
+const CoverageModel = require("../lib/CoverageModel");
+
+module.exports = new CoverageModel({
+    subject: ['file', 'directory-full', 'directory-empty'],
+    source: {
+        format: ['path', 'uid'],
+    },
+    destination: {
+        format: ['path', 'uid'],
+    },
+    name: ['default', 'specified'],
+    conditions: {
+        destinationIsFile: []
+    },
+    overwrite: [false, 'overwrite', 'dedupe_name'],
+});

+ 15 - 0
tools/api-tester/coverage_models/move.js

@@ -0,0 +1,15 @@
+const CoverageModel = require("../lib/CoverageModel");
+
+module.exports = new CoverageModel({
+    source: {
+        format: ['path', 'uid'],
+    },
+    destination: {
+        format: ['path', 'uid'],
+    },
+    name: ['default', 'specified'],
+    conditions: {
+        destinationIsFile: []
+    },
+    overwrite: [false, 'overwrite', 'dedupe_name']
+});

+ 16 - 0
tools/api-tester/coverage_models/write.js

@@ -0,0 +1,16 @@
+const CoverageModel = require("../lib/CoverageModel");
+
+// ?? What's a coverage model ??
+//
+//     See  doc/cartesian.md
+
+module.exports = new CoverageModel({
+    path: {
+        format: ['path', 'uid'],
+    },
+    name: ['default', 'specified'],
+    conditions: {
+        destinationIsFile: []
+    },
+    overwrite: [false, 'overwrite', 'dedupe_name'],
+});

+ 83 - 0
tools/api-tester/doc/cartesian.md

@@ -0,0 +1,83 @@
+# Cartesian Tests
+
+A cartesian test is a test the tries every combination of possible
+inputs based on some model. It's called this because the set of
+possible states is mathematically the cartesian product of the
+list of sets of options.
+
+## Coverage Model
+
+A coverage model is what defines all the variables and their
+possible value. The coverage model implies the set of all
+possible states (cartesian product).
+
+The following is an example of a coverage model for testing
+the `/write` API method for Puter's filesystem:
+
+```javascript
+module.exports = new CoverageModel({
+    path: {
+        format: ['path', 'uid'],
+    },
+    name: ['default', 'specified'],
+    conditions: {
+        destinationIsFile: []
+    },
+    overwrite: [],
+});
+```
+
+The object will first be flattened. `format` inside `path` will
+become a single key: `path.format`,
+just as `{ a: { x: 1, y: 2 }, b: { z: 3 } }`
+becomes `{ "a.x": 1, "a.y": 2, "b.z": 3 }`
+
+Then, each possible state will be generated to use in tests.
+For example, this is one arbitrary state:
+
+```json
+{
+    "path.format": "path",
+    "name": "specified",
+    "conditions.destinationIsFile": true,
+    "overwrite": false
+}
+```
+
+Wherever an empty list is specified for the list of possible values,
+it will be assumed to be `[false, true]`.
+
+## Finding the Culprit
+
+When a cartesian test fails, if you know the _index_ of the test which
+failed you can determine what the state was just by looking at the
+coverage model.
+
+For example, if tests are failing at indices `1` and `5`
+(starting from `0`, of course) for the `/write` example above,
+the failures are likely related and occur when the default
+filename is used and the destination (`path`) parameter points
+to an existing file.
+
+```
+destination is file:  0  1  0  1  0  1  0  1
+name is the default:  0  0  1  1  0  0  1  1
+test results:         P  F  P  P  P  F  P  P
+```
+
+### Interesting note about the anme
+
+I didn't know what this type of test was called at first. I simply knew
+I wanted to try all the combinations of possible inputs, and I knew what
+the algorithm to do this looked like. I then asked Chat GPT the following
+question:
+
+> What do you call the act of choosing one item from each set in a list of sets?
+
+which it answered with:
+
+> The act of choosing one item from each set in a list of sets is typically called the Cartesian Product.
+
+Then after a bit of searching, it turns out neither Chat GPT nor I are the
+first to use this term to describe the same thing in automated testing for
+software.

+ 3 - 0
tools/api-tester/example_config.yml

@@ -0,0 +1,3 @@
+url: http://puter.localhost:4001/
+username: lavender_hat_6100
+token: ---

+ 11 - 0
tools/api-tester/lib/Assert.js

@@ -0,0 +1,11 @@
+module.exports = class Assert {
+    equal (expected, actual) {
+        this.assert(expected === actual);
+    }
+
+    assert (b) {
+        if ( ! b ) {
+            throw new Error('assertion failed');
+        }
+    }
+}

+ 71 - 0
tools/api-tester/lib/CoverageModel.js

@@ -0,0 +1,71 @@
+const cartesianProduct = (obj) => {
+  // Get array of keys
+  let keys = Object.keys(obj);
+  
+  // Generate the Cartesian Product
+  return keys.reduce((acc, key) => {
+    let appendArrays = Array.isArray(obj[key]) ? obj[key] : [obj[key]];
+
+    let newAcc = [];
+    acc.forEach(arr => {
+      appendArrays.forEach(item => {
+        newAcc.push([...arr, item]);
+      });
+    });
+
+    return newAcc;
+  }, [[]]); // start with the "empty product"
+}
+
+let obj = {
+  a: [1, 2],
+  b: ["a", "b"]
+};
+
+console.log(cartesianProduct(obj));
+
+module.exports = class CoverageModel {
+    constructor (spec) {
+        const flat = {};
+
+        const flatten = (object, prefix) => {
+            for ( const k in object ) {
+                let targetKey = k;
+                if ( prefix ) {
+                    targetKey = prefix + '.' + k;
+                }
+
+                let type = typeof object[k];
+                if ( Array.isArray(object[k]) ) type = 'array';
+
+                if ( type === 'object' ) {
+                    flatten(object[k], targetKey);
+                    continue;
+                }
+
+                if ( object[k].length == 0 ) {
+                    object[k] = [false, true];
+                }
+
+                flat[targetKey] = object[k];
+            }
+        };
+        flatten(spec);
+
+        this.flat = flat;
+
+        const states = cartesianProduct(flat).map(
+          values => {
+            const o = {};
+            const keys = Object.keys(flat);
+            for ( let i=0 ; i < keys.length ; i++ ) {
+              o[keys[i]] = values[i];
+            }
+            return o;
+          }
+        );
+
+        this.states = states;
+        this.covered = Array(this.states.length).fill(false);
+    }
+}

+ 3 - 0
tools/api-tester/lib/ReportGenerator.js

@@ -0,0 +1,3 @@
+module.exports = class ReportGenerator {
+    //
+}

+ 27 - 0
tools/api-tester/lib/TestFactory.js

@@ -0,0 +1,27 @@
+module.exports = class TestFactory {
+    static cartesian (
+        name,
+        coverageModel,
+        { each, init }
+    ) {
+        const do_ = async t => {
+            const states = coverageModel.states;
+            
+            if ( init ) await init(t);
+
+            for ( let i=0 ; i < states.length ; i++ ) {
+                const state = states[i];
+
+                await t.case(`case ${i}`, async () => {
+                    console.log('state', state);
+                    await each(t, state, i);
+                })
+            }
+        };
+
+        return {
+            name,
+            do: do_,
+        };
+    }
+}

+ 68 - 0
tools/api-tester/lib/TestRegistry.js

@@ -0,0 +1,68 @@
+module.exports = class TestRegistry {
+    constructor (t) {
+        this.t = t;
+        this.sdks = {};
+        this.tests = {};
+        this.benches = {};
+    }
+
+    add_test_sdk (id, instance) {
+        this.t.sdks[id] = instance;
+    }
+
+    add_test (id, testDefinition) {
+        this.tests[id] = testDefinition;
+    }
+
+    add_bench (id, benchDefinition) {
+        this.benches[id] = benchDefinition;
+    }
+
+    async run_all_tests () {
+        for ( const id in this.tests ) {
+            const testDefinition = this.tests[id];
+            await this.t.runTestPackage(testDefinition);
+        }
+    }
+
+    // copilot was able to write everything below this line
+    // and I think that's pretty cool
+
+    async run_all_benches () {
+        for ( const id in this.benches ) {
+            const benchDefinition = this.benches[id];
+            await this.t.runBenchmark(benchDefinition);
+        }
+    }
+
+    async run_all () {
+        await this.run_all_tests();
+        await this.run_all_benches();
+    }
+
+    async run_test (id) {
+        const testDefinition = this.tests[id];
+        if ( ! testDefinition ) {
+            throw new Error(`Test not found: ${id}`);
+        }
+        await this.t.runTestPackage(testDefinition);
+    }
+
+    async run_bench (id) {
+        const benchDefinition = this.benches[id];
+        if ( ! benchDefinition ) {
+            throw new Error(`Bench not found: ${id}`);
+        }
+        await this.t.runBenchmark(benchDefinition);
+    }
+
+    async run (id) {
+        if ( this.tests[id] ) {
+            await this.run_test(id);
+        } else if ( this.benches[id] ) {
+            await this.run_bench(id);
+        } else {
+            throw new Error(`Test or bench not found: ${id}`);
+        }
+    }
+}

+ 378 - 0
tools/api-tester/lib/TestSDK.js

@@ -0,0 +1,378 @@
+const axios = require('axios');
+const YAML = require('yaml');
+
+const fs = require('node:fs');
+const path_ = require('node:path');
+const url = require('node:url');
+const https = require('node:https');
+const Assert = require('./Assert');
+const log_error = require('./log_error');
+
+module.exports = class TestSDK {
+    constructor (conf) {
+        this.conf = conf;
+        this.cwd = `/${conf.username}`;
+        this.httpsAgent = new https.Agent({
+            rejectUnauthorized: false
+        })
+        const url_origin = new url.URL(conf.url).origin;
+        this.headers_ = {
+            'Origin': url_origin,
+            'Authorization': `Bearer ${conf.token}`
+        };
+
+        this.installAPIMethodShorthands_();
+
+        this.assert = new Assert();
+
+        this.sdks = {};
+
+        this.results = [];
+        this.failCount = 0;
+        this.caseCount = 0;
+        this.nameStack = [];
+
+        this.packageResults = [];
+
+        this.benchmarkResults = [];
+    }
+
+    async get_sdk (name) {
+        return await this.sdks[name].create();
+    }
+
+    // === test related methods ===
+
+    async runTestPackage (testDefinition) {
+        this.nameStack.push(testDefinition.name);
+        this.packageResults.push({
+            name: testDefinition.name,
+            failCount: 0,
+            caseCount: 0,
+        });
+        const imported = {};
+        for ( const key of Object.keys(testDefinition.import ?? {}) ) {
+            imported[key] = this.sdks[key];
+        }
+        await testDefinition.do(this, imported);
+        this.nameStack.pop();
+    }
+
+    async runBenchmark (benchDefinition) {
+        const strid = '' +
+            '\x1B[35;1m[bench]\x1B[0m' +
+            this.nameStack.join(` \x1B[36;1m->\x1B[0m `);
+        process.stdout.write(strid + ' ... \n');
+
+        this.nameStack.push(benchDefinition.name);
+        let results;
+        this.benchmarkResults.push(results = {
+            name: benchDefinition.name,
+            start: Date.now(),
+        });
+        try {
+            await benchDefinition.do(this);
+        } catch (e) {
+            results.error = e;
+        } finally {
+            results.end = Date.now();
+            const dur = results.end - results.start;
+            process.stdout.write(`...\x1B[32;1m[${dur}]\x1B[0m\n`);
+        }
+    }
+
+    recordResult (result) {
+        const pkg = this.packageResults[this.packageResults.length - 1];
+        this.caseCount++;
+        pkg.caseCount++;
+        if ( ! result.success ) {
+            this.failCount++;
+            pkg.failCount++;
+        }
+        this.results.push(result);
+    }
+
+    async case (id, fn) {
+        this.nameStack.push(id);
+
+        const tabs = Array(this.nameStack.length - 2).fill('  ').join('');
+        const strid = tabs + this.nameStack.join(` \x1B[36;1m->\x1B[0m `);
+        process.stdout.write(strid + ' ... \n');
+
+        try {
+            await fn();
+        } catch (e) {
+            process.stdout.write(`${tabs}...\x1B[31;1m[FAIL]\x1B[0m\n`);
+            this.recordResult({
+                strid,
+                e,
+                success: false,
+            });
+            log_error(e);
+            return;
+        } finally {
+            this.nameStack.pop();
+        }
+
+        process.stdout.write(`${tabs}...\x1B[32;1m[PASS]\x1B[0m\n`);
+        this.recordResult({
+            strid,
+            success: true
+        });
+    }
+
+    quirk (msg) {
+        console.log(`\x1B[33;1mignoring known quirk: ${msg}\x1B[0m`);
+    }
+
+    // === information display methods ===
+
+    printTestResults () {
+        console.log(`\n\x1B[33;1m=== Test Results ===\x1B[0m`);
+
+        let tbl = {};
+        for ( const pkg of this.packageResults ) {
+            tbl[pkg.name] = {
+                passed: pkg.caseCount - pkg.failCount,
+                failed: pkg.failCount,
+                total: pkg.caseCount,
+            }
+        }
+        console.table(tbl);
+
+        process.stdout.write(`\x1B[36;1m${this.caseCount} tests were run\x1B[0m - `);
+        if ( this.failCount > 0 ) {
+            console.log(`\x1B[31;1m✖ ${this.failCount} tests failed!\x1B[0m`);
+        } else {
+            console.log(`\x1B[32;1m✔ All tests passed!\x1B[0m`)
+        }
+    }
+
+    printBenchmarkResults () {
+        console.log(`\n\x1B[33;1m=== Benchmark Results ===\x1B[0m`);
+
+        let tbl = {};
+        for ( const bench of this.benchmarkResults ) {
+            tbl[bench.name] = {
+                time: bench.end - bench.start,
+                error: bench.error ? bench.error.message : '',
+            }
+        }
+        console.table(tbl);
+    }
+
+    // === path related methods ===
+
+    cd (path) {
+        this.cwd = path_.posix.join(this.cwd, path);
+    }
+    resolve (path) {
+        if ( path.startsWith('$') ) return path;
+        if ( path.startsWith('/') ) return path;
+        return path_.posix.join(this.cwd, path);
+    }
+
+    // === API calls ===
+
+    installAPIMethodShorthands_ () {
+        const p = this.resolve.bind(this);
+        this.read = async path => {
+            const res = await this.get('read', { path: p(path) });
+            return res.data;
+        }
+        this.mkdir = async (path, opts) => {
+            const res = await this.post('mkdir', {
+                path: p(path),
+                ...(opts ?? {})
+            });
+            return res.data;
+        };
+        this.write = async (path, bin, params) => {
+            path = p(path);
+            params = params ?? {};
+            let mime = 'text/plain';
+            if ( params.hasOwnProperty('mime') ) {
+                mime = params.mime;
+                delete params.mime;
+            }
+            let name = path_.posix.basename(path);
+            path = path_.posix.dirname(path);
+            params.path = path;
+            const res = await this.upload('write', name, mime, bin, params);
+            return res.data;
+        }
+        this.stat = async (path, params) => {
+            path = p(path);
+            const res = await this.post('stat', { ...params, path });
+            return res.data;
+        }
+        this.statu = async (uid, params) => {
+            const res = await this.post('stat', { ...params, uid });
+            return res.data;
+        }
+        this.readdir = async (path, params) => {
+            path = p(path);
+            const res = await this.post('readdir', {
+                ...params,
+                path
+            })
+            return res.data;
+        }
+        this.delete = async (path, params) => {
+            path = p(path);
+            const res = await this.post('delete', {
+                ...params,
+                paths: [path]
+            });
+            return res.data;
+        }
+        this.move = async (src, dst, params = {}) => {
+            src = p(src);
+            dst = p(dst);
+            const destination = path_.dirname(dst);
+            const source = src;
+            const new_name = path_.basename(dst);
+            console.log('move', { destination, source, new_name });
+            const res = await this.post('move', {
+                ...params,
+                destination,
+                source,
+                new_name,
+            });
+            return res.data;
+        }
+    }
+
+    getURL (...path) {
+        const apiURL = new url.URL(this.conf.url);
+        apiURL.pathname = path_.posix.join(
+            apiURL.pathname,
+            ...path
+        );
+        return apiURL.href;
+    };
+
+    // === HTTP methods ===
+
+    get (ep, params) {
+        return axios.request({
+            httpsAgent: this.httpsAgent,
+            method: 'get',
+            url: this.getURL(ep),
+            params,
+            headers: {
+                ...this.headers_
+            }
+        });
+    }
+
+    post (ep, params) {
+        return axios.request({
+            httpsAgent: this.httpsAgent,
+            method: 'post',
+            url: this.getURL(ep),
+            data: params,
+            headers: {
+                ...this.headers_,
+                'Content-Type': 'application/json',
+            }
+        })
+    }
+
+    upload (ep, name, mime, bin, params) {
+        const adapt_file = (bin, mime) => {
+            if ( typeof bin === 'string' ) {
+                return new Blob([bin], { type: mime });
+            }
+            return bin;
+        };
+        const fd = new FormData();
+        for ( const k in params ) fd.append(k, params[k]);
+        const blob = adapt_file(bin, mime);
+        fd.append('size', blob.size);
+        fd.append('file', adapt_file(bin, mime), name)
+        return axios.request({
+            httpsAgent: this.httpsAgent,
+            method: 'post',
+            url: this.getURL(ep),
+            data: fd,
+            headers: {
+                ...this.headers_,
+                'Content-Type': 'multipart/form-data'
+            },
+        });
+    }
+
+    async batch (ep, ops, bins) {
+        const adapt_file = (bin, mime) => {
+            if ( typeof bin === 'string' ) {
+                return new Blob([bin], { type: mime });
+            }
+            return bin;
+        };
+        const fd = new FormData();
+
+        fd.append('original_client_socket_id', '');
+        fd.append('socket_id', '');
+        fd.append('operation_id', '');
+
+        let fileI = 0;
+        for ( let i=0 ; i < ops.length ; i++ ) {
+            const op = ops[i];
+
+            fd.append('operation', JSON.stringify(op));
+        }
+
+        const files = [];
+
+        for ( let i=0 ; i < ops.length ; i++ ) {
+            const op = ops[i];
+
+            if ( op.op === 'mkdir' ) continue;
+            if ( op.op === 'mktree' ) continue;
+
+            let mime = op.mime ?? 'text/plain';
+            const file = adapt_file(bins[fileI++], mime);
+            fd.append('fileinfo', JSON.stringify({
+                size: file.size,
+                name: op.name,
+                mime,
+            }));
+            files.push({
+                op, file,
+            })
+
+            delete op.name;
+        }
+
+        for ( const file of files ) {
+            const { op, file: blob } = file;
+            fd.append('file', blob, op.name);
+        }
+
+        const res = await axios.request({
+            httpsAgent: this.httpsAgent,
+            method: 'post',
+            url: this.getURL(ep),
+            data: fd,
+            headers: {
+                ...this.headers_,
+                'Content-Type': 'multipart/form-data'
+            },
+        });
+        return res.data.results;
+    }
+
+    batch_json (ep, ops, bins) {
+        return axios.request({
+            httpsAgent: this.httpsAgent,
+            method: 'post',
+            url: this.getURL(ep),
+            data: ops,
+            headers: {
+                ...this.headers_,
+                'Content-Type': 'application/json',
+            },
+        });
+    }
+}

+ 35 - 0
tools/api-tester/lib/log_error.js

@@ -0,0 +1,35 @@
+const log_http_error = e => {
+    console.log('\x1B[31;1m' + e.message + '\x1B[0m');
+
+    console.log('HTTP Method: ', e.config.method.toUpperCase());
+    console.log('URL: ', e.config.url);
+
+    if (e.config.params) {
+        console.log('URL Parameters: ', e.config.params);
+    }
+
+    if (e.config.method.toLowerCase() === 'post' && e.config.data) {
+        console.log('Post body: ', e.config.data);
+    }
+
+    console.log('Request Headers: ', JSON.stringify(e.config.headers, null, 2));
+
+    if (e.response) {
+        console.log('Response Status: ', e.response.status);
+        console.log('Response Headers: ', JSON.stringify(e.response.headers, null, 2));
+        console.log('Response body: ', e.response.data);
+    }
+
+    console.log('\x1B[31;1m' + e.message + '\x1B[0m');
+};
+
+const log_error = e => {
+    if ( e.request ) {
+        log_http_error(e);
+        return;
+    }
+
+    console.error(e);
+};
+
+module.exports = log_error;

+ 5 - 0
tools/api-tester/lib/sleep.js

@@ -0,0 +1,5 @@
+module.exports = async function sleep (ms) {
+    await new Promise(rslv => {
+        setTimeout(rslv, ms);
+    })
+}

+ 204 - 0
tools/api-tester/package-lock.json

@@ -0,0 +1,204 @@
+{
+  "name": "puter-api-test",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "puter-api-test",
+      "version": "0.1.0",
+      "license": "UNLICENSED",
+      "dependencies": {
+        "axios": "^1.4.0",
+        "chai": "^4.3.7",
+        "chai-as-promised": "^7.1.1",
+        "yaml": "^2.3.1"
+      }
+    },
+    "node_modules/assertion-error": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz",
+      "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
+    },
+    "node_modules/axios": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmjs.org/axios/-/axios-1.4.0.tgz",
+      "integrity": "sha512-S4XCWMEmzvo64T9GfvQDOXgYRDJ/wsSZc7Jvdgx5u1sd0JwsuPLqb3SYmusag+edF6ziyMensPVqLTSc1PiSEA==",
+      "dependencies": {
+        "follow-redirects": "^1.15.0",
+        "form-data": "^4.0.0",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/chai": {
+      "version": "4.3.7",
+      "resolved": "https://registry.npmjs.org/chai/-/chai-4.3.7.tgz",
+      "integrity": "sha512-HLnAzZ2iupm25PlN0xFreAlBA5zaBSv3og0DdeGA4Ar6h6rJ3A0rolRUKJhSF2V10GZKDgWF/VmAEsNWjCRB+A==",
+      "dependencies": {
+        "assertion-error": "^1.1.0",
+        "check-error": "^1.0.2",
+        "deep-eql": "^4.1.2",
+        "get-func-name": "^2.0.0",
+        "loupe": "^2.3.1",
+        "pathval": "^1.1.1",
+        "type-detect": "^4.0.5"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/chai-as-promised": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/chai-as-promised/-/chai-as-promised-7.1.1.tgz",
+      "integrity": "sha512-azL6xMoi+uxu6z4rhWQ1jbdUhOMhis2PvscD/xjLqNMkv3BPPp2JyyuTHOrf9BOosGpNQ11v6BKv/g57RXbiaA==",
+      "dependencies": {
+        "check-error": "^1.0.2"
+      },
+      "peerDependencies": {
+        "chai": ">= 2.1.2 < 5"
+      }
+    },
+    "node_modules/check-error": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.2.tgz",
+      "integrity": "sha512-BrgHpW9NURQgzoNyjfq0Wu6VFO6D7IZEmJNdtgNqpzGG8RuNFHt2jQxWlAs4HMe119chBnv+34syEZtc6IhLtA==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/deep-eql": {
+      "version": "4.1.3",
+      "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz",
+      "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==",
+      "dependencies": {
+        "type-detect": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.2",
+      "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
+      "integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.0",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
+      "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/get-func-name": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.0.tgz",
+      "integrity": "sha512-Hm0ixYtaSZ/V7C8FJrtZIuBBI+iSgL+1Aq82zSu8VQNB4S3Gk8e7Qs3VwBDJAhmRZcFqkl3tQu36g/Foh5I5ig==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/loupe": {
+      "version": "2.3.6",
+      "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.6.tgz",
+      "integrity": "sha512-RaPMZKiMy8/JruncMU5Bt6na1eftNoo++R4Y+N2FrxkDVTrGvcyzFTsaGif4QTeKESheMGegbhw6iUAq+5A8zA==",
+      "dependencies": {
+        "get-func-name": "^2.0.0"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/pathval": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz",
+      "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==",
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
+    },
+    "node_modules/type-detect": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
+      "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/yaml": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz",
+      "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==",
+      "engines": {
+        "node": ">= 14"
+      }
+    }
+  }
+}

+ 17 - 0
tools/api-tester/package.json

@@ -0,0 +1,17 @@
+{
+  "name": "@heyputer/puter-api-test",
+  "version": "0.1.0",
+  "description": "",
+  "main": "apitest.js",
+  "scripts": {
+    "test": "echo \"Error: no test specified\" && exit 1"
+  },
+  "author": "Puter Technologies Inc.",
+  "license": "UNLICENSED",
+  "dependencies": {
+    "axios": "^1.4.0",
+    "chai": "^4.3.7",
+    "chai-as-promised": "^7.1.1",
+    "yaml": "^2.3.1"
+  }
+}

+ 23 - 0
tools/api-tester/test_sdks/puter-rest.js

@@ -0,0 +1,23 @@
+const axios = require('axios');
+
+class PuterRestTestSDK {
+    constructor (config) {
+        this.config = config;
+    }
+    async create() {
+        const conf = this.config;
+        const axiosInstance = axios.create({
+            httpsAgent: new https.Agent({
+                rejectUnauthorized: false,
+            }),
+            baseURL: conf.url,
+            headers: {
+                'Authorization': `Bearer ${conf.token}`, // common headers
+                //... other headers
+            }
+        });
+        return axiosInstance;
+    }
+}
+
+module.exports = ({ config }) => new PuterRestTestSDK(config);

+ 13 - 0
tools/api-tester/tests/__entry__.js

@@ -0,0 +1,13 @@
+module.exports = registry => {
+    registry.add_test('write_cart', require('./write_cart'));
+    registry.add_test('move_cart', require('./move_cart'));
+    registry.add_test('copy_cart', require('./copy_cart'));
+    registry.add_test('write_and_read', require('./write_and_read'));
+    registry.add_test('move', require('./move'));
+    registry.add_test('stat', require('./stat'));
+    registry.add_test('readdir', require('./readdir'));
+    registry.add_test('mkdir', require('./mkdir'));
+    registry.add_test('batch', require('./batch'));
+    registry.add_test('delete', require('./delete'));
+    registry.add_test('telem_write', require('./telem_write'));
+};

+ 226 - 0
tools/api-tester/tests/batch.js

@@ -0,0 +1,226 @@
+const { expect } = require("chai");
+const { verify_fsentry } = require("./fsentry");
+
+module.exports = {
+    name: 'batch',
+    do: async t => {
+        let results;
+        /*
+        await t.case('batch write', async () => {
+            results = null;
+            results = await t.batch('/batch/write', [
+                {
+                    path: t.resolve('test_1.txt'),
+                    overwrite: true,
+                },
+                {
+                    path: t.resolve('test_3.txt'),
+                }
+            ], [
+                'first file',
+                'second file',
+            ])
+            console.log('results?', results)
+            expect(results.length).equal(2);
+            for ( const result of results ) {
+                await verify_fsentry(t, result)
+            }
+        });
+        t.case('batch mkdir', async () => {
+            results = null;
+            results = await t.batch_json('batch/mkdir', [
+                {
+                    path: t.resolve('test_1_dir'),
+                    overwrite: true,
+                },
+                {
+                    path: t.resolve('test_3_dir'),
+                }
+            ])
+            expect(results.length).equal(2);
+            for ( const result of results ) {
+                await verify_fsentry(t, result)
+            }
+        });
+        */
+        await t.case('3-3 nested directores', async () => {
+            results = null;
+            results = await t.batch('batch', [
+                {
+                    op: 'mktree',
+                    parent: t.cwd,
+                    tree: [
+                        'a/b/c',
+                        [
+                            'a/b/c',
+                            ['a/b/c'],
+                            ['d/e/f'],
+                            ['g/h/i'],
+                            ['j/k/l'],
+                        ],
+                        [
+                            'd/e/f',
+                            ['a/b/c'],
+                            ['d/e/f'],
+                            ['g/h/i'],
+                            ['j/k/l'],
+                        ],
+                        [
+                            'g/h/i',
+                            ['a/b/c'],
+                            ['d/e/f'],
+                            ['g/h/i'],
+                            ['j/k/l'],
+                        ],
+                        [
+                            'j/k/l',
+                            ['a/b/c'],
+                            ['d/e/f'],
+                            ['g/h/i'],
+                            ['j/k/l'],
+                        ],
+                    ]
+                }
+            ], []);
+        });
+        await t.case('path reference resolution', async () => {
+            results = null;
+            results = await t.batch('batch', [
+                {
+                    op: 'mkdir',
+                    as: 'dest_1',
+                    path: t.resolve('q/w'),
+                    create_missing_parents: true,
+                },
+                {
+                    op: 'mkdir',
+                    as: 'dest_2',
+                    path: t.resolve('q/w'), // "q/w (1)"
+                    dedupe_name: true,
+                    create_missing_parents: true,
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('$dest_1/file_1.txt'),
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('$dest_2/file_2.txt'),
+                },
+            ], [
+                'file 1 contents',
+                'file 2 contents',
+            ]);
+            console.log('res?', results)
+            expect(results.length).equal(4);
+            expect(results[0].name).equal('w');
+            expect(results[1].name).equal('w (1)');
+            expect(results[2].path).equal(t.resolve('q/w/file_1.txt'));
+            expect(results[3].path).equal(t.resolve('q/w (1)/file_2.txt'));
+        });
+
+        await t.case('batch mkdir and write', async () => {
+            results = null;
+            results = await t.batch('batch', [
+                {
+                    op: 'mkdir',
+                    path: t.resolve('test_x_1_dir'),
+                    overwrite: true,
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('test_x_1.txt'),
+                },
+                {
+                    op: 'mkdir',
+                    path: t.resolve('test_x_2_dir'),
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('test_x_2.txt'),
+                }
+            ], [
+                'first file',
+                'second file',
+            ]);
+            console.log('res?', results)
+            expect(results.length).equal(4);
+            for ( const result of results ) {
+                // await verify_fsentry(t, result)
+            }
+        });
+
+        await t.case('path reference resolution (without dedupe)', async () => {
+            results = null;
+            results = await t.batch('batch', [
+                {
+                    op: 'mkdir',
+                    as: 'dest_1',
+                    path: t.resolve('q/w'),
+                    create_missing_parents: true,
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('$dest_1/file_1.txt'),
+                },
+            ], [
+                'file 1 contents',
+            ]);
+            console.log('res?', results)
+            expect(results.length).equal(2);
+            expect(results[0].name).equal('w');
+            expect(results[1].path).equal(t.resolve('q/w/file_1.txt'));
+        });
+
+        // Test for path reference resolution
+        await t.case('path reference resolution', async () => {
+            results = null;
+            results = await t.batch('batch', [
+                {
+                    op: 'mkdir',
+                    as: 'dest_1',
+                    path: t.resolve('q/w'),
+                    create_missing_parents: true,
+                },
+                {
+                    op: 'mkdir',
+                    as: 'dest_2',
+                    path: t.resolve('q/w'), // "q/w (1)"
+                    dedupe_name: true,
+                    create_missing_parents: true,
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('$dest_1/file_1.txt'),
+                },
+                {
+                    op: 'write',
+                    path: t.resolve('$dest_2/file_2.txt'),
+                },
+            ], [
+                'file 1 contents',
+                'file 2 contents',
+            ]);
+            console.log('res?', results)
+            expect(results.length).equal(4);
+            expect(results[0].name).equal('w');
+            expect(results[1].name).equal('w (1)');
+            expect(results[2].path).equal(t.resolve('q/w/file_1.txt'));
+            expect(results[3].path).equal(t.resolve('q/w (1)/file_2.txt'));
+        });
+
+        // Test for a single write
+        await t.case('single write', async () => {
+            results = null;
+            results = await t.batch('batch', [
+                {
+                    op: 'write',
+                    path: t.resolve('just_one_file.txt'),
+                },
+            ], [
+                'file 1 contents',
+            ]);
+            console.log('res?', results)
+        });
+    }
+};

+ 131 - 0
tools/api-tester/tests/copy_cart.js

@@ -0,0 +1,131 @@
+const { default: axios } = require("axios");
+const { expect } = require("chai");
+const copy = require("../coverage_models/copy");
+const TestFactory = require("../lib/TestFactory");
+
+/*
+    CARTESIAN TEST FOR /copy
+
+    NOTE: This test is very similar to the test for /move,
+          but DRYing it would add too much complexity.
+
+          It is best to have both tests open side-by-side
+          when making changes to either one.
+*/
+
+const PREFIX = 'copy_cart_';
+
+module.exports = TestFactory.cartesian('Cartesian Test for /copy', copy, {
+    each: async (t, state, i) => {
+        // 1. Common setup for all states
+        await t.mkdir(`${PREFIX}${i}`);
+        const dir = `/${t.cwd}/${PREFIX}${i}`;
+
+        await t.mkdir(`${PREFIX}${i}/a`);
+
+        let pathOfThingToCopy = '';
+        
+        if ( state.subject === 'file' ) {
+            await t.write(`${PREFIX}${i}/a/a_file.txt`, 'file a contents\n');
+            pathOfThingToCopy = `/a/a_file.txt`;
+        } else {
+            await t.mkdir(`${PREFIX}${i}/a/a_directory`);
+            pathOfThingToCopy = `/a/a_directory`;
+
+            // for test purposes, a "full" directory has each of three classes:
+            // - a file
+            // - an empty directory
+            // - a directory with a file in it
+            if ( state.subject === 'directory-full' ) {
+                // add a file
+                await t.write(`${PREFIX}${i}/a/a_directory/a_file.txt`, 'file a contents\n');
+
+                // add a directory with a file inside of it
+                await t.mkdir(`${PREFIX}${i}/a/a_directory/b_directory`);
+                await t.write(`${PREFIX}${i}/a/a_directory/b_directory/b_file.txt`, 'file a contents\n');
+
+                // add an empty directory
+                await t.mkdir(`${PREFIX}${i}/a/a_directory/c_directory`);
+            }
+        }
+
+        // 2. Situation setup for this state
+
+        if ( state['conditions.destinationIsFile'] ) {
+            await t.write(`${PREFIX}${i}/b`, 'placeholder\n');
+        } else {
+            await t.mkdir(`${PREFIX}${i}/b`);
+            await t.write(`${PREFIX}${i}/b/b_file.txt`, 'file b contents\n');
+        }
+
+        const srcUID = (await t.stat(`${PREFIX}${i}${pathOfThingToCopy}`)).uid;
+        const dstUID = (await t.stat(`${PREFIX}${i}/b`)).uid;
+
+        // 3. Parameter setup for this state
+        const data = {};
+        data.source = state['source.format'] === 'uid'
+            ? srcUID : `${dir}${pathOfThingToCopy}` ;
+        data.destination = state['destination.format'] === 'uid'
+            ? dstUID : `${dir}/b` ;
+
+        if ( state.name === 'specified' ) {
+            data.new_name = 'x_renamed';
+        }
+
+        if ( state.overwrite ) {
+            data[state.overwrite] = true;
+        }
+
+        // 4. Request
+        let e = null;
+        let resp;
+        try {
+            resp = await axios.request({
+                method: 'post',
+                httpsAgent: t.httpsAgent,
+                url: t.getURL('copy'),
+                data,
+                headers: {
+                    ...t.headers_,
+                    'Content-Type': 'application/json'
+                }
+            });
+        } catch (e_) {
+            e = e_;
+        }
+
+        // 5. Check Response
+        let error_expected = null;
+
+        if (
+            state['conditions.destinationIsFile'] &&
+            state.name === 'specified'
+        ) {
+            error_expected = {
+                code: 'dest_is_not_a_directory',
+                message: `Destination must be a directory.`,
+            };
+        }
+
+        else if (
+            state['conditions.destinationIsFile'] &&
+            ! state.overwrite &&
+            ! state.dedupe_name
+        ) {
+            console.log('AN ERROR IS EXPECTED');
+            error_expected = {
+                code: 'item_with_same_name_exists',
+                message: 'An item with name `b` already exists.',
+                entry_name: 'b',
+            }
+        }
+
+        if ( error_expected ) {
+            expect(e).to.exist;
+            const data = e.response.data;
+            expect(data).deep.equal(error_expected);
+        } else {
+            if ( e ) throw e;
+        }
+    }
+})

+ 100 - 0
tools/api-tester/tests/delete.js

@@ -0,0 +1,100 @@
+const { expect } = require("chai");
+const sleep = require("../lib/sleep");
+
+module.exports = {
+    name: 'delete',
+    do: async t => {
+        await t.case('delete for normal file', async () => {
+            await t.write('test_delete.txt', 'delete test\n', { overwrite: true });
+            await t.delete('test_delete.txt');
+            let threw = false;
+            try {
+                await t.stat('test_delete.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+        });
+        await t.case('error for non-existing file', async () => {
+            let threw = false;
+            try {
+                await t.delete('test_delete.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+        });
+        await t.case('delete for directory', async () => {
+            await t.mkdir('test_delete_dir', { overwrite: true });
+            await t.delete('test_delete_dir');
+            let threw = false;
+            try {
+                await t.stat('test_delete_dir');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+        });
+        await t.case('delete for non-empty directory', async () => {
+            await t.mkdir('test_delete_dir', { overwrite: true });
+            await t.write('test_delete_dir/test.txt', 'delete test\n', { overwrite: true });
+            let threw = false;
+            try {
+                await t.delete('test_delete_dir');
+            } catch (e) {
+                expect(e.response.status).equal(400);
+                threw = true;
+            }
+            expect(threw).true;
+        });
+        await t.case('delete for non-empty directory with recursive=true', async () => {
+            await t.mkdir('test_delete_dir', { overwrite: true });
+            await t.write('test_delete_dir/test.txt', 'delete test\n', { overwrite: true });
+            await t.delete('test_delete_dir', { recursive: true });
+            let threw = false;
+            await sleep(500);
+            try {
+                await t.stat('test_delete_dir');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+        });
+        await t.case('non-empty deep recursion', async () => {
+            await t.mkdir('del/a/b/c/d', {
+                create_missing_parents: true,
+            });
+            await t.write('del/a/b/c/d/test.txt', 'delete test\n');
+            await t.delete('del', {
+                recursive: true,
+                descendants_only: true,
+            });
+            let threw = false;
+            t.quirk('delete too asynchronous');
+            await new Promise(rslv => setTimeout(rslv, 500));
+            try {
+                await t.stat('del/a/b/c/d/test.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+            threw = false;
+            try {
+                await t.stat('del/a');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+            await t.case('parent directory still exists', async () => {
+                const stat = await t.stat('del');
+                expect(stat.name).equal('del');
+            });
+        });
+    }
+};

+ 78 - 0
tools/api-tester/tests/fsentry.js

@@ -0,0 +1,78 @@
+const { expect } = require("chai");
+
+const _bitBooleans = [
+    'immutable',
+    'is_shortcut',
+    'is_symlink',
+    'is_dir',
+];
+
+const _integers = [
+    'created',
+    'accessed',
+    'modified',
+];
+
+const _strings = [
+    'id', 'uid', 'parent_id', 'name',
+]
+
+const verify_fsentry = async (t, o) => {
+    await t.case('fsentry is valid', async () => {
+        for ( const k of _strings ) {
+            await t.case(`${k} is a string`, () => {
+                expect(typeof o[k]).equal('string');
+            });
+        }
+        if ( o.is_dir ) {
+            await t.case(`type is null for directories`, () => {
+                expect(o.type).equal(null);
+            });
+        }
+        if ( ! o.is_dir ) {
+            await t.case(`type is a string for files`, () => {
+                expect(typeof o.type).equal('string');
+            });
+        }
+        await t.case('id === uid', () => {
+            expect(o.id).equal(o.uid);
+        });
+        await t.case('uid is string', () => {
+            expect(typeof o.uid).equal('string');
+        });
+        for ( const k of _bitBooleans ) {
+            await t.case(`${k} is 0 or 1`, () => {
+                expect(o[k]).oneOf([0, 1], `${k} should be 0 or 1`);
+            });
+        }
+        t.quirk('is_shared is not populated currently');
+        // expect(o.is_shared).oneOf([true, false]);
+        for ( const k of _integers ) {
+            if ( o.is_dir && k === 'accessed' ) {
+                t.quirk('accessed is null for new directories');
+                continue;
+            }
+
+            await t.case(`${k} is numeric type`, () => {
+                expect(typeof o[k]).equal('number');
+            });
+            await t.case(`${k} has no fractional component`, () => {
+                expect(Number.isInteger(o[k])).true;
+            });
+        }
+        await t.case('symlink_path is null or string', () => {
+            expect(
+                o.symlink_path === null ||
+                typeof o.symlink_path === 'string'
+            ).true;
+        });
+        await t.case('owner object has expected properties', () => {
+            expect(o.owner).to.haveOwnProperty('username');
+            expect(o.owner).to.haveOwnProperty('email');
+        });
+    })
+}
+
+module.exports = {
+    verify_fsentry,
+};

+ 69 - 0
tools/api-tester/tests/mkdir.js

@@ -0,0 +1,69 @@
+const { expect } = require("chai");
+const { verify_fsentry } = require("./fsentry");
+
+module.exports = {
+    name: 'mkdir',
+    do: async t => {
+        await t.case('recursive mkdir', async () => {
+            // Can create a chain of directories
+            const path = 'a/b/c/d/e/f/g';
+            let result;
+            await t.case('no exception thrown', async () => {
+                result = await t.mkdir(path, {
+                    create_missing_parents: true,
+                });
+                console.log('result?', result)
+            });
+            
+            // Returns the last directory in the chain
+            // await verify_fsentry(t, result);
+            await t.case('filename is correct', () => {
+                expect(result.name).equal('g');
+            });
+
+            await t.case('can stat the directory', async () => {
+                const stat = await t.stat(path);
+                // await verify_fsentry(t, stat);
+                await t.case('filename is correct', () => {
+                    expect(stat.name).equal('g');
+                });
+            });
+
+            // can stat the first directory in the chain
+            await t.case('can stat the first directory in the chain', async () => {
+                const stat = await t.stat('a');
+                // await verify_fsentry(t, stat);
+                await t.case('filename is correct', () => {
+                    expect(stat.name).equal('a');
+                });
+            });
+        });
+
+        // NOTE: It looks like we removed this behavior and we always create missing parents
+        // await t.case('fails with missing parent', async () => {
+        //     let threw = false;
+        //     try {
+        //         const result = await t.mkdir('a/b/x/g');
+
+        //         console.log('unexpected result', result);
+        //     } catch (e) {
+        //         expect(e.response.status).equal(422);
+        //         console.log('response?', e.response.data)
+        //         expect(e.response.data).deep.equal({
+        //             code: 'dest_does_not_exist',
+        //             message: 'Destination was not found.',
+        //         });
+        //         threw = true;
+        //     }
+        //     expect(threw).true;
+        // });
+
+        await t.case('mkdir dedupe name', async () => {
+            for ( let i = 1; i <= 3; i++ ) {
+                await t.mkdir('a', { dedupe_name: true });
+                const stat = await t.stat(`a (${i})`);
+                expect(stat.name).equal(`a (${i})`);
+            }
+        });
+    }
+};

+ 94 - 0
tools/api-tester/tests/move.js

@@ -0,0 +1,94 @@
+const { expect } = require("chai");
+const fs = require('fs');
+
+module.exports = {
+    name: 'move',
+    do: async t => {
+        // setup conditions for tests
+        await t.mkdir('dir_with_contents');
+        await t.write('dir_with_contents/a.txt', 'move test\n');
+        await t.write('dir_with_contents/b.txt', 'move test\n');
+        await t.write('dir_with_contents/c.txt', 'move test\n');
+        await t.mkdir('dir_with_contents/q');
+        await t.mkdir('dir_with_contents/w');
+        await t.mkdir('dir_with_contents/e');
+        await t.mkdir('dir_no_contents');
+        await t.write('just_a_file.txt', 'move test\n');
+
+        await t.case('move file', async () => {
+            await t.move('just_a_file.txt', 'just_a_file_moved.txt');
+            const moved = await t.stat('just_a_file_moved.txt');
+            let threw = false;
+            try {
+                await t.stat('just_a_file.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+            expect(moved.name).equal('just_a_file_moved.txt');
+        });
+
+        await t.case('move file to existing file', async () => {
+            await t.write('just_a_file.txt', 'move test\n');
+            let threw = false;
+            try {
+                await t.move('just_a_file.txt', 'dir_with_contents/a.txt');
+            } catch (e) {
+                expect(e.response.status).equal(409);
+                threw = true;
+            }
+            expect(threw).true;
+        });
+
+        /*
+        await t.case('move file to existing directory', async () => {
+            await t.move('just_a_file.txt', 'dir_with_contents');
+            const moved = await t.stat('dir_with_contents/just_a_file.txt');
+            let threw = false;
+            try {
+                await t.stat('just_a_file.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+            expect(moved.name).equal('just_a_file.txt');
+        });
+        */
+
+        await t.case('move directory', async () => {
+            await t.move('dir_no_contents', 'dir_no_contents_moved');
+            const moved = await t.stat('dir_no_contents_moved');
+            let threw = false;
+            try {
+                await t.stat('dir_no_contents');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+            expect(moved.name).equal('dir_no_contents_moved');
+        });
+
+        await t.case('move file and create parents', async () => {
+            await t.write('just_a_file.txt', 'move test\n', { overwrite: true });
+            const res = await t.move(
+                'just_a_file.txt',
+                'dir_with_contents/q/w/e/just_a_file.txt',
+                { create_missing_parents: true }
+            );
+            expect(res.parent_dirs_created).length(2);
+            const moved = await t.stat('dir_with_contents/q/w/e/just_a_file.txt');
+            let threw = false;
+            try {
+                await t.stat('just_a_file.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                threw = true;
+            }
+            expect(threw).true;
+            expect(moved.name).equal('just_a_file.txt');
+        });
+    }
+};

+ 103 - 0
tools/api-tester/tests/move_cart.js

@@ -0,0 +1,103 @@
+const { default: axios } = require("axios");
+const { expect } = require("chai");
+const move = require("../coverage_models/move");
+const TestFactory = require("../lib/TestFactory");
+
+/*
+    CARTESIAN TEST FOR /move
+
+    NOTE: This test is very similar to the test for /copy,
+          but DRYing it would add too much complexity.
+
+          It is best to have both tests open side-by-side
+          when making changes to either one.
+*/
+
+const PREFIX = 'move_cart_';
+
+module.exports = TestFactory.cartesian('Cartesian Test for /move', move, {
+    each: async (t, state, i) => {
+        // 1. Common setup for all states
+        await t.mkdir(`${PREFIX}${i}`);
+        const dir = `/${t.cwd}/${PREFIX}${i}`;
+
+        await t.mkdir(`${PREFIX}${i}/a`);
+        await t.write(`${PREFIX}${i}/a/a_file.txt`, 'file a contents\n');
+
+        // 2. Situation setup for this state
+        if ( state['conditions.destinationIsFile'] ) {
+            await t.write(`${PREFIX}${i}/b`, 'placeholder\n');
+        } else {
+            await t.mkdir(`${PREFIX}${i}/b`);
+            await t.write(`${PREFIX}${i}/b/b_file.txt`, 'file b contents\n');
+        }
+
+        const srcUID = (await t.stat(`${PREFIX}${i}/a/a_file.txt`)).uid;
+        const dstUID = (await t.stat(`${PREFIX}${i}/b`)).uid;
+
+        // 3. Parameter setup for this state
+        const data = {};
+        data.source = state['source.format'] === 'uid'
+            ? srcUID : `${dir}/a/a_file.txt` ;
+        data.destination = state['destination.format'] === 'uid'
+            ? dstUID : `${dir}/b` ;
+
+        if ( state.name === 'specified' ) {
+            data.new_name = 'x_file.txt';
+        }
+
+        if ( state.overwrite ) {
+            data[state.overwrite] = true;
+        }
+
+        // 4. Request
+        let e = null;
+        let resp;
+        try {
+            resp = await axios.request({
+                method: 'post',
+                httpsAgent: t.httpsAgent,
+                url: t.getURL('move'),
+                data,
+                headers: {
+                    ...t.headers_,
+                    'Content-Type': 'application/json'
+                }
+            });
+        } catch (e_) {
+            e = e_;
+        }
+
+        // 5. Check Response
+        let error_expected = null;
+
+        if (
+            state['conditions.destinationIsFile'] &&
+            state.name === 'specified'
+        ) {
+            error_expected = {
+                code: 'dest_is_not_a_directory',
+                message: `Destination must be a directory.`,
+            };
+        }
+
+        else if (
+            state['conditions.destinationIsFile'] &&
+            ! state.overwrite
+        ) {
+            error_expected = {
+                code: 'item_with_same_name_exists',
+                message: 'An item with name `b` already exists.',
+                entry_name: 'b',
+            }
+        }
+
+        if ( error_expected ) {
+            expect(e).to.exist;
+            const data = e.response.data;
+            expect(data).deep.equal(error_expected);
+        } else {
+            if ( e ) throw e;
+        }
+    }
+})

+ 39 - 0
tools/api-tester/tests/readdir.js

@@ -0,0 +1,39 @@
+
+const { verify_fsentry } = require("./fsentry");
+const { expect } = require("chai");
+
+module.exports = {
+    name: 'readdir',
+    do: async t => {
+        // let result;
+
+        await t.mkdir('test_readdir', { overwrite: true });
+        t.cd('test_readdir');
+
+        const files = ['a.txt', 'b.txt', 'c.txt'];
+        const dirs = ['q', 'w', 'e'];
+
+        for ( const file of files ) {
+            await t.write(file, 'readdir test\n', { overwrite: true });
+        }
+        for ( const dir of dirs ) {
+            await t.mkdir(dir, { overwrite: true });
+        }
+
+        for ( const file of files ) {
+            const result = await t.stat(file);
+            await verify_fsentry(t, result);
+        }
+        for ( const dir of dirs ) {
+            const result = await t.stat(dir);
+            await verify_fsentry(t, result);
+        }
+
+        await t.case('readdir of root shouldn\'t return everything', async () => {
+            const result = await t.readdir('/', { recursive: true });
+            console.log('result?', result)
+        })
+
+        // t.cd('..');
+    }
+};

+ 100 - 0
tools/api-tester/tests/stat.js

@@ -0,0 +1,100 @@
+const { verify_fsentry } = require("./fsentry");
+const { expect } = require("chai");
+
+module.exports = {
+    name: 'stat',
+    do: async t => {
+        let result;
+
+        const TEST_FILENAME = 'test_stat.txt';
+
+        let recorded_uid = null;
+
+        await t.case('stat with path (no flags)', async () => {
+            await t.write(TEST_FILENAME, 'stat test\n', { overwrite: true });
+            result = await t.stat(TEST_FILENAME);
+
+            await verify_fsentry(t, result);
+            recorded_uid = result.uid;
+            await t.case('filename is correct', () => {
+                expect(result.name).equal('test_stat.txt');
+            });
+        })
+
+        await t.case('stat with uid (no flags)', async () => {
+            result = await t.statu(recorded_uid);
+
+            await verify_fsentry(t, result);
+            await t.case('filename is correct', () => {
+                expect(result.name).equal('test_stat.txt');
+            });
+        })
+
+        await t.case('stat with no path or uid provided fails', async () => {
+            let threw = false;
+            try {
+                const res = await t.get('stat', {});
+            } catch (e) {
+                expect(e.response.status).equal(400);
+                expect(e.response.data).deep.equal({
+                    code: 'field_missing',
+                    message: 'Field `subject` is required.',
+                    key: 'subject',
+                });
+                threw = true;
+            }
+            expect(threw).true;
+        });
+
+        const flags = ['permissions', 'versions'];
+        for ( const flag of flags ) {
+            await t.case('stat with ' + flag, async () => {
+                result = await t.stat(TEST_FILENAME, {
+                    ['return_' + flag]: true,
+                });
+
+                await verify_fsentry(t, result);
+                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.mkdir('test_stat_subdomains', { overwrite: true });
+        await t.case('stat with subdomains', async () => {
+            result = await t.stat('test_stat_subdomains', {
+                return_subdomains: true,
+            });
+
+            await verify_fsentry(t, result);
+            await t.case('directory name is correct', () => {
+                expect(result.name).equal(`test_stat_subdomains`);
+            });
+            await t.case(`result has subdomains array`, () => {
+                expect(Array.isArray(result.subdomains)).true;
+            });
+            console.log('RESULT', result);
+        })
+
+        {
+        const flag = 'size';
+            await t.case('stat with ' + flag, async () => {
+                result = await t.stat(TEST_FILENAME, {
+                    ['return_' + flag]: true,
+                });
+
+                await verify_fsentry(t, result);
+                await t.case('filename is correct', () => {
+                    expect(result.name).equal(`test_stat.txt`);
+                });
+                console.log('RESULT', result);
+            })
+        }
+
+
+        // console.log('result?', result);
+    }
+};

+ 14 - 0
tools/api-tester/tests/telem_write.js

@@ -0,0 +1,14 @@
+const chai = require('chai');
+chai.use(require('chai-as-promised'))
+const expect = chai.expect;
+
+module.exports = {
+    name: 'single write for trace and span',
+    do: async t => {
+        let result;
+
+        const TEST_FILENAME = 'test_telem.txt';
+
+        await t.write(TEST_FILENAME, 'example\n', { overwrite: true });
+    }
+};

+ 69 - 0
tools/api-tester/tests/write_and_read.js

@@ -0,0 +1,69 @@
+const chai = require('chai');
+chai.use(require('chai-as-promised'))
+const expect = chai.expect;
+
+module.exports = {
+    name: 'write and read',
+    do: async t => {
+        let result;
+
+        const TEST_FILENAME = 'test_rw.txt';
+
+        await t.write(TEST_FILENAME, 'example\n', { overwrite: true });
+
+        await t.case('read matches what was written', async () => {
+            result = await t.read(TEST_FILENAME);
+            expect(result).equal('example\n');
+        });
+
+        await t.case('write throws for overwrite=false', () => {
+            expect(
+                t.write(TEST_FILENAME, 'no-change\n')
+            ).rejectedWith(Error);
+        });
+
+        await t.case('write updates for overwrite=true', async () => {
+            await t.write(TEST_FILENAME, 'yes-change\n', {
+                overwrite: true,
+            });
+            result = await t.read(TEST_FILENAME);
+            expect(result).equal('yes-change\n');
+        });
+
+        await t.case('write updates for overwrite=true', async () => {
+            await t.write(TEST_FILENAME, 'yes-change\n', {
+                overwrite: true,
+            });
+            result = await t.read(TEST_FILENAME, { version_id: '1' });
+            expect(result).equal('yes-change\n');
+        });
+
+        await t.case('read with no path or uid provided fails', async () => {
+            let threw = false;
+            try {
+                const res = await t.get('read', {});
+            } catch (e) {
+                expect(e.response.status).equal(400);
+                expect(e.response.data).deep.equal({
+                    message: 'Field \`file\` is required.',
+                    code: 'field_missing',
+                    key: 'file',
+                });
+                threw = true;
+            }
+            expect(threw).true;
+        });
+
+        await t.case('read for non-existing path fails', async () => {
+            let threw = false;
+            try {
+                await t.read('i-do-not-exist.txt');
+            } catch (e) {
+                expect(e.response.status).equal(404);
+                expect(e.response.data).deep.equal({ message: 'Path not found.' });
+                threw = true;
+            }
+            expect(threw).true;
+        });
+    }
+};

+ 91 - 0
tools/api-tester/tests/write_cart.js

@@ -0,0 +1,91 @@
+const { default: axios } = require("axios");
+const write = require("../coverage_models/write");
+const TestFactory = require("../lib/TestFactory");
+
+const chai = require('chai');
+chai.use(require('chai-as-promised'))
+const expect = chai.expect;
+
+module.exports = TestFactory.cartesian('Cartesian Test for /write', write, {
+    each: async (t, state, i) => {
+        if ( state['conditions.destinationIsFile'] ) {
+            await t.write('write_cart_' + i, 'placeholder\n');
+        } else {
+            await t.mkdir('write_cart_' + i);
+        }
+
+        const dir = `/${t.cwd}/write_cart_` + i;
+        const dirUID = (await t.stat('write_cart_' + i)).uid;
+
+        const contents = new Blob(
+            [`case ${i}\n`],
+            { type: 'text/plain' },
+        );
+
+        console.log('DIR UID', dirUID)
+
+        const fd = new FormData();
+
+        if ( state.name === 'specified' ) {
+            fd.append('name', 'specified_name.txt');
+        }
+        if ( state.overwrite ) {
+            fd.append(state.overwrite, true);
+        }
+
+        fd.append('path', state.format === 'path' ? dir : dirUID);
+        fd.append('size', contents.size),
+        fd.append('file', contents, 'uploaded_name.txt');
+
+        let e = null;
+
+        let resp;
+        try {
+            resp = await axios.request({
+                method: 'post',
+                httpsAgent: t.httpsAgent,
+                url: t.getURL('write'),
+                data: fd,
+                headers: {
+                    ...t.headers_,
+                    'Content-Type': 'multipart/form-data'
+                }
+            })
+        } catch (e_) {
+            e = e_;
+        }
+
+        let error_expected = null;
+
+        // Error conditions
+        if (
+            state['conditions.destinationIsFile'] &&
+            state.name === 'specified'
+        ) {
+            error_expected = {
+                code: 'dest_is_not_a_directory',
+                message: `Destination must be a directory.`,
+            };
+        }
+
+        if (
+            state['conditions.destinationIsFile'] &&
+            state.name === 'default' &&
+            ! state.overwrite
+        ) {
+            error_expected = {
+                code: 'item_with_same_name_exists',
+                message: 'An item with name `write_cart_'+i+'` already exists.',
+                entry_name: 'write_cart_' + i,
+            };
+        }
+
+        if ( error_expected ) {
+            expect(e).to.exist;
+            const data = e.response.data;
+            expect(data).deep.equal(error_expected);
+        } else {
+            if ( e ) throw e;
+        }
+    }
+})

+ 109 - 0
tools/api-tester/tools/readdir_profile.js

@@ -0,0 +1,109 @@
+const axios = require('axios');
+const YAML = require('yaml');
+
+const https = require('node:https');
+const { parseArgs } = require('node:util');
+const url = require('node:url');
+
+const path_ = require('path');
+const fs = require('fs');
+
+let config;
+
+try {
+    ({ values: {
+        config,
+    }, positionals: [id] } = parseArgs({
+        options: {
+            config: {
+                type: 'string',
+            },
+        },
+        allowPositionals: true,
+    }));
+} catch (e) {
+    if ( args.length < 1 ) {
+        console.error(
+            'Usage: readdir_profile [OPTIONS]\n' +
+            '\n' +
+            'Options:\n' +
+            '  --config=<path>  (required)  Path to configuration file\n' +
+            ''
+        );
+        process.exit(1);
+    }
+}
+
+const conf = YAML.parse(fs.readFileSync(config).toString());
+
+const dir = `/${conf.username}/readdir_test`
+
+// process.on('SIGINT', async () => {
+//     process.exit(0);
+// });
+
+const httpsAgent = new https.Agent({
+    rejectUnauthorized: false
+})
+const getURL = (...path) => {
+    const apiURL = new url.URL(conf.url);
+    apiURL.pathname = path_.posix.join(
+        apiURL.pathname,
+        ...path
+    );
+    return apiURL.href;
+};
+
+const epoch = Date.now();
+const TIME_BEFORE_TEST = 20 * 1000; // 10 seconds
+
+const NOOP = () => {};
+let check = () => {
+    if ( Date.now() - epoch >= TIME_BEFORE_TEST ) {
+        console.log(
+            `\x1B[36;1m !!! START THE TEST !!! \x1B[0m`
+        );
+        check = NOOP;
+    }
+};
+
+const measure_readdir = async () => {
+    const ts_start = Date.now();
+
+    await axios.request({
+        httpsAgent,
+        method: 'post',
+        url: getURL('readdir'),
+        data: {
+            path: dir,
+        },
+        headers: {
+            'Authorization': `Bearer ${conf.token}`,
+            'Content-Type': 'application/json'
+        }
+    })
+
+    const ts_end = Date.now();
+
+    const diff = ts_end - ts_start;
+
+    await fs.promises.appendFile(
+        `readdir_profile.txt`,
+        `${Date.now()},${diff}\n`
+    )
+
+    check();
+
+    await new Promise(rslv => {
+        setTimeout(rslv, 5);
+    });
+}
+
+
+const main = async () => {
+    while (true) {
+        await measure_readdir();
+    }
+}
+
+main();

+ 73 - 0
tools/api-tester/tools/test_read.js

@@ -0,0 +1,73 @@
+const axios = require('axios');
+const YAML = require('yaml');
+
+const https = require('node:https');
+const { parseArgs } = require('node:util');
+const url = require('node:url');
+
+const path_ = require('path');
+const fs = require('fs');
+
+let config;
+
+try {
+    ({ values: {
+        config,
+    }, positionals: [id] } = parseArgs({
+        options: {
+            config: {
+                type: 'string',
+            },
+        },
+        allowPositionals: true,
+    }));
+} catch (e) {
+    if ( args.length < 1 ) {
+        console.error(
+            'Usage: readdir_profile [OPTIONS]\n' +
+            '\n' +
+            'Options:\n' +
+            '  --config=<path>  (required)  Path to configuration file\n' +
+            ''
+        );
+        process.exit(1);
+    }
+}
+
+const conf = YAML.parse(fs.readFileSync(config).toString());
+
+const entry = `/${conf.username}/read_test.txt`;
+
+// process.on('SIGINT', async () => {
+//     process.exit(0);
+// });
+
+const httpsAgent = new https.Agent({
+    rejectUnauthorized: false
+})
+const getURL = (...path) => {
+    const apiURL = new url.URL(conf.url);
+    apiURL.pathname = path_.posix.join(
+        apiURL.pathname,
+        ...path
+    );
+    return apiURL.href;
+};
+
+const main = async () => {
+    const resp = await axios.request({
+        httpsAgent,
+        method: 'get',
+        url: getURL('read'),
+        params: {
+            file: entry,
+        },
+        headers: {
+            'Authorization': `Bearer ${conf.token}`,
+        }
+    })
+    console.log(resp.data);
+}
+
+main();
+

+ 8 - 0
tools/api-tester/toxiproxy/toxiproxy.json

@@ -0,0 +1,8 @@
+[
+  {
+    "name": "mysql",
+    "listen": "[::]:8888",
+    "upstream": "localhost:8889",
+    "enabled": true
+  }
+]

+ 8 - 0
tools/api-tester/toxiproxy/toxiproxy_control.json

@@ -0,0 +1,8 @@
+[
+  {
+    "name": "mysql",
+    "listen": "[::]:8888",
+    "upstream": "localhost:8889",
+    "enabled": true
+  }
+]