import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; import express from 'express'; import request from 'supertest'; import { Repository } from '../db/repository.js'; import { BrowserSessionRepo } from '../db/browser-session-repo.js'; import { Scheduler } from '../scheduler.js'; import { mountScheduledTasksApi } from './scheduled-tasks-api.js'; import { mkdtempSync, rmSync } from 'fs'; import { join } from 'path'; import { tmpdir } from 'os'; let app: express.Application; let repo: Repository; let scheduler: Scheduler; let tempDir: string; beforeAll(() => { tempDir = mkdtempSync(join(tmpdir(), 'agent-sched-api-')); repo = new Repository(join(tempDir, 'test.db')); scheduler = new Scheduler(repo, join(tempDir, 'workspaces')); app = express(); app.use(express.json()); mountScheduledTasksApi(app, repo, scheduler); }); afterAll(() => { repo.close(); try { rmSync(tempDir, { recursive: true, force: true }); } catch {} }); describe('POST /api/scheduled-tasks with visibility', () => { let vTempDir = ''; let vRepo: Repository; let vApp: express.Application; let aliceUser: Express.User; beforeEach(() => { vTempDir = mkdtempSync(join(tmpdir(), 'sched-vis-api-')); vRepo = new Repository(join(vTempDir, 'db.sqlite')); const real = vRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); aliceUser = { ...real, orgIds: ['10'], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const vScheduler = new Scheduler(vRepo, join(vTempDir, 'workspaces')); vApp = express(); vApp.use(express.json()); vApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = aliceUser; next(); }); mountScheduledTasksApi(vApp, vRepo, vScheduler); }); afterEach(() => { vRepo.close(); rmSync(vTempDir, { recursive: true, force: true }); }); it('creates scheduled task with owner_id set from req.user and visibility=org', async () => { const res = await request(vApp).post('/api/scheduled-tasks').send({ body: 'hello', scheduleType: 'daily', hour: 9, minute: 0, visibility: 'org', visibilityScopeOrgId: '10', }); expect(res.status).toBe(201); expect(res.body.task.visibility).toBe('org'); expect(res.body.task.visibilityScopeOrgId).toBe('10'); expect(res.body.task.ownerId).toBe(aliceUser.id); }); it('defaults visibility to private and owner from req.user when not provided', async () => { const res = await request(vApp).post('/api/scheduled-tasks').send({ body: 'hello', scheduleType: 'daily', hour: 10, }); expect(res.status).toBe(201); expect(res.body.task.visibility).toBe('private'); expect(res.body.task.visibilityScopeOrgId).toBeNull(); expect(res.body.task.ownerId).toBe(aliceUser.id); }); it('rejects visibility=org with org not in user orgs', async () => { const res = await request(vApp).post('/api/scheduled-tasks').send({ body: 'hello', scheduleType: 'daily', hour: 9, visibility: 'org', visibilityScopeOrgId: '99', }); expect(res.status).toBe(400); }); it('rejects invalid visibility enum values', async () => { const res = await request(vApp).post('/api/scheduled-tasks').send({ body: 'hello', scheduleType: 'daily', hour: 9, visibility: 'bogus', }); expect(res.status).toBe(400); }); }); describe('POST /api/scheduled-tasks in no-auth mode (synthetic local owner)', () => { let nTempDir = ''; let nRepo: Repository; let nApp: express.Application; beforeEach(() => { nTempDir = mkdtempSync(join(tmpdir(), 'sched-noauth-api-')); nRepo = new Repository(join(nTempDir, 'db.sqlite')); const nScheduler = new Scheduler(nRepo, join(nTempDir, 'workspaces')); nApp = express(); nApp.use(express.json()); // No user middleware: no-auth deployment (req.user undefined). authActive:false // tells the router to register the scheduled task under the 'local' owner so // the jobs the scheduler later spawns inherit it (not NULL). mountScheduledTasksApi(nApp, nRepo, nScheduler, { authActive: false }); }); afterEach(() => { nRepo.close(); rmSync(nTempDir, { recursive: true, force: true }); }); it('registers the scheduled task under owner "local" instead of NULL', async () => { const res = await request(nApp).post('/api/scheduled-tasks').send({ body: 'nightly thing', scheduleType: 'daily', hour: 9, minute: 0, }); expect(res.status).toBe(201); expect(res.body.task.ownerId).toBe('local'); }); }); describe('POST /api/scheduled-tasks', () => { it('should create a daily schedule', async () => { const res = await request(app) .post('/api/scheduled-tasks') .send({ title: 'テスト日次', body: 'テストプロンプト', scheduleType: 'daily', hour: 9, minute: 0, }); expect(res.status).toBe(201); expect(res.body.task.cronExpression).toBe('0 9 * * *'); expect(res.body.task.isActive).toBe(true); }); it('should require body', async () => { const res = await request(app).post('/api/scheduled-tasks').send({ scheduleType: 'daily' }); expect(res.status).toBe(400); }); }); describe('GET /api/scheduled-tasks', () => { it('should list all scheduled tasks', async () => { const res = await request(app).get('/api/scheduled-tasks'); expect(res.status).toBe(200); expect(Array.isArray(res.body.tasks)).toBe(true); }); }); describe('PATCH /api/scheduled-tasks/:id', () => { it('should pause and resume', async () => { const createRes = await request(app) .post('/api/scheduled-tasks') .send({ title: 'pause-test', body: 'test', scheduleType: 'daily', hour: 10 }); const id = createRes.body.task.id; const pauseRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: false }); expect(pauseRes.body.task.isActive).toBe(false); const resumeRes = await request(app).patch(`/api/scheduled-tasks/${id}`).send({ isActive: true }); expect(resumeRes.body.task.isActive).toBe(true); }); }); describe('DELETE /api/scheduled-tasks/:id', () => { it('should delete a scheduled task', async () => { const createRes = await request(app) .post('/api/scheduled-tasks') .send({ title: 'delete-test', body: 'test', scheduleType: 'daily', hour: 10 }); const id = createRes.body.task.id; const delRes = await request(app).delete(`/api/scheduled-tasks/${id}`); expect(delRes.status).toBe(200); const getRes = await request(app).get(`/api/scheduled-tasks/${id}`); expect(getRes.status).toBe(404); }); }); describe('PATCH/DELETE /api/scheduled-tasks/:id owner-or-admin', () => { let pTempDir = ''; let pRepo: Repository; afterEach(() => { pRepo.close(); rmSync(pTempDir, { recursive: true, force: true }); }); function buildAppForUser(user: Express.User): express.Application { const pScheduler = new Scheduler(pRepo, join(pTempDir, 'workspaces')); const pApp = express(); pApp.use(express.json()); pApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = user; next(); }); mountScheduledTasksApi(pApp, pRepo, pScheduler); return pApp; } function seedTask(ownerId: string, visibility: 'private' | 'org' | 'public' = 'public') { return pRepo.createScheduledTask({ title: 't', body: 'b', cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00', ownerId, visibility, }); } it('non-owner non-admin gets 404 on PATCH (even when visibility=public)', async () => { pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-')); pRepo = new Repository(join(pTempDir, 'db.sqlite')); const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const task = await seedTask(alice.id, 'public'); const bobUser: Express.User = { id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const pApp = buildAppForUser(bobUser); const res = await request(pApp) .patch(`/api/scheduled-tasks/${task.id}`) .send({ title: 'edited' }); expect(res.status).toBe(404); // Task title not changed const after = await pRepo.getScheduledTask(task.id); expect(after?.title).toBe('t'); }); it('non-owner non-admin gets 404 on DELETE', async () => { pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-')); pRepo = new Repository(join(pTempDir, 'db.sqlite')); const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const task = await seedTask(alice.id, 'public'); const bobUser: Express.User = { id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const pApp = buildAppForUser(bobUser); const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`); expect(res.status).toBe(404); const after = await pRepo.getScheduledTask(task.id); expect(after).not.toBeNull(); }); it('admin can PATCH any scheduled task', async () => { pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-')); pRepo = new Repository(join(pTempDir, 'db.sqlite')); const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const task = await seedTask(alice.id, 'private'); const adminUser: Express.User = { id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null, role: 'admin', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const pApp = buildAppForUser(adminUser); const res = await request(pApp) .patch(`/api/scheduled-tasks/${task.id}`) .send({ title: 'edited-by-admin' }); expect(res.status).toBe(200); expect(res.body.task.title).toBe('edited-by-admin'); }); it('admin can DELETE any scheduled task', async () => { pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-')); pRepo = new Repository(join(pTempDir, 'db.sqlite')); const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const task = await seedTask(alice.id, 'private'); const adminUser: Express.User = { id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null, role: 'admin', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const pApp = buildAppForUser(adminUser); const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); const after = await pRepo.getScheduledTask(task.id); expect(after).toBeNull(); }); it('owner can PATCH own scheduled task', async () => { pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-')); pRepo = new Repository(join(pTempDir, 'db.sqlite')); const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const task = await seedTask(alice.id, 'private'); const pApp = buildAppForUser(aliceUser); const res = await request(pApp) .patch(`/api/scheduled-tasks/${task.id}`) .send({ title: 'edited-by-owner' }); expect(res.status).toBe(200); expect(res.body.task.title).toBe('edited-by-owner'); }); it('owner can DELETE own scheduled task', async () => { pTempDir = mkdtempSync(join(tmpdir(), 'sched-perm-')); pRepo = new Repository(join(pTempDir, 'db.sqlite')); const alice = pRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const task = await seedTask(alice.id, 'private'); const pApp = buildAppForUser(aliceUser); const res = await request(pApp).delete(`/api/scheduled-tasks/${task.id}`); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); }); }); describe('GET /api/scheduled-tasks visibility filter', () => { let lTempDir = ''; let lRepo: Repository; afterEach(() => { lRepo.close(); rmSync(lTempDir, { recursive: true, force: true }); }); function buildAppForUser(user: Express.User): express.Application { const lScheduler = new Scheduler(lRepo, join(lTempDir, 'workspaces')); const lApp = express(); lApp.use(express.json()); lApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = user; next(); }); mountScheduledTasksApi(lApp, lRepo, lScheduler); return lApp; } it('non-owner does not see private scheduled tasks in list', async () => { lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-')); lRepo = new Repository(join(lTempDir, 'db.sqlite')); const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); await lRepo.createScheduledTask({ title: 'alice-private', body: 'b', cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00', ownerId: alice.id, visibility: 'private', }); const bobUser: Express.User = { id: 'bob-id', email: 'b@x.com', name: 'b', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const res = await request(buildAppForUser(bobUser)).get('/api/scheduled-tasks'); expect(res.status).toBe(200); expect(res.body.tasks.map((t: { title: string }) => t.title)).not.toContain('alice-private'); }); it('owner sees own private scheduled tasks', async () => { lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-')); lRepo = new Repository(join(lTempDir, 'db.sqlite')); const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); const aliceUser: Express.User = { ...alice, orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; await lRepo.createScheduledTask({ title: 'alice-private', body: 'b', cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00', ownerId: alice.id, visibility: 'private', }); const res = await request(buildAppForUser(aliceUser)).get('/api/scheduled-tasks'); expect(res.status).toBe(200); expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private'); }); it('admin sees all scheduled tasks regardless of visibility', async () => { lTempDir = mkdtempSync(join(tmpdir(), 'sched-list-')); lRepo = new Repository(join(lTempDir, 'db.sqlite')); const alice = lRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); await lRepo.createScheduledTask({ title: 'alice-private', body: 'b', cronExpression: '0 9 * * *', nextRunAt: '2099-01-01 09:00:00', ownerId: alice.id, visibility: 'private', }); const adminUser: Express.User = { id: 'admin-id', email: 'admin@x.com', name: 'admin', avatarUrl: null, role: 'admin', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; const res = await request(buildAppForUser(adminUser)).get('/api/scheduled-tasks'); expect(res.status).toBe(200); expect(res.body.tasks.map((t: { title: string }) => t.title)).toContain('alice-private'); }); }); describe('POST /api/scheduled-tasks browserSessionProfileId owner check', () => { let bTempDir = ''; let bRepo: Repository; let bSessRepo: BrowserSessionRepo; let alice: { id: string }; let bob: { id: string }; let aliceProfileId: number; let bobProfileId: number; beforeEach(() => { bTempDir = mkdtempSync(join(tmpdir(), 'sched-bsp-')); bRepo = new Repository(join(bTempDir, 'db.sqlite')); bSessRepo = new BrowserSessionRepo(bRepo.getDb()); alice = bRepo.createUser({ email: 'a@x.com', name: 'a', role: 'user', status: 'active' }); bob = bRepo.createUser({ email: 'b@x.com', name: 'b', role: 'user', status: 'active' }); aliceProfileId = bSessRepo.createProfile({ ownerId: alice.id, label: 'alice-twitter', startUrl: 'https://twitter.com/home', matchPatterns: ['https://twitter.com/**'], storageOrigins: ['https://twitter.com'], loggedInSelector: null, loginUrlPatterns: [], }); bobProfileId = bSessRepo.createProfile({ ownerId: bob.id, label: 'bob-twitter', startUrl: 'https://twitter.com/home', matchPatterns: ['https://twitter.com/**'], storageOrigins: ['https://twitter.com'], loggedInSelector: null, loginUrlPatterns: [], }); }); afterEach(() => { bRepo.close(); rmSync(bTempDir, { recursive: true, force: true }); }); function buildAppForUser(user: Express.User): express.Application { const bScheduler = new Scheduler(bRepo, join(bTempDir, 'workspaces')); const bApp = express(); bApp.use(express.json()); bApp.use((req, _res, next) => { (req as unknown as { user: Express.User }).user = user; next(); }); mountScheduledTasksApi(bApp, bRepo, bScheduler, { sessRepo: bSessRepo }); return bApp; } function asUser(u: { id: string }, email: string): Express.User { return { id: u.id, email, name: 'x', avatarUrl: null, role: 'user', status: 'active', orgIds: [], defaultVisibility: 'private', defaultVisibilityOrgId: null, }; } it('accepts a valid profile owned by the requesting user (201)', async () => { const res = await request(buildAppForUser(asUser(alice, 'a@x.com'))) .post('/api/scheduled-tasks') .send({ body: 'hello', scheduleType: 'daily', hour: 9, browserSessionProfileId: aliceProfileId, }); expect(res.status).toBe(201); expect(res.body.task.browserSessionProfileId).toBe(aliceProfileId); }); it('rejects a profile owned by a different user (400)', async () => { const res = await request(buildAppForUser(asUser(alice, 'a@x.com'))) .post('/api/scheduled-tasks') .send({ body: 'hello', scheduleType: 'daily', hour: 9, browserSessionProfileId: bobProfileId, }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/not owned by you|not found/i); }); it('rejects a positive integer that does not match any profile (400)', async () => { const res = await request(buildAppForUser(asUser(alice, 'a@x.com'))) .post('/api/scheduled-tasks') .send({ body: 'hello', scheduleType: 'daily', hour: 9, browserSessionProfileId: 999999, }); expect(res.status).toBe(400); }); });