diff --git a/src/puter-js/tests/api/suites/ai.suite.ts b/src/puter-js/tests/api/suites/ai.suite.ts index d2dab54d7..2247cb9c4 100644 --- a/src/puter-js/tests/api/suites/ai.suite.ts +++ b/src/puter-js/tests/api/suites/ai.suite.ts @@ -119,4 +119,26 @@ export default suite('ai', { 'the internal fake-chat provider should stay hidden', ); }, + + 'every model reported by the driver carries an id': async (t) => { + // The public listModels endpoint is empty without provider keys, so + // assert the shape against the driver-level list (always populated + // with the fake models). + const resp = await t.puter.drivers.call( + 'puter-chat-completion', + 'ai-chat', + 'models', + {}, + ); + const models = (resp.result ?? resp) as Array<{ id?: string }>; + t.assert.ok(Array.isArray(models), 'driver models should be an array'); + t.assert.ok(models.length > 0, 'driver should report models'); + for (const model of models) { + t.assert.equal( + typeof model.id, + 'string', + `every model entry should expose an id, got ${JSON.stringify(model)}`, + ); + } + }, }); diff --git a/src/puter-js/tests/api/suites/auth.suite.ts b/src/puter-js/tests/api/suites/auth.suite.ts index 82d32a38c..51103b8df 100644 --- a/src/puter-js/tests/api/suites/auth.suite.ts +++ b/src/puter-js/tests/api/suites/auth.suite.ts @@ -83,6 +83,30 @@ export default suite('auth', { ); }, + 'getDetailedAppUsage without an appId rejects': async (t) => { + await t.assert.rejects( + () => + ( + t.puter.auth.getDetailedAppUsage as ( + appId?: unknown, + ) => Promise + )(), + 'getDetailedAppUsage should require an appId', + ); + }, + + 'getDetailedAppUsage returns a report for an app': async (t) => { + const app = await t.puter.apps.create( + t.puter.randName(), + 'https://example.com/', + ); + const usage = await t.puter.auth.getDetailedAppUsage(app.uid); + t.assert.ok( + usage && typeof usage === 'object', + 'detailed usage should be an object', + ); + }, + 'regular user is rejected by admin-gated endpoints': async (t) => { const asUser = await fetch(`${t.env.apiOrigin}/serverInfo`, { headers: { diff --git a/src/puter-js/tests/api/suites/fs.suite.ts b/src/puter-js/tests/api/suites/fs.suite.ts index 3a9a6db57..9a884c9ca 100644 --- a/src/puter-js/tests/api/suites/fs.suite.ts +++ b/src/puter-js/tests/api/suites/fs.suite.ts @@ -327,6 +327,35 @@ export default suite('fs', { t.assert.equal(await blob.text(), 'upload two'); }, + 'read of a directory rejects': async (t) => { + const dir = `${home(t)}/fs-suite-read-dir`; + await t.puter.fs.mkdir(dir); + await t.assert.rejects( + () => t.puter.fs.read(dir), + 'reading a directory as a file should reject', + ); + }, + + 'stat reports uid, path and size for a file': async (t) => { + const path = `${home(t)}/fs-suite-stat-fields.txt`; + await t.puter.fs.write(path, 'twelve bytes'); + const info = await t.puter.fs.stat(path); + t.assert.ok(info.uid, 'stat should return a uid'); + t.assert.equal(Boolean(info.is_dir), false); + t.assert.equal(info.name, 'fs-suite-stat-fields.txt'); + t.assert.ok(info.path.endsWith('fs-suite-stat-fields.txt')); + t.assert.equal(Number(info.size), 'twelve bytes'.length); + }, + + 'copy into a missing destination directory rejects': async (t) => { + const src = `${home(t)}/fs-suite-copy-src.txt`; + await t.puter.fs.write(src, 'copy me'); + await t.assert.rejects( + () => t.puter.fs.copy(src, `${home(t)}/fs-suite-copy-nope`), + 'copy into a nonexistent directory should reject', + ); + }, + 'users cannot read files outside their home': async (t) => { await t.assert.rejects( () => t.puter.fs.readdir(`/${t.env.users.admin.username}`), diff --git a/src/puter-js/tests/api/suites/hosting.suite.ts b/src/puter-js/tests/api/suites/hosting.suite.ts index d78044f76..6082f6653 100644 --- a/src/puter-js/tests/api/suites/hosting.suite.ts +++ b/src/puter-js/tests/api/suites/hosting.suite.ts @@ -98,6 +98,26 @@ export default suite('hosting', { ); }, + 'get of an unknown subdomain rejects': async (t) => { + await t.assert.rejects( + () => t.puter.hosting.get('hosting-suite-never-created'), + 'get of an unknown subdomain should reject', + ); + }, + + 'update to a missing directory rejects': async (t) => { + const dir = await makeSiteDir(t, 'update-missing'); + await t.puter.hosting.create('hosting-suite-update-missing', dir); + await t.assert.rejects( + () => + t.puter.hosting.update( + 'hosting-suite-update-missing', + `${home(t)}/hosting-suite-not-a-dir`, + ), + 'update pointing at a missing directory should reject', + ); + }, + 'delete removes the subdomain': async (t) => { const dir = await makeSiteDir(t, 'delete'); await t.puter.hosting.create('hosting-suite-delete', dir); diff --git a/src/puter-js/tests/api/suites/index.ts b/src/puter-js/tests/api/suites/index.ts index 9760e0e86..2ad592d95 100644 --- a/src/puter-js/tests/api/suites/index.ts +++ b/src/puter-js/tests/api/suites/index.ts @@ -8,6 +8,7 @@ import kv from './kv.suite.ts'; import net from './net.suite.ts'; import perms from './perms.suite.ts'; import system from './system.suite.ts'; +import util from './util.suite.ts'; import workers from './workers.suite.ts'; /** @@ -24,5 +25,6 @@ export const suites: Suite[] = [ net, perms, system, + util, workers, ]; diff --git a/src/puter-js/tests/api/suites/kv.suite.ts b/src/puter-js/tests/api/suites/kv.suite.ts index e2a8d6540..c51e44041 100644 --- a/src/puter-js/tests/api/suites/kv.suite.ts +++ b/src/puter-js/tests/api/suites/kv.suite.ts @@ -132,4 +132,94 @@ export default suite('kv', { 'an undefined key should be rejected', ); }, + + 'MAX_KEY_SIZE and MAX_VALUE_SIZE expose the documented limits': async ( + t, + ) => { + t.assert.equal(t.puter.kv.MAX_KEY_SIZE, 1024); + t.assert.equal(t.puter.kv.MAX_VALUE_SIZE, 399 * 1024); + }, + + 'incr on a fresh key starts from zero': async (t) => { + t.assert.equal(await t.puter.kv.incr('kv-suite-incr-fresh', 3), 3); + }, + + 'decr can drive a value negative': async (t) => { + t.assert.equal(await t.puter.kv.decr('kv-suite-decr-neg', 5), -5); + }, + + 'add appends values into an array at a path': async (t) => { + await t.puter.kv.set('kv-suite-add', { tags: ['alpha'] }); + const updated = await t.puter.kv.add('kv-suite-add', { + tags: ['beta', 'gamma'], + }); + t.assert.deepEqual(updated.tags, ['alpha', 'beta', 'gamma']); + }, + + 'update with a ttl keeps the value readable before it expires': async ( + t, + ) => { + await t.puter.kv.set('kv-suite-update-ttl', { n: 1 }); + await t.puter.kv.update('kv-suite-update-ttl', { n: 2 }, 3600); + const value = await t.puter.kv.get('kv-suite-update-ttl'); + t.assert.equal(value.n, 2); + }, + + 'list without a pattern returns every key for the app': async (t) => { + await t.puter.kv.set('kv-suite-all-1', 1); + await t.puter.kv.set('kv-suite-all-2', 2); + const keys = (await t.puter.kv.list()) as string[]; + t.assert.ok(keys.includes('kv-suite-all-1')); + t.assert.ok(keys.includes('kv-suite-all-2')); + }, + + 'list returns keys in lexicographic order': async (t) => { + await t.puter.kv.set('kv-suite-sorted-c', 1); + await t.puter.kv.set('kv-suite-sorted-a', 1); + await t.puter.kv.set('kv-suite-sorted-b', 1); + const keys = (await t.puter.kv.list('kv-suite-sorted-*')) as string[]; + t.assert.deepEqual(keys, [ + 'kv-suite-sorted-a', + 'kv-suite-sorted-b', + 'kv-suite-sorted-c', + ]); + }, + + 'list with a limit and cursor paginates through matches': async (t) => { + for (let i = 1; i <= 3; i++) { + await t.puter.kv.set(`kv-suite-page-${i}`, `v${i}`); + } + const seen: string[] = []; + let cursor: string | undefined; + let guard = 0; + do { + const page = (await t.puter.kv.list({ + pattern: 'kv-suite-page-*', + returnValues: true, + limit: 2, + cursor, + })) as { items: Array<{ key: string }>; cursor?: string }; + for (const item of page.items) seen.push(item.key); + cursor = page.cursor; + } while (cursor && ++guard < 10); + t.assert.deepEqual(seen.sort(), [ + 'kv-suite-page-1', + 'kv-suite-page-2', + 'kv-suite-page-3', + ]); + }, + + 'flush removes every key for the app': async (t) => { + await t.puter.kv.set('kv-suite-flush-a', 1); + await t.puter.kv.set('kv-suite-flush-b', 2); + await t.puter.kv.flush(); + t.assert.equal(await t.puter.kv.get('kv-suite-flush-a'), null); + t.assert.equal(await t.puter.kv.get('kv-suite-flush-b'), null); + const keys = (await t.puter.kv.list()) as string[]; + t.assert.equal( + keys.some((k) => k.startsWith('kv-suite-')), + false, + 'flush should clear every key the suite created', + ); + }, }); diff --git a/src/puter-js/tests/api/suites/perms.suite.ts b/src/puter-js/tests/api/suites/perms.suite.ts index fcb713c6b..0c08c3637 100644 --- a/src/puter-js/tests/api/suites/perms.suite.ts +++ b/src/puter-js/tests/api/suites/perms.suite.ts @@ -123,6 +123,24 @@ export default suite('perms', { t.assert.equal(await res.text(), 'group content'); }, + 'grantGroup then revokeGroup both succeed': async (t) => { + const path = `${home(t)}/perms-suite-group-revoke.txt`; + await t.puter.fs.write(path, 'group revoke content'); + const permission = `fs:${path}:read`; + + const created = await t.puter.perms.createGroup({ + title: 'perms-suite-revoke-readers', + }); + await t.puter.perms.addUsersToGroup(created.uid, [ + t.env.users.other.username, + ]); + const granted = await t.puter.perms.grantGroup(created.uid, permission); + t.assert.ok(!granted.error, `grant failed: ${JSON.stringify(granted)}`); + + const revoked = await t.puter.perms.revokeGroup(created.uid, permission); + t.assert.ok(!revoked.error, `revoke failed: ${JSON.stringify(revoked)}`); + }, + 'grantApp records an app permission': async (t) => { const app = await t.puter.apps.create( 'perms-suite-app', diff --git a/src/puter-js/tests/api/suites/system.suite.ts b/src/puter-js/tests/api/suites/system.suite.ts index 7051b3332..3c997a8f2 100644 --- a/src/puter-js/tests/api/suites/system.suite.ts +++ b/src/puter-js/tests/api/suites/system.suite.ts @@ -20,6 +20,7 @@ export default suite('system', { 'puter-kvstore', 'puter-apps', 'puter-subdomains', + 'puter-chat-completion', ]) { t.assert.ok( Object.prototype.hasOwnProperty.call(interfaces, expected), diff --git a/src/puter-js/tests/api/suites/util.suite.ts b/src/puter-js/tests/api/suites/util.suite.ts new file mode 100644 index 000000000..62fb15f48 --- /dev/null +++ b/src/puter-js/tests/api/suites/util.suite.ts @@ -0,0 +1,43 @@ +import { suite } from '../harness/types.ts'; + +/** + * puter.js utility helpers (`puter.randName`, `puter.env`). These are pure + * client-side helpers, so they run identically on every platform. + * DOM-bound utilities like `puter.print` are covered by the browser + * fixtures, not here. + */ +export default suite('util', { + 'randName returns a domain-safe name': async (t) => { + const name = t.puter.randName(); + t.assert.equal(typeof name, 'string'); + t.assert.ok(name.length > 0, 'randName should be non-empty'); + t.assert.ok( + /^[a-z0-9-]+$/.test(name), + `randName should be lowercase, digits and dashes only, got: ${name}`, + ); + }, + + 'randName produces a fresh name each call': async (t) => { + const a = t.puter.randName(); + const b = t.puter.randName(); + t.assert.ok(a !== b, 'two randName calls should differ'); + }, + + 'randName honours a custom separator': async (t) => { + const name = t.puter.randName('_'); + t.assert.ok( + name.includes('_') && !name.includes('-'), + `custom separator should be used throughout, got: ${name}`, + ); + }, + + 'env reports the runtime environment': async (t) => { + const env = t.puter.env; + t.assert.ok( + ['web', 'app', 'gui', 'nodejs', 'web-worker', 'service-worker'].includes( + env, + ), + `env should be a known environment, got: ${env}`, + ); + }, +}); diff --git a/src/puter-js/tests/api/suites/workers.suite.ts b/src/puter-js/tests/api/suites/workers.suite.ts index 5b42ac160..fceac6939 100644 --- a/src/puter-js/tests/api/suites/workers.suite.ts +++ b/src/puter-js/tests/api/suites/workers.suite.ts @@ -12,6 +12,17 @@ const home = (t: TestContext) => `/${t.env.users.user.username}`; */ const WORKER_SOURCE = ` router.custom('GET', '/ping', async () => ({ pong: true })); +router.post('/echo', async ({ request }) => { + const body = await request.json(); + return { echoed: body }; +}); +router.get('/posts/:category/:id', async ({ params }) => params); +router.get('/teapot', async () => new Response('no coffee', { status: 418 })); +router.get('/whoami', async ({ user }) => { + if (!user || !user.puter) return { authed: false }; + const me = await user.puter.getUser(); + return { authed: true, username: me.username }; +}); `; const deployWorker = async (t: TestContext, name: string) => { @@ -35,6 +46,38 @@ export default suite('workers', { t.assert.deepEqual(body, { pong: true }); }, + 'exec POSTs a body and reads the JSON response': async (t) => { + const created = await deployWorker(t, 'workers-suite-echo'); + const res = await t.puter.workers.exec(`${created.url}/echo`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ hello: 'worker' }), + }); + t.assert.equal(res.status, 200); + t.assert.deepEqual(await res.json(), { echoed: { hello: 'worker' } }); + }, + + 'a worker resolves route parameters': async (t) => { + const created = await deployWorker(t, 'workers-suite-params'); + const res = await t.puter.workers.exec(`${created.url}/posts/tech/42`); + t.assert.deepEqual(await res.json(), { category: 'tech', id: '42' }); + }, + + 'a worker can return a custom status code': async (t) => { + const created = await deployWorker(t, 'workers-suite-status'); + const res = await t.puter.workers.exec(`${created.url}/teapot`); + t.assert.equal(res.status, 418); + t.assert.equal(await res.text(), 'no coffee'); + }, + + 'exec runs the worker in the calling user context': async (t) => { + const created = await deployWorker(t, 'workers-suite-userctx'); + const res = await t.puter.workers.exec(`${created.url}/whoami`); + const body = await res.json(); + t.assert.equal(body.authed, true, 'user.puter should be populated'); + t.assert.equal(body.username, t.env.users.user.username); + }, + 'get returns the deployed worker': async (t) => { await deployWorker(t, 'workers-suite-get'); const worker = await t.puter.workers.get('workers-suite-get');