diff --git a/cds b/cds new file mode 160000 index 0000000..727be92 --- /dev/null +++ b/cds @@ -0,0 +1 @@ +Subproject commit 727be92d6ab98afe73fe009a7d90ab983603605f diff --git a/db/schema.cds b/db/schema.cds index 079b721..d86b055 100644 --- a/db/schema.cds +++ b/db/schema.cds @@ -69,7 +69,10 @@ entity TravelStatus : sap.common.CodeList { Open = 'O'; Accepted = 'A'; Canceled = 'X'; + + Rejected = 'R'; } } + type Price : Decimal(9,4); diff --git a/package.json b/package.json index dc730a2..cee584e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "@capire/common": "*", "@capire/xflights-data": "*", "@sap-cloud-sdk/http-client": "^4", - "@sap/cds": ">=9", + "@sap/cds": "*", "express": "^4" }, "devDependencies": { @@ -20,5 +20,8 @@ "@cap-js/cds-test": "*", "@cap-js/sqlite": ">=2" }, + "workspaces": [ + "cds" + ], "license": "Apache-2.0" } diff --git a/srv/travel-service.cds b/srv/travel-service.cds index f1043d3..d4a7e0a 100644 --- a/srv/travel-service.cds +++ b/srv/travel-service.cds @@ -8,13 +8,29 @@ service TravelService { { grant: ['*'], to: 'processor'}, { grant: ['*'], to: 'admin'} ]) - entity Travels as projection on db.Travels actions { + entity Travels as projection on db.Travels + actions { action createTravelByTemplate() returns Travels; action rejectTravel(); action acceptTravel(); action deductDiscount( percent: Percentage not null ) returns Travels; + + action reopenTravel(); } + annotate Travels with @flow.status: Status actions { + NEW /* @from: [ null ] */ @to: /* #Draft */ #Open; + SAVE /* @from: [ #Draft ] */ @to: #Open; + // cancel @from: [ #Open ] @to: #Canceled; + rejectTravel @from: [ #Open ] @to: #Rejected; + acceptTravel @from: [ #Open ] @to: #Accepted; + // close @from: [ #Accepted ] @to: #Closed; + EDIT @from: [ #Accepted, #Rejected ] @to: /* #Draft */ #Open; + + reopenTravel @from: [ #Accepted, #Rejected ] @to: $flow.previous; + PATCH /* @from: [ #Open ] */ @to: #Rejected; + }; + // Also expose Flights and Currencies for travel booking UIs and Value Helps @readonly entity Flights as projection on db.masterdata.Flights; @readonly entity Supplements as projection on db.masterdata.Supplements; @@ -23,6 +39,7 @@ service TravelService { // Export functions to export download travel data function exportJSON() returns LargeBinary @Core.MediaType:'application/json'; function exportCSV() returns LargeBinary @Core.MediaType:'text/csv'; + } @@ -41,4 +58,5 @@ entity TravelsExport @cds.persistence.skip as projection on db.Travels { Description } + type Percentage : Integer @assert.range: [1,100]; diff --git a/test/flows.test.js b/test/flows.test.js new file mode 100644 index 0000000..b5b777f --- /dev/null +++ b/test/flows.test.js @@ -0,0 +1,85 @@ +const cds = require('@sap/cds') + +const { GET, POST, PATCH, DELETE, axios, expect } = cds.test(__dirname + '/..', '--with-mocks') +axios.defaults.auth = { username: 'alice', password: 'admin' } +axios.defaults.validateStatus = () => true + +describe('Status Transition Flows', () => { + const READ = async (ID, IsActiveEntity = true) => { + const { data: travel } = await GET(`/odata/v4/travel/Travels(ID=${ID},IsActiveEntity=${IsActiveEntity})`) + if (IsActiveEntity) + travel.transitions_ = await SELECT('sap.capire.travels.Travels.transitions_').where({ up__ID: ID }) + return travel + } + + beforeEach(async () => { + await cds.ql.DELETE('sap.capire.travels.Travels.transitions_') + }) + + it('flows like a charm', async () => { + let travel + + travel = await READ(1) + expect(travel.Status_code).to.eql('O') + expect(travel.transitions_).to.have.length(0) + + await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/acceptTravel', {}) + travel = await READ(1) + expect(travel.Status_code).to.eql('A') + expect(travel.transitions_).to.have.length(1) + + await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/draftEdit', {}) + travel = await READ(1, false) + expect(travel.Status_code).to.eql('O') + + const res = await GET('/odata/v4/travel/Travels(ID=1,IsActiveEntity=false)?$expand=transitions_') + expect(res.status).to.eql(400) + + await PATCH('/odata/v4/travel/Travels(ID=1,IsActiveEntity=false)', { Description: 'foo' }) + travel = await READ(1, false) + expect(travel.Status_code).to.eql('R') + + await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=false)/draftActivate', {}) + travel = await READ(1) + expect(travel.Status_code).to.eql('O') + expect(travel.transitions_).to.have.length(2) + + await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/rejectTravel', {}) + travel = await READ(1) + expect(travel.Status_code).to.eql('R') + expect(travel.transitions_).to.have.length(3) + + await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/reopenTravel', {}) + travel = await READ(1) + expect(travel.Status_code).to.eql('O') + expect(travel.transitions_).to.have.length(4) + }) + + // NOTE: not applicable with transitions_ being excluded from projections + it.skip('prohibits altering the flow history', async () => { + let res + + await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/acceptTravel', {}) + const travel = await READ(1) + expect(travel.Status_code).to.eql('A') + expect(travel.transitions_).to.have.length(1) + + const transition = `up__ID=1,timestamp=${travel.transitions_[0].timestamp}` + + res = await GET(`/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/transitions_(${transition})`) + expect(res.status).to.eql(200) + + res = await POST('/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/transitions_', { + comment: `I shouldn't be able to do this` + }) + expect(res.status).to.eql(405) + + res = await PATCH(`/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/transitions_(${transition})`, { + comment: `Not this either` + }) + expect(res.status).to.eql(405) + + res = await DELETE(`/odata/v4/travel/Travels(ID=1,IsActiveEntity=true)/transitions_(${transition})`) + expect(res.status).to.eql(405) + }) +})