Compare commits
5 Commits
suppliers-
...
cucumber
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ce3bb9ef6 | ||
|
|
5135276952 | ||
|
|
eb98444afb | ||
|
|
83537482b8 | ||
|
|
cf10129b68 |
@@ -44,11 +44,11 @@
|
|||||||
<img v-bind:src="book.image" alt=""/>
|
<img v-bind:src="book.image" alt=""/>
|
||||||
<label style="text-align:right">
|
<label style="text-align:right">
|
||||||
<span class="succeeded"> {{ order.succeeded }} </span>
|
<span class="succeeded"> {{ order.succeeded }} </span>
|
||||||
<span class="failed"> {{ order.failed }} </span>
|
<span class="failed"> {{ order.failed }} </span>
|
||||||
{{ book.stock }} in stock
|
<span id="stock"> {{ book.stock }} in stock </span>
|
||||||
</label>
|
</label>
|
||||||
<form @submit.prevent="submitOrder" style="float:right; display:flex; flex-direction:row-reverse">
|
<form @submit.prevent="submitOrder" style="float:right; display:flex; flex-direction:row-reverse">
|
||||||
<input type="number" v-model="order.amount" v-bind:class="{ failed: order.failed }" style="width:5em">
|
<input id="amount" type="number" v-model="order.amount" v-bind:class="{ failed: order.failed }" style="width:5em">
|
||||||
<input type="submit" value="Order:" class="muted-button">
|
<input type="submit" value="Order:" class="muted-button">
|
||||||
</form>
|
</form>
|
||||||
<h4> {{ book.title }} </h4>
|
<h4> {{ book.title }} </h4>
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@capire/bookshop": "*",
|
"@capire/bookshop": "*",
|
||||||
"@capire/common": "*",
|
|
||||||
"@capire/orders": "*",
|
|
||||||
"@capire/reviews": "*",
|
"@capire/reviews": "*",
|
||||||
"@capire/suppliers": "*",
|
"@capire/orders": "*",
|
||||||
|
"@capire/common": "*",
|
||||||
"@sap/cds": "^4",
|
"@sap/cds": "^4",
|
||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"passport": "^0.4.1"
|
"passport": "^0.4.1"
|
||||||
@@ -20,10 +19,6 @@
|
|||||||
"deploy-format": "hdbtable"
|
"deploy-format": "hdbtable"
|
||||||
},
|
},
|
||||||
"requires": {
|
"requires": {
|
||||||
"API_BUSINESS_PARTNER": {
|
|
||||||
"kind": "odata",
|
|
||||||
"model": "@capire/suppliers"
|
|
||||||
},
|
|
||||||
"auth": {
|
"auth": {
|
||||||
"strategy": "dummy"
|
"strategy": "dummy"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ cds.once('bootstrap',(app)=>{
|
|||||||
})
|
})
|
||||||
|
|
||||||
cds.once('served', require('./srv/mashup'))
|
cds.once('served', require('./srv/mashup'))
|
||||||
cds.once('served', require('@capire/suppliers/srv/mashup'))
|
|
||||||
|
|
||||||
module.exports = cds.server
|
module.exports = cds.server
|
||||||
|
|
||||||
|
|||||||
@@ -11,17 +11,17 @@
|
|||||||
"@capire/hello": "./hello",
|
"@capire/hello": "./hello",
|
||||||
"@capire/media": "./media",
|
"@capire/media": "./media",
|
||||||
"@capire/orders": "./orders",
|
"@capire/orders": "./orders",
|
||||||
"@capire/reviews": "./reviews",
|
"@capire/reviews": "./reviews"
|
||||||
"@capire/suppliers": "./suppliers"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"chai": "^4.2.0",
|
"chai": "^4.2.0",
|
||||||
"chai-as-promised": "^7.1.1",
|
"chai-as-promised": "^7.1.1",
|
||||||
"chai-subset": "^1.6.0",
|
"chai-subset": "^1.6.0",
|
||||||
|
"@cucumber/cucumber": "^7.0.0",
|
||||||
|
"selenium-webdriver": "^4.0.0-beta.1",
|
||||||
"sqlite3": "5.0.0"
|
"sqlite3": "5.0.0"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"fix-antlr": "sed -i -e 's/INVALID_ALT_NUMBER = require.*/INVALID_ALT_NUMBER = 0/' node_modules/antlr4/tree/Trees.js node_modules/antlr4/RuleContext.js",
|
|
||||||
"registry": "node .registry/server.js",
|
"registry": "node .registry/server.js",
|
||||||
"bookshop": "cds watch bookshop",
|
"bookshop": "cds watch bookshop",
|
||||||
"fiori": "cds watch fiori",
|
"fiori": "cds watch fiori",
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ service ReviewsService {
|
|||||||
action unlike (review: type of Reviews:ID);
|
action unlike (review: type of Reviews:ID);
|
||||||
|
|
||||||
// Async API
|
// Async API
|
||||||
event reviewed : projection on Reviews {
|
event reviewed : {
|
||||||
subject,
|
subject: type of Reviews:subject;
|
||||||
rating
|
rating: Decimal(2,1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Input validation
|
// Input validation
|
||||||
|
|||||||
12
samples.md
12
samples.md
@@ -49,22 +49,14 @@ Each sub directory essentially is an individual npm package arranged in an [all-
|
|||||||
- Late-cut Micro Services
|
- Late-cut Micro Services
|
||||||
- As well as managed data, input validations, and authorization
|
- As well as managed data, input validations, and authorization
|
||||||
|
|
||||||
## [@capire/suppliers](suppliers)
|
|
||||||
|
|
||||||
- Shows how to integrate remote services, in this case the BusinessPartner service from SAP S/4HANA.
|
|
||||||
- Extending [@capire/bookshop](bookshop) with suppliers from SAP S/4HANA
|
|
||||||
- Providing that as a pre-built integration & extension package
|
|
||||||
- Used in [@capire/fiori](fiori)
|
|
||||||
|
|
||||||
|
|
||||||
## [@capire/fiori](fiori)
|
## [@capire/fiori](fiori)
|
||||||
|
|
||||||
- A [composite app, reusing and combining](https://cap.cloud.sap/docs/guides/verticalize) these packages:
|
- A [composite app, reusing and combining](https://cap.cloud.sap/docs/guides/verticalize) these packages:
|
||||||
- [@capire/bookshop](bookshop)
|
- [@capire/bookshop](bookshop)
|
||||||
- [@capire/common](common)
|
|
||||||
- [@capire/orders](orders)
|
|
||||||
- [@capire/reviews](reviews)
|
- [@capire/reviews](reviews)
|
||||||
- [@capire/suppliers](suppliers)
|
- [@capire/orders](orders)
|
||||||
|
- [@capire/common](common)
|
||||||
- [Adds an SAP Fiori elements application](https://cap.cloud.sap/docs/guides/fiori/) to bookshop, thereby introducing to:
|
- [Adds an SAP Fiori elements application](https://cap.cloud.sap/docs/guides/fiori/) to bookshop, thereby introducing to:
|
||||||
- [OData Annotations](https://cap.cloud.sap/docs/guides/fiori#adding-odata-annotations) in `.cds` files
|
- [OData Annotations](https://cap.cloud.sap/docs/guides/fiori#adding-odata-annotations) in `.cds` files
|
||||||
- Support for [Fiori Draft](https://cap.cloud.sap/docs/guides/fiori#draft)
|
- Support for [Fiori Draft](https://cap.cloud.sap/docs/guides/fiori#draft)
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
using from './srv/mashup';
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@capire/suppliers",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "Shows integration with SAP S/4HANA, in turn provided as a reusable extension package to bookshop.",
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
|
||||||
"@capire/common": "*",
|
|
||||||
"@sap/cds": "^4",
|
|
||||||
"express": "^4"
|
|
||||||
},
|
|
||||||
"scripts": {
|
|
||||||
"start": "cds run --in-memory?",
|
|
||||||
"watch": "cds watch",
|
|
||||||
"mocked-s4": "cds mock API_BUSINESS_PARTNER"
|
|
||||||
},
|
|
||||||
"cds": {
|
|
||||||
"requires": {
|
|
||||||
"API_BUSINESS_PARTNER": {
|
|
||||||
"kind": "odata",
|
|
||||||
"model": "srv/external/API_BUSINESS_PARTNER"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
const cds = require ('@sap/cds')
|
|
||||||
cds.once('served', require('./srv/mashup'))
|
|
||||||
module.exports = cds.server
|
|
||||||
2425
suppliers/srv/external/API_BUSINESS_PARTNER.csn
vendored
2425
suppliers/srv/external/API_BUSINESS_PARTNER.csn
vendored
File diff suppressed because it is too large
Load Diff
3261
suppliers/srv/external/API_BUSINESS_PARTNER.edmx
vendored
3261
suppliers/srv/external/API_BUSINESS_PARTNER.edmx
vendored
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
|||||||
BusinessPartner;BusinessPartnerFullName
|
|
||||||
ACME;A Company Making Everything
|
|
||||||
B4U;Books for You
|
|
||||||
S&C;Shakespeare & Co.
|
|
||||||
WSL;Waterstones
|
|
||||||
TLD;Thalia
|
|
||||||
PNG;Penguin Books
|
|
||||||
|
@@ -1,5 +0,0 @@
|
|||||||
ID;name
|
|
||||||
ACME;A Company Making Everything
|
|
||||||
B4U;Books for You
|
|
||||||
S&C;Shakespeare & Co.
|
|
||||||
WSL;Waterstones
|
|
||||||
|
@@ -1,25 +0,0 @@
|
|||||||
/*
|
|
||||||
Optionally add projections to external entities, to capture what
|
|
||||||
you actually want to use from there.
|
|
||||||
*/
|
|
||||||
|
|
||||||
using {API_BUSINESS_PARTNER as S4} from './external/API_BUSINESS_PARTNER.csn';
|
|
||||||
|
|
||||||
@cds.autoexpose // or expose explicitly in Catalog and AdminService
|
|
||||||
@cds.persistence: {table,skip:false}
|
|
||||||
entity sap.capire.bookshop.Suppliers as projection on S4.A_BusinessPartner {
|
|
||||||
key BusinessPartner as ID, BusinessPartnerFullName as name
|
|
||||||
}
|
|
||||||
|
|
||||||
using { sap.capire.bookshop.Books, CatalogService } from '@capire/bookshop';
|
|
||||||
extend Books with {
|
|
||||||
supplier: Association to sap.capire.bookshop.Suppliers;
|
|
||||||
}
|
|
||||||
|
|
||||||
extend service AdminService with { // why is AdminService visible?
|
|
||||||
entity Suppliers as projection on sap.capire.bookshop.Suppliers;
|
|
||||||
}
|
|
||||||
|
|
||||||
extend projection CatalogService.ListOfBooks with {
|
|
||||||
supplier
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
////////////////////////////////////////////////////////////////////////////
|
|
||||||
//
|
|
||||||
// Mashing up provided and required services...
|
|
||||||
//
|
|
||||||
module.exports = async()=>{ // called by server.js
|
|
||||||
|
|
||||||
if (!cds.services.AdminService) return //> mocking S4 service only
|
|
||||||
|
|
||||||
// Connect to services we want to mashup below...
|
|
||||||
const S4bupa = await cds.connect.to('API_BUSINESS_PARTNER') //> external S4 service
|
|
||||||
const admin = await cds.connect.to('AdminService') //> local domain service
|
|
||||||
const db = await cds.connect.to('db') //> our primary database
|
|
||||||
|
|
||||||
// Reflect CDS definition of the Suppliers entity
|
|
||||||
const { Suppliers } = S4bupa.entities
|
|
||||||
|
|
||||||
admin.prepend (()=>{ //> to ensure our .on handlers below go before the default ones
|
|
||||||
|
|
||||||
// Delegate Value Help reads for Suppliers to S4 backend
|
|
||||||
admin.on ('READ', 'Suppliers', req => {
|
|
||||||
console.log ('>> delegating to S4 service...')
|
|
||||||
return S4bupa.run(req.query)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Replicate Supplier data when edited Books have suppliers
|
|
||||||
admin.on (['CREATE','UPDATE'], 'Books', ({data:{supplier}}, next) => {
|
|
||||||
// Using Promise.all(...) to parallelize local write, i.e. next(), and replication
|
|
||||||
if (supplier) return Promise.all ([ next(), async()=>{
|
|
||||||
let replicated = await db.exists (Suppliers, supplier)
|
|
||||||
if (!replicated) await replicate (supplier, 'initial')
|
|
||||||
}])
|
|
||||||
else return next() //> don't forget to pass down the interceptor stack
|
|
||||||
})
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
// Subscribe to changes in the S4 origin of Suppliers data
|
|
||||||
S4bupa.on ('BusinessPartners/Changed', async msg => { //> would be great if we had batch events from S/4
|
|
||||||
let replicas = await SELECT('ID').from (Suppliers) .where ('ID in', msg.businessPartners)
|
|
||||||
return replicate (replicas.map(each => each.ID))
|
|
||||||
})
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper function to replicate Suppliers data.
|
|
||||||
* @param {string|string[]} IDs a single ID or an array of IDs
|
|
||||||
* @param {truthy|falsy} _initial indicates whether an insert or an update is required
|
|
||||||
*/
|
|
||||||
async function replicate (IDs,_initial) {
|
|
||||||
if (!Array.isArray(IDs)) IDs = [ IDs ]
|
|
||||||
let suppliers = await S4bupa.read (Suppliers).where('ID in',IDs)
|
|
||||||
if (_initial) return db.insert (suppliers) .into (Suppliers) //> using bulk insert
|
|
||||||
else return Promise.all(suppliers.map ( //> parallelizing updates
|
|
||||||
each => db.update (Suppliers,each.ID) .with (each)
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
34
test/features/bookshop.feature
Normal file
34
test/features/bookshop.feature
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
Feature: List Books using Vue.js UI
|
||||||
|
|
||||||
|
Scenario: Launch cds server for bookshop
|
||||||
|
When we run the 'bookshop' server
|
||||||
|
And wait for 1s
|
||||||
|
Then it should listen at 'http://localhost:4004'
|
||||||
|
|
||||||
|
Scenario: Display Books List
|
||||||
|
When we open page '/vue/index.html'
|
||||||
|
And wait for 1s
|
||||||
|
Then it should list these rows in table 'books':
|
||||||
|
| Wuthering Heights | Emily Brontë |
|
||||||
|
| Jane Eyre | Charlotte Brontë |
|
||||||
|
| The Raven | Edgar Allen Poe |
|
||||||
|
| Eleonora | Edgar Allen Poe |
|
||||||
|
| Catweazle | Richard Carpenter |
|
||||||
|
|
||||||
|
Scenario: Select a Book
|
||||||
|
When we click on the 1st row in table 'books'
|
||||||
|
Then it shows '12' in 'stock'
|
||||||
|
|
||||||
|
Scenario: Order One Book
|
||||||
|
When we click on button 'Order:'
|
||||||
|
Then it succeeds with 'ordered 1 item(s)'
|
||||||
|
|
||||||
|
Scenario: Order Four Books
|
||||||
|
When we enter '4' into 'amount'
|
||||||
|
And we click on button 'Order:'
|
||||||
|
Then it succeeds with 'ordered 4 item(s)'
|
||||||
|
|
||||||
|
Scenario: Order Amount Exceeding Stock
|
||||||
|
When we enter '9' into 'amount'
|
||||||
|
And we click on button 'Order:'
|
||||||
|
Then it fails with '9 exceeds stock'
|
||||||
82
test/features/step_definitions/bookshop_steps.js
Normal file
82
test/features/step_definitions/bookshop_steps.js
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
const { Given, When, Then, AfterAll } = require('@cucumber/cucumber')
|
||||||
|
const { Builder, By } = require('selenium-webdriver')
|
||||||
|
const browser = (new Builder).forBrowser('safari').build()
|
||||||
|
const cds = require ('@sap/cds/lib')
|
||||||
|
|
||||||
|
process.env.cds_requires_auth_strategy = 'dummy'
|
||||||
|
|
||||||
|
let axios = require('axios').default
|
||||||
|
let {display} = browser
|
||||||
|
|
||||||
|
When('we run the {string} server', project => cds.exec('watch', project))
|
||||||
|
Then('it should listen at {string}', baseURL => {
|
||||||
|
axios = axios.create({ baseURL })
|
||||||
|
display = url => browser.get (baseURL+url)
|
||||||
|
return axios.head()
|
||||||
|
})
|
||||||
|
Then('terminate the server', ()=> process.exit())
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
When(/wait for (\d+)\s*(\w+)/, {timeout:60*1000}, (delay, unit, done) => {
|
||||||
|
const factor = {
|
||||||
|
ms: 1,
|
||||||
|
s: 1000, sec: 1000, second: 1000, seconds: 1000,
|
||||||
|
m: 60*1000, min: 60*1000, minute: 60*1000, minutes: 60*1000,
|
||||||
|
h: 60*60*1000, hr: 60*60*1000, hour: 60*60*1000, hours: 60*60*1000,
|
||||||
|
}[unit]
|
||||||
|
if (!factor) throw `Unknown duration unit: ${unit}`
|
||||||
|
setTimeout (done, delay * factor)
|
||||||
|
})
|
||||||
|
|
||||||
|
AfterAll(()=> setTimeout (process.exit, 111))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
When('we open page {string}', page => display(page))
|
||||||
|
|
||||||
|
Then('it should list these rows in table {string}:', async (id,data) => {
|
||||||
|
let rows = await browser.findElements(By.css(`#${id} tr`)); rows.shift()
|
||||||
|
await Promise.all (data.rawTable.map (async (row,i)=>{
|
||||||
|
const tr = await rows[i].getText()
|
||||||
|
for (let each of row) if (!tr.match(each)) throw `Didn't find '${each}' in web page as expected`
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
When(/we click on the (\d+)(?:st|nd|rd|th) row in table '(\w+)'/, async (row, id) => {
|
||||||
|
let rows = await browser.findElements(By.css(`#${id} tr`))
|
||||||
|
let td = await rows[row].findElement(By.css('td'))
|
||||||
|
return td.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
When('we enter {string} into {string}', async (value,id) => {
|
||||||
|
const field = await browser.findElement(By.css(`input#${id}`))
|
||||||
|
return field.sendKeys('\b\b\b\b\b\b',value)
|
||||||
|
})
|
||||||
|
|
||||||
|
When('we click on button {string}', async (text) => {
|
||||||
|
const button = await browser.findElement(By.css(`input[value='${text}']`))
|
||||||
|
return button.click()
|
||||||
|
})
|
||||||
|
|
||||||
|
Then('it succeeds with {string}', async message => {
|
||||||
|
const element = await browser.wait (browser.findElement(By.css(`span.succeeded`)))
|
||||||
|
return (await element.getText()).includes(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
Then('it fails with {string}', async message => {
|
||||||
|
const element = await browser.wait (browser.findElement(By.css(`span.failed`)))
|
||||||
|
return (await element.getText()).includes(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
Then('it shows {string} in {string}', async (message,id) => {
|
||||||
|
const element = await browser.wait (browser.findElement(By.id(id)))
|
||||||
|
return (await element.getText()).includes(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
Given('we login as {string}, {string}', async (username, password) => {
|
||||||
|
const alert = await browser.switchTo().alert()
|
||||||
|
return alert.authenticateAs(username, password)
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user