From bd5cf9a65148c681049615c742b4900f122ad909 Mon Sep 17 00:00:00 2001 From: Guy Nir Date: Thu, 16 Apr 2026 08:40:49 +0300 Subject: [PATCH] add logic tests for new action API --- .../production/scanResourcesFinish.js | 2 +- .../handlers/production/scanSurfaceFinish.js | 2 +- .../gameLogic/handlers/ship/assembleStart.js | 2 +- .../gameLogic/validators/stateMachine.js | 11 +- test/helpers/actionTestHelper.js | 444 +++++++++++++++++ test/src/api/controllers/actions/auth.spec.js | 138 ++++++ .../src/api/controllers/actions/batch.spec.js | 101 ++++ .../controllers/actions/construction.spec.js | 445 ++++++++++++++++++ test/src/api/controllers/actions/crew.spec.js | 375 +++++++++++++++ .../api/controllers/actions/delivery.spec.js | 174 +++++++ .../controllers/actions/extraction.spec.js | 267 +++++++++++ .../controllers/actions/marketplace.spec.js | 197 ++++++++ .../controllers/actions/processing.spec.js | 252 ++++++++++ .../api/controllers/actions/scanning.spec.js | 299 ++++++++++++ test/src/api/controllers/actions/ship.spec.js | 348 ++++++++++++++ 15 files changed, 3049 insertions(+), 8 deletions(-) create mode 100644 test/helpers/actionTestHelper.js create mode 100644 test/src/api/controllers/actions/auth.spec.js create mode 100644 test/src/api/controllers/actions/batch.spec.js create mode 100644 test/src/api/controllers/actions/construction.spec.js create mode 100644 test/src/api/controllers/actions/crew.spec.js create mode 100644 test/src/api/controllers/actions/delivery.spec.js create mode 100644 test/src/api/controllers/actions/extraction.spec.js create mode 100644 test/src/api/controllers/actions/marketplace.spec.js create mode 100644 test/src/api/controllers/actions/processing.spec.js create mode 100644 test/src/api/controllers/actions/scanning.spec.js create mode 100644 test/src/api/controllers/actions/ship.spec.js diff --git a/src/common/gameLogic/handlers/production/scanResourcesFinish.js b/src/common/gameLogic/handlers/production/scanResourcesFinish.js index edddfc7..d2cea69 100644 --- a/src/common/gameLogic/handlers/production/scanResourcesFinish.js +++ b/src/common/gameLogic/handlers/production/scanResourcesFinish.js @@ -35,7 +35,7 @@ class ScanResourcesFinishHandler extends BaseActionHandler { if (this.asteroid.Celestial?.scanStatus !== Asteroid.SCAN_STATUSES.RESOURCE_SCANNING) { throw new ValidationError('Asteroid is not currently resource scanning'); } - StateMachineValidator.assertFinished(this.asteroid.Celestial, 'Resource scan'); + StateMachineValidator.assertFinished(this.asteroid.Celestial, 'Resource scan', 'scanFinishTime'); } async applyStateChanges() { diff --git a/src/common/gameLogic/handlers/production/scanSurfaceFinish.js b/src/common/gameLogic/handlers/production/scanSurfaceFinish.js index 59a484e..3659373 100644 --- a/src/common/gameLogic/handlers/production/scanSurfaceFinish.js +++ b/src/common/gameLogic/handlers/production/scanSurfaceFinish.js @@ -35,7 +35,7 @@ class ScanSurfaceFinishHandler extends BaseActionHandler { if (this.asteroid.Celestial?.scanStatus !== Asteroid.SCAN_STATUSES.SURFACE_SCANNING) { throw new ValidationError('Asteroid is not currently surface scanning'); } - StateMachineValidator.assertFinished(this.asteroid.Celestial, 'Surface scan'); + StateMachineValidator.assertFinished(this.asteroid.Celestial, 'Surface scan', 'scanFinishTime'); } async applyStateChanges() { diff --git a/src/common/gameLogic/handlers/ship/assembleStart.js b/src/common/gameLogic/handlers/ship/assembleStart.js index e25906c..0519231 100644 --- a/src/common/gameLogic/handlers/ship/assembleStart.js +++ b/src/common/gameLogic/handlers/ship/assembleStart.js @@ -83,7 +83,7 @@ class AssembleShipStartHandler extends BaseActionHandler { data: { shipType: this.shipType, status: Ship.STATUSES.UNDER_CONSTRUCTION, - variant: Ship.getVariant(this.shipId), + variant: Ship.VARIANTS.STANDARD, readyAt: 0, emergencyAt: 0, transitDeparture: 0, diff --git a/src/common/gameLogic/validators/stateMachine.js b/src/common/gameLogic/validators/stateMachine.js index 28bbcd7..3ce916f 100644 --- a/src/common/gameLogic/validators/stateMachine.js +++ b/src/common/gameLogic/validators/stateMachine.js @@ -22,17 +22,18 @@ class StateMachineValidator { * Asserts a time-gated operation has finished (finishTime has passed). * Used for construction, extraction, processing, transit completions. * - * @param {object} component - Component with a finishTime field + * @param {object} component - Component with a finishTime (or custom) field * @param {string} [label] - Human-readable label for error messages + * @param {string} [field] - Field name containing the finish timestamp (default: 'finishTime') */ - static assertFinished(component, label = 'Component') { + static assertFinished(component, label = 'Component', field = 'finishTime') { if (!component) throw new ValidationError(`${label} not found`); - if (!component.finishTime) throw new ValidationError(`${label} has no finish time`); + if (!component[field]) throw new ValidationError(`${label} has no finish time`); const now = Math.floor(Date.now() / 1000); - if (component.finishTime > now) { + if (component[field] > now) { throw new ValidationError( - `${label} not finished yet (finishes at ${component.finishTime}, now ${now})` + `${label} not finished yet (finishes at ${component[field]}, now ${now})` ); } } diff --git a/test/helpers/actionTestHelper.js b/test/helpers/actionTestHelper.js new file mode 100644 index 0000000..b123e33 --- /dev/null +++ b/test/helpers/actionTestHelper.js @@ -0,0 +1,444 @@ +/** + * Integration test helper for hybrid-mode action endpoints. + * + * Provides seed data loading, JWT generation, supertest wrapper, + * and convenience constants matching the seed entities. + */ +const path = require('path'); +const mongoose = require('mongoose'); +const jwt = require('jsonwebtoken'); +const appConfig = require('config'); +const http = require('http'); +const request = require('supertest'); +const Koa = require('koa'); +const { Address } = require('@influenceth/sdk'); +const EntityLib = require('@common/lib/Entity'); + +// ─── Wallet / Auth ───────────────────────────────────────────────────────── +const WALLET_ADDRESS = Address.toStandard('0x0669B0254bce827409e794EB6146d355Ed0dE3A7306ab8E4CDA9ed8C5A48b09d'); +const WRONG_WALLET = Address.toStandard('0x0111111111111111111111111111111111111111111111111111111111111111'); + +function makeToken(address) { + return jwt.sign({ sub: address }, appConfig.get('App.jwtSecret')); +} + +const TOKEN = makeToken(WALLET_ADDRESS); +const WRONG_TOKEN = makeToken(WRONG_WALLET); + +// ─── Entity constants (matching seed data.json) ──────────────────────────── +const CREW_1 = { id: 1, label: 1 }; +const CREW_2 = { id: 2, label: 1 }; +const ASTEROID_1 = { id: 1, label: 3 }; +const ASTEROID_2 = { id: 2, label: 3 }; +const WAREHOUSE = { id: 1, label: 5 }; +const EXTRACTOR = { id: 2, label: 5 }; +const REFINERY = { id: 3, label: 5 }; +const FACTORY = { id: 4, label: 5 }; +const SHIPYARD = { id: 5, label: 5 }; +const BIOREACTOR = { id: 6, label: 5 }; +const MARKETPLACE_BLDG = { id: 7, label: 5 }; +const HABITAT = { id: 8, label: 5 }; +const SPACEPORT = { id: 9, label: 5 }; +const TANK_FARM = { id: 10, label: 5 }; +const SHIP_1 = { id: 1, label: 6 }; + +// Lot on asteroid 1 with no building (lotIndex 11) +// Lot id = (lotIndex << 32) | asteroidId = 11 * 2**32 + 1 +const EMPTY_LOT = { id: (11 * 4294967296) + 1, label: 4 }; + +// ─── Seed data loader ────────────────────────────────────────────────────── + +const SEED_DATA_PATH = path.resolve(__dirname, '../seed/data.json'); + +const COLLECTIONS_TO_CLEAR = [ + 'Constant', 'Entity', + 'NftComponent', 'CelestialComponent', 'OrbitComponent', 'NameComponent', + 'CrewComponent', 'CrewmateComponent', 'LocationComponent', 'ControlComponent', + 'BuildingComponent', 'ShipComponent', 'InventoryComponent', + 'StationComponent', 'DockComponent', 'ExtractorComponent', + 'ProcessorComponent', 'DryDockComponent', + 'DepositComponent', 'DeliveryComponent', + 'PublicPolicyComponent', 'WhitelistAgreementComponent', + 'PrepaidPolicyComponent', 'ContractPolicyComponent', + 'User', 'WorldFork', 'Activity' +]; + +function replaceWallet(obj, address) { + return JSON.parse(JSON.stringify(obj).replace(/"WALLET"/g, JSON.stringify(address))); +} + +async function clearCollections() { + for (const name of COLLECTIONS_TO_CLEAR) { + try { await mongoose.model(name).deleteMany({}); } catch (_) { /* model may not exist */ } + } + // Raw collections + const db = mongoose.connection.db; + try { await db.collection('events').deleteMany({}); } catch (_) {} + try { await db.collection('counters').deleteMany({}); } catch (_) {} +} + +async function loadSeedData(walletAddress = WALLET_ADDRESS) { + // Re-read each time so tests get fresh copy + delete require.cache[SEED_DATA_PATH]; + const seedData = require(SEED_DATA_PATH); // eslint-disable-line global-require + + // 0. Constants + for (const c of (seedData.constants || [])) { + await mongoose.model('Constant').findOneAndUpdate({ name: c.name }, c, { upsert: true, new: true }); + } + + // 1. Entities + for (const ent of seedData.entities) { + const uuid = EntityLib.toUuid(ent.id, ent.label); + await mongoose.model('Entity').updateOne({ uuid }, { $setOnInsert: { id: ent.id, label: ent.label, uuid } }, { upsert: true }); + } + + // 2. Nft + const nfts = replaceWallet(seedData.nftComponents, walletAddress); + for (const nft of nfts) { + await mongoose.model('NftComponent').findOneAndUpdate( + { 'entity.id': nft.entity.id, 'entity.label': nft.entity.label }, nft, { upsert: true, new: true } + ); + } + + // 3. Celestial + for (const c of seedData.celestialComponents) { + await mongoose.model('CelestialComponent').findOneAndUpdate( + { 'entity.id': c.entity.id, 'entity.label': c.entity.label }, c, { upsert: true, new: true } + ); + } + + // 4. Orbit + for (const o of seedData.orbitComponents) { + await mongoose.model('OrbitComponent').findOneAndUpdate( + { 'entity.id': o.entity.id, 'entity.label': o.entity.label }, o, { upsert: true, new: true } + ); + } + + // 5. Names + for (const n of seedData.nameComponents) { + await mongoose.model('NameComponent').findOneAndUpdate( + { 'entity.id': n.entity.id, 'entity.label': n.entity.label }, n, { upsert: true, new: true } + ); + } + + // 6. Crew + const crews = replaceWallet(seedData.crewComponents, walletAddress); + for (const c of crews) { + await mongoose.model('CrewComponent').findOneAndUpdate( + { 'entity.id': c.entity.id, 'entity.label': c.entity.label }, c, { upsert: true, new: true } + ); + } + + // 7. Crewmate + for (const c of seedData.crewmateComponents) { + await mongoose.model('CrewmateComponent').findOneAndUpdate( + { 'entity.id': c.entity.id, 'entity.label': c.entity.label }, c, { upsert: true, new: true } + ); + } + + // 8. Location + for (const l of seedData.locationComponents) { + await mongoose.model('LocationComponent').findOneAndUpdate( + { 'entity.id': l.entity.id, 'entity.label': l.entity.label }, + { entity: l.entity, location: l.location, locations: l.locations || [] }, + { upsert: true, new: true } + ); + } + + // 9. Control + for (const c of seedData.controlComponents) { + await mongoose.model('ControlComponent').findOneAndUpdate( + { 'entity.id': c.entity.id, 'entity.label': c.entity.label }, c, { upsert: true, new: true } + ); + } + + // 10. Building + for (const b of seedData.buildingComponents) { + await mongoose.model('BuildingComponent').findOneAndUpdate( + { 'entity.id': b.entity.id, 'entity.label': b.entity.label }, b, { upsert: true, new: true } + ); + } + + // 11. Ship + for (const s of (seedData.shipComponents || [])) { + await mongoose.model('ShipComponent').findOneAndUpdate( + { 'entity.id': s.entity.id, 'entity.label': s.entity.label }, s, { upsert: true, new: true } + ); + } + + // 12. Inventory + for (const inv of seedData.inventoryComponents) { + if (inv.contents && inv.contents.length > 0 && !inv.mass) { + inv.mass = inv.contents.reduce((sum, c) => sum + (c.amount || 0), 0); + } + await mongoose.model('InventoryComponent').findOneAndUpdate( + { 'entity.id': inv.entity.id, 'entity.label': inv.entity.label, slot: inv.slot }, + inv, { upsert: true, new: true } + ); + } + + // 13. Station + for (const s of seedData.stationComponents) { + await mongoose.model('StationComponent').findOneAndUpdate( + { 'entity.id': s.entity.id, 'entity.label': s.entity.label }, s, { upsert: true, new: true } + ); + } + + // 14. Dock + for (const d of (seedData.dockComponents || [])) { + await mongoose.model('DockComponent').findOneAndUpdate( + { 'entity.id': d.entity.id, 'entity.label': d.entity.label }, d, { upsert: true, new: true } + ); + } + + // 15. Extractor + for (const e of (seedData.extractorComponents || [])) { + await mongoose.model('ExtractorComponent').findOneAndUpdate( + { 'entity.id': e.entity.id, 'entity.label': e.entity.label, slot: e.slot }, e, { upsert: true, new: true } + ); + } + + // 16. Processor + for (const p of (seedData.processorComponents || [])) { + await mongoose.model('ProcessorComponent').findOneAndUpdate( + { 'entity.id': p.entity.id, 'entity.label': p.entity.label, slot: p.slot }, p, { upsert: true, new: true } + ); + } + + // 17. DryDock + for (const dd of (seedData.dryDockComponents || [])) { + await mongoose.model('DryDockComponent').findOneAndUpdate( + { 'entity.id': dd.entity.id, 'entity.label': dd.entity.label, slot: dd.slot }, dd, { upsert: true, new: true } + ); + } + + // 18. User + await mongoose.model('User').findOneAndUpdate( + { address: walletAddress }, + { $setOnInsert: { address: walletAddress } }, + { upsert: true, new: true } + ); + + // 19. WorldFork + const existingFork = await mongoose.model('WorldFork').findOne({}); + if (!existingFork) { + await mongoose.model('WorldFork').create({ + blockNumber: 0, blockHash: '0x0', blockTimestamp: new Date(), forkedAt: new Date(), label: 'test-seed' + }); + } +} + +async function resetSeedData(walletAddress = WALLET_ADDRESS) { + await clearCollections(); + await loadSeedData(walletAddress); +} + +// ─── Stubs ───────────────────────────────────────────────────────────────── + +/** + * Apply all necessary stubs for the action tests. + * Call in before() or beforeEach() — the caller's sandbox.restore() cleans up. + */ +function applyStubs(sandbox) { + // Stub mongoose sessions (MongoMemoryServer has no replica set by default). + // We create a fake session that GameEngine can call start/commit/abort on, + // but that evaluates to falsy when used as `{ session }` option in Mongoose + // operations (save, updateOne). The trick: BaseActionHandler stores + // `this.session = session` and passes it as `{ session: this.session }`. + // We intercept setSession so it stores null instead. + const fakeSession = { + startTransaction: () => {}, + commitTransaction: async () => {}, + abortTransaction: async () => {}, + endSession: () => {}, + inTransaction: () => false, + hasEnded: false + }; + sandbox.stub(mongoose, 'startSession').resolves(fakeSession); + + // Prevent the session from being passed to Mongoose operations. + // BaseActionHandler.setSession stores the session — we override it to store null. + const BaseActionHandler = require('@common/gameLogic/handlers/BaseActionHandler'); // eslint-disable-line global-require + const origSetSession = BaseActionHandler.prototype.setSession; + if (!origSetSession._stubbed) { + sandbox.stub(BaseActionHandler.prototype, 'setSession').callsFake(function () { + this.session = null; + }); + BaseActionHandler.prototype.setSession._stubbed = true; + } + + // Stub Socket.IO emitter (requires Redis) + const emitter = require('@common/lib/sio/emitter'); // eslint-disable-line global-require + if (!emitter.emitTo?.isSinonProxy) sandbox.stub(emitter, 'emitTo').resolves(); + if (!emitter.broadcast?.isSinonProxy) sandbox.stub(emitter, 'broadcast').resolves(); + + // Stub ElasticSearch indexing + const { ElasticSearchService } = require('@common/services'); // eslint-disable-line global-require + if (ElasticSearchService?.queueEntityForIndexing && !ElasticSearchService.queueEntityForIndexing.isSinonProxy) { + sandbox.stub(ElasticSearchService, 'queueEntityForIndexing').resolves(); + } +} + +// ─── Server builder ──────────────────────────────────────────────────────── + +let _cachedServer = null; + +function buildActionServer() { + if (_cachedServer) return _cachedServer; + + const actionsRouter = require('@api/controllers/actions'); // eslint-disable-line global-require + const usersRouter = require('@api/controllers/users'); // eslint-disable-line global-require + const app = new Koa(); + app.use(actionsRouter.routes()); + app.use(actionsRouter.allowedMethods()); + app.use(usersRouter.routes()); + app.use(usersRouter.allowedMethods()); + + const server = request(http.createServer(app.callback())); + _cachedServer = server; + return server; +} + +// ─── POST convenience ────────────────────────────────────────────────────── + +function postAction(server, token, actionName, vars, meta) { + const body = { callerCrew: vars?.caller_crew, vars, meta }; + return server + .post(`/v2/actions/${actionName}`) + .set('Authorization', `Bearer ${token}`) + .send(body); +} + +// ─── State mutation helpers ──────────────────────────────────────────────── + +async function setBuildingStatus(buildingId, status, finishTime = 0) { + await mongoose.model('BuildingComponent').updateOne( + { 'entity.id': buildingId, 'entity.label': 5 }, + { $set: { status, finishTime } } + ); +} + +async function setCrewBusy(crewId, readyAt) { + await mongoose.model('CrewComponent').updateOne( + { 'entity.id': crewId, 'entity.label': 1 }, + { $set: { readyAt } } + ); +} + +async function createEmptyLot(asteroidId, lotIndex) { + const lotId = (lotIndex * 4294967296) + asteroidId; + const uuid = EntityLib.toUuid(lotId, 4); + await mongoose.model('Entity').updateOne({ uuid }, { $setOnInsert: { id: lotId, label: 4, uuid } }, { upsert: true }); + const asteroidUuid = EntityLib.toUuid(asteroidId, 3); + await mongoose.model('LocationComponent').findOneAndUpdate( + { 'entity.id': lotId, 'entity.label': 4 }, + { + entity: { id: lotId, label: 4 }, + location: { id: asteroidId, label: 3 }, + locations: [{ id: asteroidId, label: 3, uuid: asteroidUuid }] + }, + { upsert: true, new: true } + ); + return { id: lotId, label: 4 }; +} + +async function createUnscannedAsteroid(id) { + const uuid = EntityLib.toUuid(id, 3); + await mongoose.model('Entity').updateOne({ uuid }, { $setOnInsert: { id, label: 3, uuid } }, { upsert: true }); + await mongoose.model('NftComponent').findOneAndUpdate( + { 'entity.id': id, 'entity.label': 3 }, + { entity: { id, label: 3 }, nftType: 0, owners: { starknet: WALLET_ADDRESS } }, + { upsert: true, new: true } + ); + await mongoose.model('CelestialComponent').findOneAndUpdate( + { 'entity.id': id, 'entity.label': 3 }, + { + entity: { id, label: 3 }, + celestialType: 1, mass: 1000000000, radius: 200000, + purchaseOrder: 0, scanStatus: 0, scanFinishTime: 0, bonuses: 0, abundances: '' + }, + { upsert: true, new: true } + ); + await mongoose.model('ControlComponent').findOneAndUpdate( + { 'entity.id': id, 'entity.label': 3 }, + { entity: { id, label: 3 }, controller: { id: 1, label: 1 } }, + { upsert: true, new: true } + ); + return { id, label: 3 }; +} + +async function createSampledDeposit(id, { resource = 1, remainingYield = 5000, lotId, asteroidId = 1 } = {}) { + const uuid = EntityLib.toUuid(id, 7); // 7 = DEPOSIT + await mongoose.model('Entity').updateOne({ uuid }, { $setOnInsert: { id, label: 7, uuid } }, { upsert: true }); + + await mongoose.model('DepositComponent').findOneAndUpdate( + { 'entity.id': id, 'entity.label': 7 }, + { + entity: { id, label: 7 }, + resource, + status: 2, // SAMPLED + initialYield: remainingYield, + remainingYield, + yieldEff: 1, + finishTime: 0 + }, + { upsert: true, new: true } + ); + + // Place deposit on a lot on the asteroid + const depositLotId = lotId || ((20 * 4294967296) + asteroidId); + const lotUuid = EntityLib.toUuid(depositLotId, 4); + const asteroidUuid = EntityLib.toUuid(asteroidId, 3); + await mongoose.model('LocationComponent').findOneAndUpdate( + { 'entity.id': id, 'entity.label': 7 }, + { + entity: { id, label: 7 }, + location: { id: depositLotId, label: 4 }, + locations: [ + { id: depositLotId, label: 4, uuid: lotUuid }, + { id: asteroidId, label: 3, uuid: asteroidUuid } + ] + }, + { upsert: true, new: true } + ); + + return { id, label: 7 }; +} + +// ─── Exports ─────────────────────────────────────────────────────────────── + +module.exports = { + // Auth + WALLET_ADDRESS, + WRONG_WALLET, + TOKEN, + WRONG_TOKEN, + makeToken, + + // Entity refs + CREW_1, CREW_2, + ASTEROID_1, ASTEROID_2, + WAREHOUSE, EXTRACTOR, REFINERY, FACTORY, SHIPYARD, + BIOREACTOR, MARKETPLACE_BLDG, HABITAT, SPACEPORT, TANK_FARM, + SHIP_1, EMPTY_LOT, + + // Seed + loadSeedData, + resetSeedData, + clearCollections, + + // Server + buildActionServer, + postAction, + + // Stubs + applyStubs, + + // Helpers + setBuildingStatus, + setCrewBusy, + createEmptyLot, + createUnscannedAsteroid, + createSampledDeposit +}; diff --git a/test/src/api/controllers/actions/auth.spec.js b/test/src/api/controllers/actions/auth.spec.js new file mode 100644 index 0000000..c27cf5d --- /dev/null +++ b/test/src/api/controllers/actions/auth.spec.js @@ -0,0 +1,138 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, WAREHOUSE, + buildActionServer, postAction, applyStubs, + resetSeedData +} = require('@test/helpers/actionTestHelper'); + +describe('Actions endpoint – auth & validation', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ── Authentication ───────────────────────────────────────────── + + it('returns 401 without a token', async function () { + const res = await server + .post('/v2/actions/ConstructionPlan') + .send({ callerCrew: CREW_1, vars: {} }); + + expect(res.status).to.equal(401); + }); + + it('returns 401 with an invalid token', async function () { + const res = await server + .post('/v2/actions/ConstructionPlan') + .set('Authorization', 'Bearer totally-bogus-jwt') + .send({ callerCrew: CREW_1, vars: {} }); + + expect(res.status).to.equal(401); + }); + + // ── Input validation ─────────────────────────────────────────── + + it('returns 400 for an invalid action name (special chars)', async function () { + const res = await server + .post('/v2/actions/not-valid!') + .set('Authorization', `Bearer ${TOKEN}`) + .send({ vars: {} }); + + expect(res.status).to.equal(400); + expect(res.text).to.include('Invalid action name'); + }); + + it('returns 400 when callerCrew is not an object', async function () { + const res = await server + .post('/v2/actions/ConstructionPlan') + .set('Authorization', `Bearer ${TOKEN}`) + .send({ callerCrew: [1, 2], vars: {} }); + + expect(res.status).to.equal(400); + expect(res.body.error || res.text).to.include('callerCrew must be an object'); + }); + + it('returns 400 when vars is a string', async function () { + const res = await server + .post('/v2/actions/ConstructionPlan') + .set('Authorization', `Bearer ${TOKEN}`) + .send({ callerCrew: CREW_1, vars: 'bad' }); + + expect(res.status).to.equal(400); + expect(res.body.error || res.text).to.include('vars must be an object'); + }); + + it('returns 400 when meta is an array', async function () { + const res = await server + .post('/v2/actions/ConstructionPlan') + .set('Authorization', `Bearer ${TOKEN}`) + .send({ callerCrew: CREW_1, vars: {}, meta: [1] }); + + expect(res.status).to.equal(400); + expect(res.body.error || res.text).to.include('meta must be an object'); + }); + + it('returns 400 for an unknown action name', async function () { + const res = await postAction(server, TOKEN, 'TotallyFakeAction', { + caller_crew: CREW_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Unknown action'); + }); + + // ── Successful request ───────────────────────────────────────── + + it('returns 200 for a valid action request', async function () { + // Use ConstructionAbandon on a PLANNED building — first set one up + await mongoose.model('BuildingComponent').updateOne( + { 'entity.id': WAREHOUSE.id, 'entity.label': 5 }, + { $set: { status: 1 } } // PLANNED + ); + + const res = await postAction(server, TOKEN, 'ConstructionAbandon', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // Restore + await mongoose.model('BuildingComponent').updateOne( + { 'entity.id': WAREHOUSE.id, 'entity.label': 5 }, + { $set: { status: 3 } } // OPERATIONAL + ); + }); + + // ── Wrong owner ──────────────────────────────────────────────── + + it('returns 400 when caller does not control the crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 1, + lot: { id: 99999 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); +}); diff --git a/test/src/api/controllers/actions/batch.spec.js b/test/src/api/controllers/actions/batch.spec.js new file mode 100644 index 0000000..a5458db --- /dev/null +++ b/test/src/api/controllers/actions/batch.spec.js @@ -0,0 +1,101 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { Building } = require('@influenceth/sdk'); +const { + TOKEN, + CREW_1, WAREHOUSE, + buildActionServer, postAction, applyStubs, + resetSeedData, setBuildingStatus +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Batch/Virtual actions', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // FinishAllReady + // ═══════════════════════════════════════════════════════════════ + + describe('FinishAllReady', function () { + it('decomposes into individual finish calls', async function () { + // Set warehouse to UNDER_CONSTRUCTION with past finishTime + const pastTime = Math.floor(Date.now() / 1000) - 100; + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.UNDER_CONSTRUCTION, pastTime); + + const res = await postAction(server, TOKEN, 'FinishAllReady', { + caller_crew: CREW_1, + finishCalls: [ + { + key: 'ConstructionFinish', + vars: { + caller_crew: CREW_1, + building: WAREHOUSE + } + } + ] + }); + + expect(res.status).to.equal(200); + + // Verify: building is now OPERATIONAL + const bldg = await mongoose.model('BuildingComponent').findOne({ + 'entity.id': WAREHOUSE.id, 'entity.label': 5 + }).lean(); + expect(bldg.status).to.equal(Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('handles empty finishCalls array', async function () { + const res = await postAction(server, TOKEN, 'FinishAllReady', { + caller_crew: CREW_1, + finishCalls: [] + }); + + // Empty finishCalls returns empty results + expect(res.status).to.equal(200); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // Alias actions + // ═══════════════════════════════════════════════════════════════ + + describe('Alias actions', function () { + it('FlexibleExtractResourceStart resolves to ExtractResourceStart', async function () { + // Should fail validation just like ExtractResourceStart would + const res = await postAction(server, TOKEN, 'FlexibleExtractResourceStart', { + caller_crew: CREW_1 + // missing extractor, deposit, etc. + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('extractor'); + }); + + it('rejects unknown action names', async function () { + const res = await postAction(server, TOKEN, 'CompletelyFakeAction', { + caller_crew: CREW_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Unknown action'); + }); + }); +}); diff --git a/test/src/api/controllers/actions/construction.spec.js b/test/src/api/controllers/actions/construction.spec.js new file mode 100644 index 0000000..3fc7d43 --- /dev/null +++ b/test/src/api/controllers/actions/construction.spec.js @@ -0,0 +1,445 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { Building } = require('@influenceth/sdk'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, CREW_2, ASTEROID_1, WAREHOUSE, EMPTY_LOT, + buildActionServer, postAction, applyStubs, + resetSeedData, setBuildingStatus, setCrewBusy, + createEmptyLot +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Construction lifecycle', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // ConstructionPlan + // ═══════════════════════════════════════════════════════════════ + + describe('ConstructionPlan', function () { + it('plans a building on an empty lot → PLANNED status', async function () { + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 1, // Warehouse + lot: EMPTY_LOT + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // The event returnValues should include the new building ref + const rv = res.body.event.returnValues; + expect(rv.building).to.have.property('id'); + expect(rv.building.id).to.be.greaterThan(100000000); // LOCAL_ID_OFFSET + expect(rv.buildingType).to.equal(1); + expect(rv.lot.id).to.equal(EMPTY_LOT.id); + + // Verify DB state: BuildingComponent exists with PLANNED status + const bldgComp = await mongoose.model('BuildingComponent').findOne({ + 'entity.id': rv.building.id, 'entity.label': 5 + }).lean(); + expect(bldgComp).to.exist; + expect(bldgComp.status).to.equal(Building.CONSTRUCTION_STATUSES.PLANNED); + expect(bldgComp.buildingType).to.equal(1); + + // Verify: Entity was created + const entity = await mongoose.model('Entity').findOne({ + id: rv.building.id, label: 5 + }).lean(); + expect(entity).to.exist; + + // Verify: InventoryComponent created (site inventory) + const inv = await mongoose.model('InventoryComponent').findOne({ + 'entity.id': rv.building.id, 'entity.label': 5 + }).lean(); + expect(inv).to.exist; + expect(inv.status).to.equal(1); // AVAILABLE + + // Cleanup: remove the planned building + await mongoose.model('BuildingComponent').deleteOne({ 'entity.id': rv.building.id }); + await mongoose.model('Entity').deleteOne({ id: rv.building.id, label: 5 }); + await mongoose.model('InventoryComponent').deleteMany({ 'entity.id': rv.building.id }); + await mongoose.model('LocationComponent').deleteOne({ 'entity.id': rv.building.id }); + await mongoose.model('ControlComponent').deleteOne({ 'entity.id': rv.building.id }); + await mongoose.model('NameComponent').deleteOne({ 'entity.id': rv.building.id }); + }); + + it('rejects when building_type is missing', async function () { + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + lot: EMPTY_LOT + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('building_type is required'); + }); + + it('rejects when caller_crew is missing', async function () { + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + building_type: 1, + lot: EMPTY_LOT + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('caller_crew'); + }); + + it('rejects when lot is missing', async function () { + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('lot'); + }); + + it('rejects invalid building type', async function () { + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 999, + lot: EMPTY_LOT + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Invalid building type'); + }); + + it('rejects when lot already has a building', async function () { + // Lot id for building 1 (Warehouse) is at lotIndex=1 on asteroid 1 + const occupiedLot = { id: (1 * 4294967296) + 1, label: 4 }; + + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 1, + lot: occupiedLot + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('already has a building'); + }); + + it('rejects when caller does not control the crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 1, + lot: EMPTY_LOT + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'ConstructionPlan', { + caller_crew: CREW_1, + building_type: 1, + lot: EMPTY_LOT + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + // Restore + await setCrewBusy(CREW_1.id, 0); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ConstructionStart + // ═══════════════════════════════════════════════════════════════ + + describe('ConstructionStart', function () { + it('starts construction on a PLANNED building → UNDER_CONSTRUCTION', async function () { + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.PLANNED); + + const res = await postAction(server, TOKEN, 'ConstructionStart', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + expect(res.body.event.returnValues.finishTime).to.be.a('number'); + expect(res.body.event.returnValues.finishTime).to.be.greaterThan( + Math.floor(Date.now() / 1000) + ); + + // Verify DB: status is UNDER_CONSTRUCTION + const bldg = await mongoose.model('BuildingComponent').findOne({ + 'entity.id': WAREHOUSE.id, 'entity.label': 5 + }).lean(); + expect(bldg.status).to.equal(Building.CONSTRUCTION_STATUSES.UNDER_CONSTRUCTION); + expect(bldg.finishTime).to.be.greaterThan(0); + + // Restore + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects when building is not in PLANNED status', async function () { + // Warehouse is OPERATIONAL by default + const res = await postAction(server, TOKEN, 'ConstructionStart', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('status'); + }); + + it('rejects when building is not found', async function () { + const res = await postAction(server, TOKEN, 'ConstructionStart', { + caller_crew: CREW_1, + building: { id: 999999, label: 5 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Building not found'); + }); + + it('rejects when caller does not control the building', async function () { + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.PLANNED); + + const res = await postAction(server, WRONG_TOKEN, 'ConstructionStart', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects missing vars.building', async function () { + const res = await postAction(server, TOKEN, 'ConstructionStart', { + caller_crew: CREW_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('building'); + }); + + it('rejects when crew is busy', async function () { + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.PLANNED); + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'ConstructionStart', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ConstructionFinish + // ═══════════════════════════════════════════════════════════════ + + describe('ConstructionFinish', function () { + it('finishes construction when finishTime has passed → OPERATIONAL', async function () { + const pastTime = Math.floor(Date.now() / 1000) - 100; + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.UNDER_CONSTRUCTION, pastTime); + + const res = await postAction(server, TOKEN, 'ConstructionFinish', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // Verify DB: building is OPERATIONAL + const bldg = await mongoose.model('BuildingComponent').findOne({ + 'entity.id': WAREHOUSE.id, 'entity.label': 5 + }).lean(); + expect(bldg.status).to.equal(Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects when building is not UNDER_CONSTRUCTION', async function () { + // Warehouse is OPERATIONAL + const res = await postAction(server, TOKEN, 'ConstructionFinish', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('status'); + }); + + it('rejects when finishTime has not passed', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.UNDER_CONSTRUCTION, futureTime); + + const res = await postAction(server, TOKEN, 'ConstructionFinish', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not finished'); + + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects when caller does not own the crew', async function () { + const pastTime = Math.floor(Date.now() / 1000) - 100; + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.UNDER_CONSTRUCTION, pastTime); + + const res = await postAction(server, WRONG_TOKEN, 'ConstructionFinish', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ConstructionDeconstruct + // ═══════════════════════════════════════════════════════════════ + + describe('ConstructionDeconstruct', function () { + it('deconstructs OPERATIONAL building → PLANNED', async function () { + const res = await postAction(server, TOKEN, 'ConstructionDeconstruct', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // Verify DB + const bldg = await mongoose.model('BuildingComponent').findOne({ + 'entity.id': WAREHOUSE.id, 'entity.label': 5 + }).lean(); + expect(bldg.status).to.equal(Building.CONSTRUCTION_STATUSES.PLANNED); + expect(bldg.finishTime).to.equal(0); + + // Restore + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects when building is not OPERATIONAL', async function () { + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.PLANNED); + + const res = await postAction(server, TOKEN, 'ConstructionDeconstruct', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('status'); + + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects when caller does not control the building', async function () { + const res = await postAction(server, WRONG_TOKEN, 'ConstructionDeconstruct', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'ConstructionDeconstruct', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ConstructionAbandon + // ═══════════════════════════════════════════════════════════════ + + describe('ConstructionAbandon', function () { + it('abandons a PLANNED building → UNPLANNED', async function () { + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.PLANNED); + + const res = await postAction(server, TOKEN, 'ConstructionAbandon', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // Verify DB + const bldg = await mongoose.model('BuildingComponent').findOne({ + 'entity.id': WAREHOUSE.id, 'entity.label': 5 + }).lean(); + expect(bldg.status).to.equal(Building.CONSTRUCTION_STATUSES.UNPLANNED); + + // Restore + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + + it('rejects when building is not PLANNED', async function () { + // Warehouse is OPERATIONAL + const res = await postAction(server, TOKEN, 'ConstructionAbandon', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('status'); + }); + + it('rejects when caller does not control the building', async function () { + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.PLANNED); + + const res = await postAction(server, WRONG_TOKEN, 'ConstructionAbandon', { + caller_crew: CREW_1, + building: WAREHOUSE + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + + await setBuildingStatus(WAREHOUSE.id, Building.CONSTRUCTION_STATUSES.OPERATIONAL); + }); + }); +}); diff --git a/test/src/api/controllers/actions/crew.spec.js b/test/src/api/controllers/actions/crew.spec.js new file mode 100644 index 0000000..ca3c187 --- /dev/null +++ b/test/src/api/controllers/actions/crew.spec.js @@ -0,0 +1,375 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, CREW_2, WAREHOUSE, HABITAT, + buildActionServer, postAction, applyStubs, + resetSeedData, setCrewBusy +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Crew operations', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // StationCrew + // ═══════════════════════════════════════════════════════════════ + + describe('StationCrew', function () { + it('moves crew to a new destination', async function () { + // Move crew 1 from habitat (building 8) to warehouse (building 1) + const res = await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label } + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + expect(res.body.event.returnValues.destinationStation.id).to.equal(WAREHOUSE.id); + + // Verify DB: crew location updated + const loc = await mongoose.model('LocationComponent').findOne({ + 'entity.id': CREW_1.id, 'entity.label': 1 + }).lean(); + expect(loc.location.id).to.equal(WAREHOUSE.id); + + // Restore: move back to habitat + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: HABITAT.id, label: HABITAT.label } + }); + }); + + it('rejects when destination does not exist', async function () { + const res = await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: 999999, label: 5 } + }); + + expect(res.status).to.equal(400); + // May fail with "Destination not found" or "Permission denied" depending on lookup order + expect(res.body.error).to.be.a('string'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + + it('rejects when caller does not own the crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when destination is missing id or label', async function () { + const res = await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: 1 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('destination'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // EjectCrew + // ═══════════════════════════════════════════════════════════════ + + describe('EjectCrew', function () { + it('ejects crew 2 to the asteroid', async function () { + // Both crews are at habitat (building 8). Crew 1 controls the habitat. + const res = await postAction(server, TOKEN, 'EjectCrew', { + caller_crew: CREW_1, + ejected_crew: CREW_2 + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // Verify DB: ejected crew is now at the asteroid level + const loc = await mongoose.model('LocationComponent').findOne({ + 'entity.id': CREW_2.id, 'entity.label': 1 + }).lean(); + expect(loc.location.label).to.equal(3); // Asteroid + + // Restore: station crew 2 back at habitat + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_2, + destination: { id: HABITAT.id, label: HABITAT.label } + }); + }); + + it('rejects when ejected crew does not exist', async function () { + const res = await postAction(server, TOKEN, 'EjectCrew', { + caller_crew: CREW_1, + ejected_crew: { id: 999999, label: 1 } + }); + + expect(res.status).to.equal(400); + // Fails with either "Ejected crew not found" or "Crews must be stationed" + expect(res.body.error).to.be.a('string'); + }); + + it('rejects when crews are not at the same station', async function () { + // Move crew 2 to warehouse first so they're at different stations + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_2, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label } + }); + + const res = await postAction(server, TOKEN, 'EjectCrew', { + caller_crew: CREW_1, + ejected_crew: CREW_2 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not at the same station'); + + // Restore + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_2, + destination: { id: HABITAT.id, label: HABITAT.label } + }); + }); + + it('rejects when caller does not control the station', async function () { + const res = await postAction(server, WRONG_TOKEN, 'EjectCrew', { + caller_crew: CREW_1, + ejected_crew: CREW_2 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ArrangeCrew + // ═══════════════════════════════════════════════════════════════ + + describe('ArrangeCrew', function () { + it('reorders the crew roster', async function () { + // Crew 1 has roster [1,2,3] — rearrange to [3,1,2] + const res = await postAction(server, TOKEN, 'ArrangeCrew', { + caller_crew: CREW_1, + composition: [3, 1, 2] + }); + + expect(res.status).to.equal(200); + expect(res.body.event.returnValues.compositionNew).to.deep.equal([3, 1, 2]); + + // Verify DB + const crew = await mongoose.model('CrewComponent').findOne({ + 'entity.id': CREW_1.id, 'entity.label': 1 + }).lean(); + expect(crew.roster).to.deep.equal([3, 1, 2]); + + // Restore + await postAction(server, TOKEN, 'ArrangeCrew', { + caller_crew: CREW_1, + composition: [1, 2, 3] + }); + }); + + it('rejects when composition contains different crewmates', async function () { + const res = await postAction(server, TOKEN, 'ArrangeCrew', { + caller_crew: CREW_1, + composition: [1, 2, 99] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('same crewmates'); + }); + + it('rejects when composition is empty', async function () { + const res = await postAction(server, TOKEN, 'ArrangeCrew', { + caller_crew: CREW_1, + composition: [] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('non-empty'); + }); + + it('rejects when caller does not own crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'ArrangeCrew', { + caller_crew: CREW_1, + composition: [3, 1, 2] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ExchangeCrew + // ═══════════════════════════════════════════════════════════════ + + describe('ExchangeCrew', function () { + it('swaps crewmates between two crews at the same location', async function () { + // Ensure both crews are at the same location (habitat) + await mongoose.model('LocationComponent').updateOne( + { 'entity.id': CREW_2.id, 'entity.label': 1 }, + { $set: { 'location.id': HABITAT.id, 'location.label': HABITAT.label } } + ); + + // Crew 1 roster: [1,2,3], Crew 2 roster: [4,5] + // Swap: crew1 gets [1,4], crew2 gets [2,3,5] + const res = await postAction(server, TOKEN, 'ExchangeCrew', { + crew1: CREW_1, + comp1: [1, 4], + _crew2: CREW_2, + comp2: [2, 3, 5], + caller_crew: CREW_1 + }); + + expect(res.status).to.equal(200); + + // Verify DB + const c1 = await mongoose.model('CrewComponent').findOne({ + 'entity.id': CREW_1.id, 'entity.label': 1 + }).lean(); + const c2 = await mongoose.model('CrewComponent').findOne({ + 'entity.id': CREW_2.id, 'entity.label': 1 + }).lean(); + expect(c1.roster.map(Number).sort()).to.deep.equal([1, 4]); + expect(c2.roster.map(Number).sort()).to.deep.equal([2, 3, 5]); + + // Restore + await postAction(server, TOKEN, 'ExchangeCrew', { + crew1: CREW_1, + comp1: [1, 2, 3], + _crew2: CREW_2, + comp2: [4, 5], + caller_crew: CREW_1 + }); + }); + + it('rejects when crewmates are not redistributed correctly', async function () { + // Ensure both crews are at the same location first + await mongoose.model('LocationComponent').updateOne( + { 'entity.id': CREW_2.id, 'entity.label': 1 }, + { $set: { 'location.id': HABITAT.id, 'location.label': HABITAT.label } } + ); + + const res = await postAction(server, TOKEN, 'ExchangeCrew', { + crew1: CREW_1, + comp1: [1, 2], + _crew2: CREW_2, + comp2: [4, 5, 99], + caller_crew: CREW_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('same crewmates'); + }); + + it('rejects when crews are at different locations', async function () { + // Directly set crew 2 to warehouse via DB to avoid side effect entanglement + await mongoose.model('LocationComponent').updateOne( + { 'entity.id': CREW_2.id, 'entity.label': 1 }, + { $set: { 'location.id': WAREHOUSE.id, 'location.label': WAREHOUSE.label } } + ); + + const res = await postAction(server, TOKEN, 'ExchangeCrew', { + crew1: CREW_1, + comp1: [1, 4], + _crew2: CREW_2, + comp2: [2, 3, 5], + caller_crew: CREW_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('same location'); + + // Restore + await mongoose.model('LocationComponent').updateOne( + { 'entity.id': CREW_2.id, 'entity.label': 1 }, + { $set: { 'location.id': HABITAT.id, 'location.label': HABITAT.label } } + ); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ResupplyFood + // ═══════════════════════════════════════════════════════════════ + + describe('ResupplyFood', function () { + it('updates crew lastFed timestamp', async function () { + const res = await postAction(server, TOKEN, 'ResupplyFood', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + food: 100 + }); + + expect(res.status).to.equal(200); + expect(res.body.event.returnValues.lastFed).to.be.a('number'); + expect(res.body.event.returnValues.food).to.equal(100); + + // Verify DB + const crew = await mongoose.model('CrewComponent').findOne({ + 'entity.id': CREW_1.id, 'entity.label': 1 + }).lean(); + expect(crew.lastFed).to.be.greaterThan(0); + }); + + it('rejects when food is zero or negative', async function () { + const res = await postAction(server, TOKEN, 'ResupplyFood', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + food: 0 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('positive'); + }); + + it('rejects when origin is missing', async function () { + const res = await postAction(server, TOKEN, 'ResupplyFood', { + caller_crew: CREW_1, + origin_slot: 1, + food: 100 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('origin'); + }); + }); +}); diff --git a/test/src/api/controllers/actions/delivery.spec.js b/test/src/api/controllers/actions/delivery.spec.js new file mode 100644 index 0000000..c235974 --- /dev/null +++ b/test/src/api/controllers/actions/delivery.spec.js @@ -0,0 +1,174 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, WAREHOUSE, EXTRACTOR, + buildActionServer, postAction, applyStubs, + resetSeedData, setCrewBusy +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Delivery operations', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // SendDelivery + // ═══════════════════════════════════════════════════════════════ + + describe('SendDelivery', function () { + it('creates a delivery from warehouse to extractor', async function () { + const res = await postAction(server, TOKEN, 'SendDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [{ product: 1, amount: 100 }] + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + const rv = res.body.event.returnValues; + expect(rv.delivery).to.have.property('id'); + expect(rv.delivery.id).to.be.greaterThan(100000000); + }); + + it('rejects when products array is empty', async function () { + const res = await postAction(server, TOKEN, 'SendDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('non-empty'); + }); + + it('rejects when origin is missing', async function () { + const res = await postAction(server, TOKEN, 'SendDelivery', { + caller_crew: CREW_1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [{ product: 1, amount: 100 }] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('origin'); + }); + + it('rejects when destination is missing', async function () { + const res = await postAction(server, TOKEN, 'SendDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + products: [{ product: 1, amount: 100 }] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('dest'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'SendDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [{ product: 1, amount: 100 }] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'SendDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [{ product: 1, amount: 100 }] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // PackageDelivery + // ═══════════════════════════════════════════════════════════════ + + describe('PackageDelivery', function () { + it('creates a packaged delivery', async function () { + const res = await postAction(server, TOKEN, 'PackageDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [{ product: 1, amount: 50 }], + price: 0 + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + }); + + it('rejects when products is empty', async function () { + const res = await postAction(server, TOKEN, 'PackageDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('non-empty'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'PackageDelivery', { + caller_crew: CREW_1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + dest: { id: EXTRACTOR.id, label: EXTRACTOR.label }, + dest_slot: 1, + products: [{ product: 1, amount: 50 }] + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); +}); diff --git a/test/src/api/controllers/actions/extraction.spec.js b/test/src/api/controllers/actions/extraction.spec.js new file mode 100644 index 0000000..92b8dd9 --- /dev/null +++ b/test/src/api/controllers/actions/extraction.spec.js @@ -0,0 +1,267 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { Extractor } = require('@influenceth/sdk'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, EXTRACTOR, WAREHOUSE, + buildActionServer, postAction, applyStubs, + resetSeedData, setCrewBusy, createSampledDeposit +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Extraction', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // ExtractResourceStart + // ═══════════════════════════════════════════════════════════════ + + describe('ExtractResourceStart', function () { + it('starts extraction on a sampled deposit', async function () { + const deposit = await createSampledDeposit(500, { resource: 1, remainingYield: 5000 }); + + const res = await postAction(server, TOKEN, 'ExtractResourceStart', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1, + deposit, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1, + yield: 1000 + }); + + expect(res.status).to.equal(200); + expect(res.body.event.returnValues.finishTime).to.be.greaterThan( + Math.floor(Date.now() / 1000) + ); + expect(res.body.event.returnValues.yield).to.equal(1000); + + // Verify DB: Extractor is RUNNING + const ext = await mongoose.model('ExtractorComponent').findOne({ + 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 + }).lean(); + expect(ext.status).to.equal(Extractor.STATUSES.RUNNING); + expect(ext.yield).to.equal(1000); + expect(ext.finishTime).to.be.greaterThan(0); + + // Verify: deposit remainingYield decreased + const dep = await mongoose.model('DepositComponent').findOne({ + 'entity.id': 500, 'entity.label': 7 + }).lean(); + expect(dep.remainingYield).to.equal(4000); + + // Cleanup: reset extractor to idle + await mongoose.model('ExtractorComponent').updateOne( + { 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 }, + { $set: { status: 0, yield: 0, finishTime: 0 } } + ); + }); + + it('rejects when deposit is not sampled', async function () { + // Create an UNDISCOVERED deposit + await createSampledDeposit(501); + await mongoose.model('DepositComponent').updateOne( + { 'entity.id': 501, 'entity.label': 7 }, + { $set: { status: 0 } } // UNDISCOVERED + ); + + const res = await postAction(server, TOKEN, 'ExtractResourceStart', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1, + deposit: { id: 501, label: 7 }, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1, + yield: 100 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('sampled'); + }); + + it('rejects when yield exceeds remaining', async function () { + const deposit = await createSampledDeposit(502, { remainingYield: 100 }); + + const res = await postAction(server, TOKEN, 'ExtractResourceStart', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1, + deposit, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1, + yield: 200 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('exceeds'); + }); + + it('rejects when yield is zero or negative', async function () { + const deposit = await createSampledDeposit(503); + + const res = await postAction(server, TOKEN, 'ExtractResourceStart', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1, + deposit, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1, + yield: 0 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('positive'); + }); + + it('rejects when caller does not control crew', async function () { + const deposit = await createSampledDeposit(504); + + const res = await postAction(server, WRONG_TOKEN, 'ExtractResourceStart', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1, + deposit, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1, + yield: 100 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + const deposit = await createSampledDeposit(505); + + const res = await postAction(server, TOKEN, 'ExtractResourceStart', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1, + deposit, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1, + yield: 100 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ExtractResourceFinish + // ═══════════════════════════════════════════════════════════════ + + describe('ExtractResourceFinish', function () { + it('finishes extraction when time has passed', async function () { + // Set extractor to RUNNING with past finishTime + const pastTime = Math.floor(Date.now() / 1000) - 100; + await mongoose.model('ExtractorComponent').updateOne( + { 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 }, + { $set: { + status: Extractor.STATUSES.RUNNING, + outputProduct: 1, + yield: 500, + finishTime: pastTime, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destinationSlot: 1 + }} + ); + + const res = await postAction(server, TOKEN, 'ExtractResourceFinish', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1 + }); + + expect(res.status).to.equal(200); + + // Verify DB: extractor is IDLE + const ext = await mongoose.model('ExtractorComponent').findOne({ + 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 + }).lean(); + expect(ext.status).to.equal(Extractor.STATUSES.IDLE); + expect(ext.finishTime).to.equal(0); + }); + + it('rejects when extractor is not RUNNING', async function () { + // Extractor slot 1 is IDLE after previous test + const res = await postAction(server, TOKEN, 'ExtractResourceFinish', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not running'); + }); + + it('rejects when extraction has not finished', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await mongoose.model('ExtractorComponent').updateOne( + { 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 }, + { $set: { status: Extractor.STATUSES.RUNNING, finishTime: futureTime } } + ); + + const res = await postAction(server, TOKEN, 'ExtractResourceFinish', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not finished'); + + // Cleanup + await mongoose.model('ExtractorComponent').updateOne( + { 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 }, + { $set: { status: 0, finishTime: 0 } } + ); + }); + + it('rejects when caller does not own crew', async function () { + const pastTime = Math.floor(Date.now() / 1000) - 100; + await mongoose.model('ExtractorComponent').updateOne( + { 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 }, + { $set: { status: Extractor.STATUSES.RUNNING, finishTime: pastTime } } + ); + + const res = await postAction(server, WRONG_TOKEN, 'ExtractResourceFinish', { + caller_crew: CREW_1, + extractor: EXTRACTOR, + extractor_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + + // Cleanup + await mongoose.model('ExtractorComponent').updateOne( + { 'entity.id': EXTRACTOR.id, 'entity.label': 5, slot: 1 }, + { $set: { status: 0, finishTime: 0 } } + ); + }); + }); +}); diff --git a/test/src/api/controllers/actions/marketplace.spec.js b/test/src/api/controllers/actions/marketplace.spec.js new file mode 100644 index 0000000..0478230 --- /dev/null +++ b/test/src/api/controllers/actions/marketplace.spec.js @@ -0,0 +1,197 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const EntityLib = require('@common/lib/Entity'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, WAREHOUSE, MARKETPLACE_BLDG, + buildActionServer, postAction, applyStubs, + resetSeedData +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Marketplace', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + + // Create ExchangeComponent for the marketplace building so orders can be placed + await mongoose.model('ExchangeComponent').findOneAndUpdate( + { 'entity.id': MARKETPLACE_BLDG.id, 'entity.label': 5 }, + { entity: { id: MARKETPLACE_BLDG.id, label: 5 }, enabled: true }, + { upsert: true, new: true } + ); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // CreateBuyOrder + // ═══════════════════════════════════════════════════════════════ + + describe('CreateBuyOrder', function () { + it('creates a buy order', async function () { + const res = await postAction(server, TOKEN, 'CreateBuyOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + storage_slot: 1, + product: 1, + amount: 100, + price: 50 + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + }); + + it('rejects when amount is zero', async function () { + const res = await postAction(server, TOKEN, 'CreateBuyOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + product: 1, + amount: 0, + price: 50 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('positive'); + }); + + it('rejects when price is zero', async function () { + const res = await postAction(server, TOKEN, 'CreateBuyOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + product: 1, + amount: 100, + price: 0 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('positive'); + }); + + it('rejects when product is missing', async function () { + const res = await postAction(server, TOKEN, 'CreateBuyOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + amount: 100, + price: 50 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('product'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'CreateBuyOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + product: 1, + amount: 100, + price: 50 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // CreateSellOrder + // ═══════════════════════════════════════════════════════════════ + + describe('CreateSellOrder', function () { + it('creates a sell order', async function () { + const res = await postAction(server, TOKEN, 'CreateSellOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + storage_slot: 1, + product: 1, + amount: 50, + price: 25 + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + }); + + it('rejects when amount is zero', async function () { + const res = await postAction(server, TOKEN, 'CreateSellOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + product: 1, + amount: 0, + price: 25 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('positive'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'CreateSellOrder', { + caller_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + product: 1, + amount: 50, + price: 25 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // CancelBuyOrder + // ═══════════════════════════════════════════════════════════════ + + describe('CancelBuyOrder', function () { + it('rejects when product is missing', async function () { + const res = await postAction(server, TOKEN, 'CancelBuyOrder', { + caller_crew: CREW_1, + buyer_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + amount: 100, + price: 50 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('product'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'CancelBuyOrder', { + caller_crew: CREW_1, + buyer_crew: CREW_1, + exchange: MARKETPLACE_BLDG, + storage: WAREHOUSE, + product: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); +}); diff --git a/test/src/api/controllers/actions/processing.spec.js b/test/src/api/controllers/actions/processing.spec.js new file mode 100644 index 0000000..7f421e5 --- /dev/null +++ b/test/src/api/controllers/actions/processing.spec.js @@ -0,0 +1,252 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { Processor } = require('@influenceth/sdk'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, REFINERY, WAREHOUSE, + buildActionServer, postAction, applyStubs, + resetSeedData, setCrewBusy +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Processing', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // ProcessProductsStart + // ═══════════════════════════════════════════════════════════════ + + describe('ProcessProductsStart', function () { + it('starts processing with valid parameters', async function () { + const res = await postAction(server, TOKEN, 'ProcessProductsStart', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1, + process: 45, // Fungal Soilbuilding + recipes: 1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1 + }); + + expect(res.status).to.equal(200); + expect(res.body.event.returnValues.finishTime).to.be.greaterThan( + Math.floor(Date.now() / 1000) + ); + expect(res.body.event.returnValues.process).to.equal(45); + + // Verify DB: Processor is RUNNING + const proc = await mongoose.model('ProcessorComponent').findOne({ + 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 + }).lean(); + expect(proc.status).to.equal(Processor.STATUSES.RUNNING); + expect(proc.runningProcess).to.equal(45); + expect(proc.recipes).to.equal(1); + expect(proc.finishTime).to.be.greaterThan(0); + + // Cleanup + await mongoose.model('ProcessorComponent').updateOne( + { 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 }, + { $set: { status: 0, runningProcess: 0, recipes: 0, finishTime: 0 } } + ); + }); + + it('rejects invalid process type', async function () { + const res = await postAction(server, TOKEN, 'ProcessProductsStart', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1, + process: 99999, + recipes: 1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Invalid process'); + }); + + it('rejects when recipes is zero', async function () { + const res = await postAction(server, TOKEN, 'ProcessProductsStart', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1, + process: 45, + recipes: 0, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('positive'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'ProcessProductsStart', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1, + process: 45, + recipes: 1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'ProcessProductsStart', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1, + process: 45, + recipes: 1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destination_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + + it('rejects when required vars are missing', async function () { + const res = await postAction(server, TOKEN, 'ProcessProductsStart', { + caller_crew: CREW_1, + processor: REFINERY + // missing process, recipes, origin, destination + }); + + expect(res.status).to.equal(400); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ProcessProductsFinish + // ═══════════════════════════════════════════════════════════════ + + describe('ProcessProductsFinish', function () { + it('finishes processing when time has passed', async function () { + // Set processor to RUNNING with past finishTime + const pastTime = Math.floor(Date.now() / 1000) - 100; + await mongoose.model('ProcessorComponent').updateOne( + { 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 }, + { $set: { + status: Processor.STATUSES.RUNNING, + runningProcess: 45, + recipes: 1, + outputProduct: 56, + finishTime: pastTime, + destination: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + destinationSlot: 1 + }} + ); + + const res = await postAction(server, TOKEN, 'ProcessProductsFinish', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1 + }); + + expect(res.status).to.equal(200); + + // Verify DB: processor is IDLE + const proc = await mongoose.model('ProcessorComponent').findOne({ + 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 + }).lean(); + expect(proc.status).to.equal(Processor.STATUSES.IDLE); + expect(proc.runningProcess).to.equal(0); + expect(proc.finishTime).to.equal(0); + }); + + it('rejects when processor is not RUNNING', async function () { + const res = await postAction(server, TOKEN, 'ProcessProductsFinish', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not running'); + }); + + it('rejects when processing has not finished', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await mongoose.model('ProcessorComponent').updateOne( + { 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 }, + { $set: { status: Processor.STATUSES.RUNNING, finishTime: futureTime } } + ); + + const res = await postAction(server, TOKEN, 'ProcessProductsFinish', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not finished'); + + // Cleanup + await mongoose.model('ProcessorComponent').updateOne( + { 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 }, + { $set: { status: 0, finishTime: 0 } } + ); + }); + + it('rejects when caller does not own crew', async function () { + const pastTime = Math.floor(Date.now() / 1000) - 100; + await mongoose.model('ProcessorComponent').updateOne( + { 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 }, + { $set: { status: Processor.STATUSES.RUNNING, finishTime: pastTime } } + ); + + const res = await postAction(server, WRONG_TOKEN, 'ProcessProductsFinish', { + caller_crew: CREW_1, + processor: REFINERY, + processor_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + + // Cleanup + await mongoose.model('ProcessorComponent').updateOne( + { 'entity.id': REFINERY.id, 'entity.label': 5, slot: 1 }, + { $set: { status: 0, finishTime: 0 } } + ); + }); + }); +}); diff --git a/test/src/api/controllers/actions/scanning.spec.js b/test/src/api/controllers/actions/scanning.spec.js new file mode 100644 index 0000000..4cb9b86 --- /dev/null +++ b/test/src/api/controllers/actions/scanning.spec.js @@ -0,0 +1,299 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { Asteroid } = require('@influenceth/sdk'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, ASTEROID_1, + buildActionServer, postAction, applyStubs, + resetSeedData, setCrewBusy, createUnscannedAsteroid +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Scanning', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // ScanSurfaceStart + // ═══════════════════════════════════════════════════════════════ + + describe('ScanSurfaceStart', function () { + it('starts surface scan on an UNSCANNED asteroid', async function () { + const asteroid = await createUnscannedAsteroid(100); + + const res = await postAction(server, TOKEN, 'ScanSurfaceStart', { + caller_crew: CREW_1, + asteroid: { id: 100 } + }); + + expect(res.status).to.equal(200); + expect(res.body.event.returnValues.finishTime).to.be.a('number'); + expect(res.body.event.returnValues.finishTime).to.be.greaterThan( + Math.floor(Date.now() / 1000) + ); + + // Verify DB + const celestial = await mongoose.model('CelestialComponent').findOne({ + 'entity.id': 100, 'entity.label': 3 + }).lean(); + expect(celestial.scanStatus).to.equal(Asteroid.SCAN_STATUSES.SURFACE_SCANNING); + expect(celestial.scanFinishTime).to.be.greaterThan(0); + }); + + it('rejects when asteroid is already scanned', async function () { + // ASTEROID_1 has scanStatus=4 (RESOURCE_SCANNED) + const res = await postAction(server, TOKEN, 'ScanSurfaceStart', { + caller_crew: CREW_1, + asteroid: ASTEROID_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('UNSCANNED'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + await createUnscannedAsteroid(101); + const res = await postAction(server, TOKEN, 'ScanSurfaceStart', { + caller_crew: CREW_1, + asteroid: { id: 101 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + + it('rejects when caller does not control crew', async function () { + await createUnscannedAsteroid(102); + const res = await postAction(server, WRONG_TOKEN, 'ScanSurfaceStart', { + caller_crew: CREW_1, + asteroid: { id: 102 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ScanSurfaceFinish + // ═══════════════════════════════════════════════════════════════ + + describe('ScanSurfaceFinish', function () { + it('finishes surface scan when time has passed', async function () { + // Set up asteroid in SURFACE_SCANNING state with past finishTime + const pastTime = Math.floor(Date.now() / 1000) - 100; + await createUnscannedAsteroid(200); + await mongoose.model('CelestialComponent').updateOne( + { 'entity.id': 200, 'entity.label': 3 }, + { $set: { + scanStatus: Asteroid.SCAN_STATUSES.SURFACE_SCANNING, + scanFinishTime: pastTime + }} + ); + + const res = await postAction(server, TOKEN, 'ScanSurfaceFinish', { + caller_crew: CREW_1, + asteroid: { id: 200 } + }); + + expect(res.status).to.equal(200); + + // Verify DB: asteroid is now SURFACE_SCANNED + const celestial = await mongoose.model('CelestialComponent').findOne({ + 'entity.id': 200, 'entity.label': 3 + }).lean(); + expect(celestial.scanStatus).to.equal(Asteroid.SCAN_STATUSES.SURFACE_SCANNED); + expect(celestial.bonuses).to.be.a('number'); + }); + + it('rejects when asteroid is not SURFACE_SCANNING', async function () { + // ASTEROID_1 is RESOURCE_SCANNED + const res = await postAction(server, TOKEN, 'ScanSurfaceFinish', { + caller_crew: CREW_1, + asteroid: ASTEROID_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not currently surface scanning'); + }); + + it('rejects when scan has not finished yet', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await createUnscannedAsteroid(201); + await mongoose.model('CelestialComponent').updateOne( + { 'entity.id': 201, 'entity.label': 3 }, + { $set: { + scanStatus: Asteroid.SCAN_STATUSES.SURFACE_SCANNING, + scanFinishTime: futureTime + }} + ); + + const res = await postAction(server, TOKEN, 'ScanSurfaceFinish', { + caller_crew: CREW_1, + asteroid: { id: 201 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not finished yet'); + }); + + it('rejects when caller does not own crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'ScanSurfaceFinish', { + caller_crew: CREW_1, + asteroid: ASTEROID_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ScanResourcesStart + // ═══════════════════════════════════════════════════════════════ + + describe('ScanResourcesStart', function () { + it('starts resource scan on a SURFACE_SCANNED asteroid', async function () { + // Set up asteroid in SURFACE_SCANNED state + await createUnscannedAsteroid(300); + await mongoose.model('CelestialComponent').updateOne( + { 'entity.id': 300, 'entity.label': 3 }, + { $set: { scanStatus: Asteroid.SCAN_STATUSES.SURFACE_SCANNED, bonuses: 42 } } + ); + + const res = await postAction(server, TOKEN, 'ScanResourcesStart', { + caller_crew: CREW_1, + asteroid: { id: 300 } + }); + + expect(res.status).to.equal(200); + expect(res.body.event.returnValues.finishTime).to.be.greaterThan( + Math.floor(Date.now() / 1000) + ); + + // Verify DB + const celestial = await mongoose.model('CelestialComponent').findOne({ + 'entity.id': 300, 'entity.label': 3 + }).lean(); + expect(celestial.scanStatus).to.equal(Asteroid.SCAN_STATUSES.RESOURCE_SCANNING); + }); + + it('rejects when asteroid is not SURFACE_SCANNED', async function () { + // ASTEROID_1 is RESOURCE_SCANNED (status 4), not SURFACE_SCANNED (status 2) + const res = await postAction(server, TOKEN, 'ScanResourcesStart', { + caller_crew: CREW_1, + asteroid: ASTEROID_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('surface-scanned'); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + await createUnscannedAsteroid(301); + await mongoose.model('CelestialComponent').updateOne( + { 'entity.id': 301, 'entity.label': 3 }, + { $set: { scanStatus: Asteroid.SCAN_STATUSES.SURFACE_SCANNED } } + ); + + const res = await postAction(server, TOKEN, 'ScanResourcesStart', { + caller_crew: CREW_1, + asteroid: { id: 301 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // ScanResourcesFinish + // ═══════════════════════════════════════════════════════════════ + + describe('ScanResourcesFinish', function () { + it('finishes resource scan and generates abundances', async function () { + const pastTime = Math.floor(Date.now() / 1000) - 100; + await createUnscannedAsteroid(400); + await mongoose.model('CelestialComponent').updateOne( + { 'entity.id': 400, 'entity.label': 3 }, + { $set: { + scanStatus: Asteroid.SCAN_STATUSES.RESOURCE_SCANNING, + scanFinishTime: pastTime, + bonuses: 42 + }} + ); + + const res = await postAction(server, TOKEN, 'ScanResourcesFinish', { + caller_crew: CREW_1, + asteroid: { id: 400 } + }); + + expect(res.status).to.equal(200); + + // Verify DB: RESOURCE_SCANNED with abundances + const celestial = await mongoose.model('CelestialComponent').findOne({ + 'entity.id': 400, 'entity.label': 3 + }).lean(); + expect(celestial.scanStatus).to.equal(Asteroid.SCAN_STATUSES.RESOURCE_SCANNED); + expect(celestial.abundances).to.not.be.empty; + }); + + it('rejects when asteroid is not RESOURCE_SCANNING', async function () { + const res = await postAction(server, TOKEN, 'ScanResourcesFinish', { + caller_crew: CREW_1, + asteroid: ASTEROID_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not currently resource scanning'); + }); + + it('rejects when scan has not finished yet', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await createUnscannedAsteroid(401); + await mongoose.model('CelestialComponent').updateOne( + { 'entity.id': 401, 'entity.label': 3 }, + { $set: { + scanStatus: Asteroid.SCAN_STATUSES.RESOURCE_SCANNING, + scanFinishTime: futureTime, + bonuses: 42 + }} + ); + + const res = await postAction(server, TOKEN, 'ScanResourcesFinish', { + caller_crew: CREW_1, + asteroid: { id: 401 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not finished yet'); + }); + }); +}); diff --git a/test/src/api/controllers/actions/ship.spec.js b/test/src/api/controllers/actions/ship.spec.js new file mode 100644 index 0000000..307708f --- /dev/null +++ b/test/src/api/controllers/actions/ship.spec.js @@ -0,0 +1,348 @@ +const { expect } = require('chai'); +const sinon = require('sinon'); +const mongoose = require('mongoose'); +const { Ship } = require('@influenceth/sdk'); +const { + TOKEN, WRONG_TOKEN, + CREW_1, CREW_2, ASTEROID_1, ASTEROID_2, + WAREHOUSE, SPACEPORT, SHIPYARD, SHIP_1, + buildActionServer, postAction, applyStubs, + resetSeedData, setCrewBusy +} = require('@test/helpers/actionTestHelper'); + +describe('Actions – Ship operations', function () { + let server; + let sandbox; + + before(async function () { + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + await resetSeedData(); + server = buildActionServer(); + }); + + afterEach(function () { + sandbox.restore(); + sandbox = sinon.createSandbox(); + applyStubs(sandbox); + }); + + after(function () { + sandbox.restore(); + }); + + // ═══════════════════════════════════════════════════════════════ + // UndockShip + // ═══════════════════════════════════════════════════════════════ + + describe('UndockShip', function () { + it('undocks ship from spaceport to asteroid', async function () { + // Ship 1 is docked at spaceport (building 9) + const res = await postAction(server, TOKEN, 'UndockShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + expect(res.status).to.equal(200); + expect(res.body).to.have.property('event'); + + // Verify DB: ship location is now asteroid + const loc = await mongoose.model('LocationComponent').findOne({ + 'entity.id': SHIP_1.id, 'entity.label': 6 + }).lean(); + expect(loc.location.label).to.equal(3); // Asteroid + + // Restore: station crew on ship, then dock + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SHIP_1.id, label: SHIP_1.label } + }); + await postAction(server, TOKEN, 'DockShip', { + caller_crew: CREW_1, + target: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + }); + + it('rejects when ship is not docked at a building', async function () { + // First undock + await postAction(server, TOKEN, 'UndockShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + // Try undocking again — ship is at asteroid, not a building + const res = await postAction(server, TOKEN, 'UndockShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('not docked'); + + // Restore: station crew on ship, dock, then station crew at habitat + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SHIP_1.id, label: SHIP_1.label } + }); + await postAction(server, TOKEN, 'DockShip', { + caller_crew: CREW_1, + target: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'UndockShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // DockShip + // ═══════════════════════════════════════════════════════════════ + + describe('DockShip', function () { + it('docks ship at a building', async function () { + // Undock the ship first + await postAction(server, TOKEN, 'UndockShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + // Crew must be ON the ship to dock it + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SHIP_1.id, label: SHIP_1.label } + }); + + // Now dock at spaceport + const res = await postAction(server, TOKEN, 'DockShip', { + caller_crew: CREW_1, + target: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + + expect(res.status).to.equal(200); + + // Verify DB: ship at spaceport + const loc = await mongoose.model('LocationComponent').findOne({ + 'entity.id': SHIP_1.id, 'entity.label': 6 + }).lean(); + expect(loc.location.id).to.equal(SPACEPORT.id); + + // Restore: crew back to habitat + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'DockShip', { + caller_crew: CREW_1, + target: { id: SPACEPORT.id, label: SPACEPORT.label } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + + it('rejects when target is missing id or label', async function () { + const res = await postAction(server, TOKEN, 'DockShip', { + caller_crew: CREW_1, + target: { id: 1 } + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('target'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // CommandeerShip + // ═══════════════════════════════════════════════════════════════ + + describe('CommandeerShip', function () { + it('transfers ship control to caller crew', async function () { + // Crew 1 controls ship. Transfer to crew 2 (both controlled by same wallet) + const res = await postAction(server, TOKEN, 'CommandeerShip', { + caller_crew: CREW_2, + ship: SHIP_1 + }); + + expect(res.status).to.equal(200); + + // Verify DB: ship controller is now crew 2 + const control = await mongoose.model('ControlComponent').findOne({ + 'entity.id': SHIP_1.id, 'entity.label': 6 + }).lean(); + expect(control.controller.id).to.equal(CREW_2.id); + + // Restore: transfer back to crew 1 + await postAction(server, TOKEN, 'CommandeerShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'CommandeerShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // TransitBetweenStart + // ═══════════════════════════════════════════════════════════════ + + describe('TransitBetweenStart', function () { + it('starts transit to another asteroid', async function () { + // First undock the ship + await postAction(server, TOKEN, 'UndockShip', { + caller_crew: CREW_1, + ship: SHIP_1 + }); + + // Move crew to ship first + await postAction(server, TOKEN, 'StationCrew', { + caller_crew: CREW_1, + destination: { id: SHIP_1.id, label: SHIP_1.label } + }); + + const now = Math.floor(Date.now() / 1000); + const res = await postAction(server, TOKEN, 'TransitBetweenStart', { + caller_crew: CREW_1, + destination: { id: ASTEROID_2.id, label: ASTEROID_2.label }, + departure_time: now, + arrival_time: now + 3600 + }); + + expect(res.status).to.equal(200); + + // Verify DB: ship has transit data + const ship = await mongoose.model('ShipComponent').findOne({ + 'entity.id': SHIP_1.id, 'entity.label': 6 + }).lean(); + expect(ship.transitArrival).to.be.greaterThan(0); + + // Cleanup: reset seed data to avoid complex state restoration + await resetSeedData(); + }); + + it('rejects when destination is missing', async function () { + const res = await postAction(server, TOKEN, 'TransitBetweenStart', { + caller_crew: CREW_1, + departure_time: Math.floor(Date.now() / 1000), + arrival_time: Math.floor(Date.now() / 1000) + 3600 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('destination'); + }); + + it('rejects when caller does not control crew', async function () { + const now = Math.floor(Date.now() / 1000); + const res = await postAction(server, WRONG_TOKEN, 'TransitBetweenStart', { + caller_crew: CREW_1, + destination: { id: ASTEROID_2.id, label: ASTEROID_2.label }, + departure_time: now, + arrival_time: now + 3600 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); + + // ═══════════════════════════════════════════════════════════════ + // AssembleShipStart + // ═══════════════════════════════════════════════════════════════ + + describe('AssembleShipStart', function () { + it('starts ship assembly at shipyard', async function () { + const res = await postAction(server, TOKEN, 'AssembleShipStart', { + caller_crew: CREW_1, + dry_dock: SHIPYARD, + ship_type: 2, // Light Transport + dry_dock_slot: 1, + origin: { id: WAREHOUSE.id, label: WAREHOUSE.label }, + origin_slot: 1 + }); + + expect(res.status).to.equal(200); + const rv = res.body.event.returnValues; + expect(rv.ship).to.have.property('id'); + expect(rv.ship.id).to.be.greaterThan(100000000); + + // Verify DB: Ship entity created with UNDER_CONSTRUCTION status + const ship = await mongoose.model('ShipComponent').findOne({ + 'entity.id': rv.ship.id, 'entity.label': 6 + }).lean(); + expect(ship).to.exist; + expect(ship.status).to.equal(Ship.STATUSES.UNDER_CONSTRUCTION); + + // Cleanup + await mongoose.model('ShipComponent').deleteOne({ 'entity.id': rv.ship.id }); + await mongoose.model('Entity').deleteOne({ id: rv.ship.id, label: 6 }); + await mongoose.model('LocationComponent').deleteOne({ 'entity.id': rv.ship.id, 'entity.label': 6 }); + await mongoose.model('ControlComponent').deleteOne({ 'entity.id': rv.ship.id, 'entity.label': 6 }); + }); + + it('rejects when crew is busy', async function () { + const futureTime = Math.floor(Date.now() / 1000) + 99999; + await setCrewBusy(CREW_1.id, futureTime); + + const res = await postAction(server, TOKEN, 'AssembleShipStart', { + caller_crew: CREW_1, + dry_dock: SHIPYARD, + ship_type: 2, + dry_dock_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('busy'); + + await setCrewBusy(CREW_1.id, 0); + }); + + it('rejects invalid ship type', async function () { + const res = await postAction(server, TOKEN, 'AssembleShipStart', { + caller_crew: CREW_1, + dry_dock: SHIPYARD, + ship_type: 99999, + dry_dock_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.be.a('string'); + }); + + it('rejects when caller does not control crew', async function () { + const res = await postAction(server, WRONG_TOKEN, 'AssembleShipStart', { + caller_crew: CREW_1, + dry_dock: SHIPYARD, + ship_type: 2, + dry_dock_slot: 1 + }); + + expect(res.status).to.equal(400); + expect(res.body.error).to.include('Not authorized'); + }); + }); +});