diff --git a/origin-dapp/src/components/notification-message.js b/origin-dapp/src/components/notification-message.js index 580888ebcf25..59af3c2f2212 100644 --- a/origin-dapp/src/components/notification-message.js +++ b/origin-dapp/src/components/notification-message.js @@ -9,49 +9,77 @@ class NotificationMessage extends Component { constructor(props) { super(props) - this.intlMessages = defineMessages({ - offerMade: { - id: 'notification.offerMade', + const intlMessages = defineMessages({ + // + // Notifications received by the seller. + // + sellerOfferCreated: { + id: 'notification.sellerOfferCreated', defaultMessage: 'You have a new offer.' }, - offerAccepted: { - id: 'notification.purchaseSent', - defaultMessage: 'Your offer has been accepted.' + sellerOfferFinalized: { + id: 'notification.sellerOfferFinalized', + defaultMessage: 'Your transaction has been completed.' }, - saleConfirmed: { - id: 'notification.saleConfirmed', - defaultMessage: 'Your sale has been confirmed.' + sellerOfferDisputed: { + id: 'notification.sellerOfferDisputed', + defaultMessage: 'A problem has been reported with your transaction.' }, - sellerReviewed: { - id: 'notification.sellerReviewed', - defaultMessage: 'You have a new review.' + sellerOfferWithdrawn: { + id: 'notification.sellerOfferWithdrawn', + defaultMessage: 'An offer on your listing has been withdrawn.' + }, + sellerOfferRuling: { + id: 'notification.sellerOfferRuling', + defaultMessage: 'A ruling has been issued on your disputed transaction.' + }, + // + // Notifications received by the buyer. + // + buyerOfferAccepted: { + id: 'notification.buyerOfferAccepted', + defaultMessage: 'An offer you made has been accepted.' + }, + buyerOfferDisputed: { + id: 'notification.buyerOfferDisputed', + defaultMessage: 'A problem has been reported with your transaction.' + }, + buyerOfferRuling: { + id: 'notification.buyerOfferRuling', + defaultMessage: 'A ruling has been issued on your disputed transaction.' + }, + buyerOfferReview: { + id: 'notification.buyerOfferReview', + defaultMessage: 'A review has been left on your transaction.' + }, + buyerOfferWithdrawn: { + id: 'notification.buyerOfferWithdrawn', + defaultMessage: 'An offer you made has been rejected.' } + }) + + this.notificationTypeToMessage = { + 'seller_offer_created': intlMessages.sellerOfferCreated, + 'seller_offer_finalized': intlMessages.sellerOfferFinalized, + 'seller_offer_disputed': intlMessages.sellerOfferDisputed, + 'seller_offer_ruling': intlMessages.sellerOfferRuling, + 'seller_offer_withdrawn': intlMessages.sellerOfferWithdrawn, + 'buyer_offer_accepted': intlMessages.buyerOfferAccepted, + 'buyer_offer_disputed': intlMessages.sellerOfferDisputed, + 'buyer_offer_ruling': intlMessages.buyerOfferRuling, + 'buyer_offer_review': intlMessages.buyerOfferReview, + 'buyer_offer_withdrawn': intlMessages.buyerOfferWithdrawn, + } } render() { const { className, type } = this.props - let message - - switch (type) { - case 'buyer_review_received': - message = this.props.intl.formatMessage( - this.intlMessages.sellerReviewed - ) - break - case 'seller_review_received': - message = this.props.intl.formatMessage(this.intlMessages.saleConfirmed) - break - case 'buyer_listing_shipped': - message = this.props.intl.formatMessage(this.intlMessages.offerAccepted) - break - case 'seller_listing_purchased': - message = this.props.intl.formatMessage(this.intlMessages.offerMade) - break - default: + let message = this.notificationTypeToMessage[type] + if (!message) { return

{NON_PURCHASE_RELATED_MESSAGE}

} - + message = this.props.intl.formatMessage(message) return (
{message} diff --git a/origin-dapp/src/components/notification.js b/origin-dapp/src/components/notification.js index 819780879392..210fb92aa37b 100644 --- a/origin-dapp/src/components/notification.js +++ b/origin-dapp/src/components/notification.js @@ -17,8 +17,8 @@ class Notification extends Component { super(props) const { notification, wallet } = this.props - const { listing, purchase } = notification.resources - const counterpartyAddress = [listing.seller, purchase.buyer].find( + const { listing, offer } = notification.resources + const counterpartyAddress = [listing.seller, offer.buyer].find( addr => formattedAddress(addr) !== formattedAddress(wallet.address) ) @@ -27,7 +27,7 @@ class Notification extends Component { counterpartyAddress, counterpartyName: '', listing, - purchase + offer } } @@ -58,7 +58,7 @@ class Notification extends Component { counterpartyAddress, counterpartyName, listing, - purchase + offer } = this.state const listingImageURL = @@ -66,7 +66,7 @@ class Notification extends Component { return (
  • - +
    {!listing.id && ( diff --git a/origin-dapp/translations/all-messages.json b/origin-dapp/translations/all-messages.json index 897e439ccf90..13813573b2aa 100644 --- a/origin-dapp/translations/all-messages.json +++ b/origin-dapp/translations/all-messages.json @@ -287,10 +287,16 @@ "navbar.addListing": "Add a Listing", "not-found.heading": "How did I get here?", "not-found.content": "The page you’re looking for is no longer here, maybe it was never here in the first place. In any case, we sincerely apologize if it’s us and we forgive you if it’s you :)", - "notification.offerMade": "You have a new offer.", - "notification.purchaseSent": "Your offer has been accepted.", - "notification.saleConfirmed": "Your sale has been confirmed.", - "notification.sellerReviewed": "You have a new review.", + "notification.sellerOfferCreated": "You have a new offer.", + "notification.sellerOfferFinalized": "Your transaction has been completed.", + "notification.sellerOfferDisputed": "A problem has been reported with your transaction.", + "notification.sellerOfferWithdrawn": "An offer on your listing has been withdrawn.", + "notification.sellerOfferRuling": "A ruling has been issued on your disputed transaction.", + "notification.buyerOfferAccepted": "An offer you made has been accepted.", + "notification.buyerOfferDisputed": "A problem has been reported with your transaction.", + "notification.buyerOfferRuling": "A ruling has been issued on your disputed transaction.", + "notification.buyerOfferReview": "A review has been left on your transaction.", + "notification.buyerOfferWithdrawn": "An offer you made has been rejected.", "notification.buyer": "Buyer", "notification.seller": "Seller", "notificationsComponent.notificationsHeading": "Notifications", diff --git a/origin-dapp/translations/messages/src/components/notification-message.json b/origin-dapp/translations/messages/src/components/notification-message.json index eaa7bdc89511..4459f7862af1 100644 --- a/origin-dapp/translations/messages/src/components/notification-message.json +++ b/origin-dapp/translations/messages/src/components/notification-message.json @@ -1,18 +1,42 @@ [ { - "id": "notification.offerMade", + "id": "notification.sellerOfferCreated", "defaultMessage": "You have a new offer." }, { - "id": "notification.purchaseSent", - "defaultMessage": "Your offer has been accepted." + "id": "notification.sellerOfferFinalized", + "defaultMessage": "Your transaction has been completed." }, { - "id": "notification.saleConfirmed", - "defaultMessage": "Your sale has been confirmed." + "id": "notification.sellerOfferDisputed", + "defaultMessage": "A problem has been reported with your transaction." }, { - "id": "notification.sellerReviewed", - "defaultMessage": "You have a new review." + "id": "notification.sellerOfferWithdrawn", + "defaultMessage": "An offer on your listing has been withdrawn." + }, + { + "id": "notification.sellerOfferRuling", + "defaultMessage": "A ruling has been issued on your disputed transaction." + }, + { + "id": "notification.buyerOfferAccepted", + "defaultMessage": "An offer you made has been accepted." + }, + { + "id": "notification.buyerOfferDisputed", + "defaultMessage": "A problem has been reported with your transaction." + }, + { + "id": "notification.buyerOfferRuling", + "defaultMessage": "A ruling has been issued on your disputed transaction." + }, + { + "id": "notification.buyerOfferReview", + "defaultMessage": "A review has been left on your transaction." + }, + { + "id": "notification.buyerOfferWithdrawn", + "defaultMessage": "An offer you made has been rejected." } ] \ No newline at end of file diff --git a/origin-docs/source/includes/resources/marketplace.md b/origin-docs/source/includes/resources/marketplace.md index bbc78a621424..75a60b85fc9a 100644 --- a/origin-docs/source/includes/resources/marketplace.md +++ b/origin-docs/source/includes/resources/marketplace.md @@ -154,21 +154,18 @@ const listingId = "927-832" ## getNotifications -Each Notification corresponds to the status of a Listing. Notifications are currently generated for each of the following Listing statuses: +Each Notification corresponds to the status of an Offer. Notifications are currently generated for each of the following Offer statuses: -- ListingCreated -- ListingUpdated -- ListingWithdrawn -- ListingArbitrated - OfferCreated - OfferAccepted - OfferFinalized - OfferWithdrawn -- OfferFundsAdded - OfferDisputed - OfferRuling +- OfferFinalized +- OfferData -Notifications do not exist on the blockchain nor are they read from a database. They are derived from the blockchain transaction logs of the Listing statuses at the time of the API request. Because of this, there is no central record of a notification's status as "read" or "unread". When a client first interacts with the notifications API, Origin.js will record a timestamp in local storage. All notifications resulting from blockchain events that happen prior to this timestamp will be considered to be "read". This ensures that when the same user interacts with the notifications API from a different client for the first time, they will not receive a large number of "unread" notifications that they have previously read from their original client. +Notifications do not exist on the blockchain nor are they read from a database. They are derived from the blockchain transaction logs of the Offer statuses at the time of the API request. Because of this, there is no central record of a notification's status as "read" or "unread". When a client first interacts with the notifications API, Origin.js will record a timestamp in local storage. All notifications resulting from blockchain events that happen prior to this timestamp will be considered to be "read". This ensures that when the same user interacts with the notifications API from a different client for the first time, they will not receive a large number of "unread" notifications that they have previously read from their original client. > Example: getNotifications @@ -180,10 +177,10 @@ Notifications do not exist on the blockchain nor are they read from a database. [{ "id": "2984803-23433", - "type": "buyer_listing_shipped", + "type": "buyer_offer_accepted", "status": "unread", - "event": {}, - "resources": { listingId: "927-832", offerId: "183", listing: { title: "Whirlpool Microwave" } } + "event": {...}, + "resources": { listingId: "1-000-832", offerId: "183", listing: { title: "Whirlpool Microwave" }, offer: {...} } ]} ``` diff --git a/origin-js/src/contractInterface/marketplace/resolver.js b/origin-js/src/contractInterface/marketplace/resolver.js index 055e96866824..80dc58db9213 100644 --- a/origin-js/src/contractInterface/marketplace/resolver.js +++ b/origin-js/src/contractInterface/marketplace/resolver.js @@ -263,6 +263,24 @@ export default class MarketplaceResolver { ) } + /** + * Returns all notifications relevant to a user. + * The notification status is set to 'read' if either + * - The notification was previously marked as read in local storage. + * - The event occurred prior to the subscription start time (e.g. date at which + * the notification component was initialized for the first time). + * + * @param {string} party - User's ETH address. + * @return {Promise} + **/ async getNotifications(party) { const network = await this.contractService.web3.eth.net.getId() let notifications = [] @@ -277,6 +295,7 @@ export default class MarketplaceResolver { version, transactionHash: notification.event.transactionHash }) + // Check if the event occurred prior to the subscription start time. const timestamp = await this.contractService.getTimestamp( notification.event ) diff --git a/origin-js/src/contractInterface/marketplace/v00_adapter.js b/origin-js/src/contractInterface/marketplace/v00_adapter.js index 8998b795da10..c3c3ed9c77c1 100644 --- a/origin-js/src/contractInterface/marketplace/v00_adapter.js +++ b/origin-js/src/contractInterface/marketplace/v00_adapter.js @@ -8,6 +8,20 @@ const OFFER_STATUS = [ 'withdrawn', 'ruling' ] +const offerStatusToSellerNotificationType = { + 'created': 'seller_offer_created', + 'finalized': 'seller_offer_finalized', + 'disputed': 'seller_offer_disputed', + 'ruling': 'seller_offer_ruling', + 'withdrawn': 'seller_offer_withdrawn', +} +const offerStatusToBuyerNotificationType = { + 'accepted': 'buyer_offer_accepted', + 'disputed': 'buyer_offer_disputed', + 'ruling': 'buyer_offer_ruling', + 'sellerReviewed': 'buyer_offer_review', + 'withdrawn': 'buyer_offer_withdrawn', +} const SUPPORTED_DEPOSIT_CURRENCIES = ['OGN'] const emptyAddress = '0x0000000000000000000000000000000000000000' @@ -233,8 +247,6 @@ class V00_MarkeplaceAdapter { await this.getContract() // Get the raw listing data from the contract. - // Note: once a listing is withdrawn, it is deleted from the blockchain to save - // on gas. In this cases rawListing is returned as an object with all its fields set to zero. const rawListing = await this.call('listings', [listingId]) // Find all events related to this listing @@ -253,9 +265,9 @@ class V00_MarkeplaceAdapter { if (event.event === 'ListingCreated') { ipfsHash = event.returnValues.ipfsHash } else if (event.event === 'ListingUpdated') { - // If a blockInfo is passed in, ignore udpated IPFS data that occurred after. + // If a blockInfo is passed in, ignore updated IPFS data that occurred after. // This is used when we want to see what a listing looked like at the time an offer was made. - // Specificatlly, on myPurchases and mySales requests as well as for arbitration. + // Specifically, on myPurchases and mySales requests as well as for arbitration. if (!blockInfo || (event.blockNumber < blockInfo.blockNumber) || (event.blockNumber === blockInfo.blockNumber && event.logIndex <= blockInfo.logIndex)) { @@ -273,6 +285,8 @@ class V00_MarkeplaceAdapter { offers[event.returnValues.offerID] = { status: 'ruling', event } } else if (event.event === 'OfferFinalized') { offers[event.returnValues.offerID] = { status: 'finalized', event } + } else if (event.event === 'OfferWithdrawn') { + offers[event.returnValues.offerID] = { status: 'withdrawn', event } } else if (event.event === 'OfferData') { offers[event.returnValues.offerID] = { status: 'sellerReviewed', event } } @@ -471,12 +485,13 @@ class V00_MarkeplaceAdapter { blockNumber = e.blockNumber logIndex = e.logIndex break - // In all cases below, the offer was deleted from the blochain + // In all cases below, the offer was deleted from the blockchain and therefore // rawOffer fields are set to zero => populate rawOffer.status based on event history. case 'OfferFinalized': rawOffer.status = 4 break - // TODO: Assumes OfferData event is a seller review + // FIXME: This assumes OfferData event is always a seller review whereas it may be + // emitted by the marketplace contract in other cases such as a seller initiated refund. case 'OfferData': rawOffer.status = 5 break @@ -507,6 +522,14 @@ class V00_MarkeplaceAdapter { return Object.assign({ timestamp }, transactionReceipt) } + /** + * Fetches all notifications for a user since inception. + * @param {string} party - User's ETH address. + * @return {Promise} + */ async getNotifications(party) { await this.getContract() @@ -515,11 +538,15 @@ class V00_MarkeplaceAdapter { const partyListingIds = [] const partyOfferIds = [] + // Fetch all marketplace events where user is the party. const events = await this.contract.getPastEvents('allEvents', { topics: [null, this.padTopic(party)], fromBlock: this.blockEpoch }) + // Create a list of + // - Ids of listings created by the user as a seller + // - Ids of offers made by the user as a buyer. for (const event of events) { if (event.event === 'ListingCreated') { partyListingIds.push(event.returnValues.listingID) @@ -532,40 +559,59 @@ class V00_MarkeplaceAdapter { } } - // Find pending offers and pending reviews + // Find events of interest on offers for listings created by the user as a seller. for (const listingId of partyListingIds) { - const listing = await this.getListing(listingId) - for (const offerId in listing.offers) { + try { + const listing = await this.getListing(listingId) + for (const offerId in listing.offers) { + const offer = listing.offers[offerId] + // Skip the event if the action was initiated by the user. + if (party.toLowerCase() === offer.event.returnValues.party.toLowerCase()) { + continue + } + const type = offerStatusToSellerNotificationType[offer.status] + if (type) { + notifications.push({ + type, + event: offer.event, + resources: { listingId, offerId } + }) + } + } + } catch (e) { + // Guard against invalid listing/offer that might be created for example + // by exploiting a validation loophole in origin-js listing/offer code + // or by writing directly to the blockchain. + console.log('getNotifications: skipping invalid listing') + console.log(` contract=${this.contractName} listingId=${listingId} error=${e}`) + } + } + + // Find events of interest on offers made by the user as a buyer. + for (const [listingId, offerId] of partyOfferIds) { + try { + const listing = await this.getListing(listingId) const offer = listing.offers[offerId] - if (offer.status === 'created') { - notifications.push({ - event: offer.event, - type: 'seller_listing_purchased', - resources: { listingId, offerId } - }) + // Skip the event if the action was initiated by the user. + if (party.toLowerCase() === offer.event.returnValues.party.toLowerCase()) { + continue } - if (offer.status === 'finalized') { + const type = offerStatusToBuyerNotificationType[offer.status] + if (type) { notifications.push({ + type, event: offer.event, - type: 'seller_review_received', resources: { listingId, offerId } }) } + } catch (e) { + // Guard against invalid listing/offer that might be created for example + // by exploiting a validation loophole in origin-js listing/offer code + // or by writing directly to the blockchain. + console.log('getNotifications: skipping invalid offer') + console.log(` contract=${this.contractName} offerId=${offerId} error=${e}`) } } - // Find pending offers and pending reviews - for (const [listingId, offerId] of partyOfferIds) { - const listing = await this.getListing(listingId) - const offer = listing.offers[offerId] - if (offer.status === 'accepted') { - notifications.push({ - event: offer.event, - type: 'buyer_listing_shipped', - resources: { listingId, offerId } - }) - } - } - return notifications } diff --git a/origin-js/src/models/notification.js b/origin-js/src/models/notification.js index 4964e3955cd4..ee2a3ae4edb8 100644 --- a/origin-js/src/models/notification.js +++ b/origin-js/src/models/notification.js @@ -9,10 +9,17 @@ export const storeKeys = { export class Notification { constructor({ id, event, type, status, resources = {} } = {}) { + // Unique id with format --. this.id = id + // Web3 event. this.event = event + // See src/contractInterface/marketplace/v00_adapter.js for list of types. this.type = type + // 'read' or 'unread'. this.status = status + // Resources includes the following fields: + // - listing: Listing model object + // - offer: Offer model object this.resources = resources } } diff --git a/origin-js/src/resources/marketplace.js b/origin-js/src/resources/marketplace.js index 2e7f63ba22ee..fc9bd64b6e9f 100644 --- a/origin-js/src/resources/marketplace.js +++ b/origin-js/src/resources/marketplace.js @@ -461,6 +461,8 @@ export default class Marketplace { /** * Withdraws an offer. + * This may be called by either the buyer (to cancel an offer) + * or the seller (to reject an offer). * @param {string} id - Offer unique ID. * @param ipfsData - Data to store in IPFS. For future use, currently empty. * @param {func(confirmationCount, transactionReceipt)} confirmationCallback @@ -617,24 +619,43 @@ export default class Marketplace { return reviews } + /** + * Fetch all notifications for the current user. + * + * Notes: + * a) Only the latest notification for a given offer is returned (vs the whole history). + * Imagine the following scenario: + * - Buyer creates offer. + * - getNotification called for seller -> offer created notification returned + * - Seller accepts offer then buyer finalizes it + * - getNotification called for seller -> only the finalized notification is returned. + * b) The current implementation is very inefficient, especially for sellers with large + * number of listings/offers. When this becomes an issue, the logic could be optimized. + * For example a possibility would be to add a "fromBlockNumber" argument to allow to fetch + * incrementally new notifications. Alternatively, support for a "performance mode" that + * fetches data from the back-end could be added. + * + * @return {Promise} + */ async getNotifications() { + // Fetch all notifications. const party = await this.contractService.currentAccount() const notifications = await this.resolver.getNotifications(party) - let isValid = true + + // Decorate each notification with listing and offer data. const withResources = await Promise.all(notifications.map(async (notification) => { - if (notification.resources.listingId) { - notification.resources.listing = await this.getListing( - generateListingId({ - version: notification.version, - network: notification.network, - listingIndex: notification.resources.listingId - }) - ) - } - if (notification.resources.offerId) { - let offer - try { - offer = await this.getOffer( + try { + if (notification.resources.listingId) { + notification.resources.listing = await this.getListing( + generateListingId({ + version: notification.version, + network: notification.network, + listingIndex: notification.resources.listingId + }) + ) + } + if (notification.resources.offerId) { + notification.resources.offer = await this.getOffer( generateOfferId({ version: notification.version, network: notification.network, @@ -642,16 +663,24 @@ export default class Marketplace { offerIndex: notification.resources.offerId }) ) - } catch(e) { - isValid = false } - notification.resources.purchase = offer - } - return isValid ? new Notification(notification) : null + return new Notification(notification) + } catch(e) { + // Guard against invalid listing/offer that might be created for example + // by exploiting a validation loophole in origin-js listing/offer code + // or by writing directly to the blockchain. + return null + } })) return withResources.filter(notification => notification !== null) } + /** + * Update the status of a notification in the local store. + * @param {string} id - Unique notification ID + * @param {string} status - 'read' or 'unread' + * @return {Promise} + */ async setNotification({ id, status }) { if (!notificationStatuses.includes(status)) { throw new Error(`invalid notification status: ${status}`) diff --git a/origin-js/test/helpers/as-account.js b/origin-js/test/helpers/as-account.js index 0ded69fc516b..6ff25cf523ea 100644 --- a/origin-js/test/helpers/as-account.js +++ b/origin-js/test/helpers/as-account.js @@ -1,5 +1,4 @@ export default async function asAccount(web3, account, fn) { - const accounts = await web3.eth.getAccounts() const accountBefore = web3.eth.defaultAccount web3.eth.defaultAccount = account const result = await fn() diff --git a/origin-js/test/helpers/schema-validation-helper.js b/origin-js/test/helpers/schema-validation-helper.js index c0de800d3d97..8432fa7a5c57 100644 --- a/origin-js/test/helpers/schema-validation-helper.js +++ b/origin-js/test/helpers/schema-validation-helper.js @@ -130,6 +130,7 @@ export const validateNotification = (notification) => { expect(notification.resources).to.have.property('listingId').that.is.a('string') expect(notification.resources).to.have.property('offerId').that.is.a('string') expect(notification.resources).to.have.property('listing').that.is.an('object') + expect(notification.resources).to.have.property('offer').that.is.an('object') } export const validateMessaging = (messaging) => { diff --git a/origin-js/test/resource_marketplace.test.js b/origin-js/test/resource_marketplace.test.js index 05626d6424da..13b1cd7e676f 100644 --- a/origin-js/test/resource_marketplace.test.js +++ b/origin-js/test/resource_marketplace.test.js @@ -21,6 +21,7 @@ const multiUnitListingData = Object.assign({}, listingValid, { unitsTotal: 2 }) const multiUnitListingWithCommissionData = Object.assign( {}, multiUnitListingData, + { commission: { currency: 'OGN', amount: '2' }, commissionPerUnit: { currency: 'OGN', amount: '1' } @@ -106,8 +107,18 @@ describe('Marketplace Resource', function() { store }) + // Set default account for contract calls. + // Use helper method asAccount to make calls on behalf of a different user. + contractService.web3.eth.defaultAccount = accounts[0] + + // Create a listing using default account. await marketplace.createListing(listingData) - await marketplace.makeOffer('999-000-0', offerData) + + // Make an offer on that listing using the buyer account. + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.makeOffer('999-000-0', offerData) + }) + makeMaliciousOffer = async ({ affiliate = validAffiliate, arbitrator = validArbitrator }) => { const ipfsHash = await marketplace.ipfsDataStore.save(OFFER_DATA_TYPE, offerData) const ipfsBytes = contractService.getBytes32FromIpfsHash(ipfsHash) @@ -157,13 +168,7 @@ describe('Marketplace Resource', function() { }) it('should return listing data as it was when an offer was made with purchasesFor option', async () => { - await asAccount(contractService.web3, validBuyer, async () => { - await marketplace.makeOffer('999-000-0', offerData) - }) - - await asAccount(contractService.web3, this.userAddress, async () => { - await marketplace.updateListing('999-000-0', udpatedListingData) - }) + await marketplace.updateListing('999-000-0', udpatedListingData) const listings = await marketplace.getListings({ purchasesFor: validBuyer, @@ -424,7 +429,9 @@ describe('Marketplace Resource', function() { let offer = await marketplace.getOffer('999-000-0-0') expect(offer.status).to.equal('created') await marketplace.acceptOffer('999-000-0-0') - await marketplace.finalizeOffer('999-000-0-0', reviewData) + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.finalizeOffer('999-000-0-0', reviewData) + }) offer = await marketplace.getOffer('999-000-0-0') validateOffer(offer) @@ -434,39 +441,25 @@ describe('Marketplace Resource', function() { describe('myPurchases', () => { it('should return a user\'s purchases with listing data as it was at the time of the offer', async () => { - await asAccount(contractService.web3, validBuyer, async () => { - await marketplace.makeOffer('999-000-0', offerData) - }) - - await asAccount(contractService.web3, this.userAddress, async () => { - await marketplace.updateListing('999-000-0', udpatedListingData) - }) - + await marketplace.updateListing('999-000-0', udpatedListingData) const purchases = await marketplace.getPurchases(validBuyer) expect(purchases).to.be.an('array') - expect(purchases.length).to.equal(2) - expect(purchases[1].offer.listingId).to.equal('999-000-0') - expect(purchases[1].listing.title).to.equal('my listing') // not 'my listing EDITED!' + expect(purchases.length).to.equal(1) + expect(purchases[0].offer.listingId).to.equal('999-000-0') + expect(purchases[0].listing.title).to.equal('my listing') // not 'my listing EDITED!' }) }) describe('mySales', () => { it('should return a seller\'s sales with listing data as it was at the time of the offer', async () => { - await asAccount(contractService.web3, validBuyer, async () => { - await marketplace.makeOffer('999-000-0', offerData) - }) - - await asAccount(contractService.web3, this.userAddress, async () => { - await marketplace.updateListing('999-000-0', udpatedListingData) - }) - + await marketplace.updateListing('999-000-0', udpatedListingData) const sales = await marketplace.getSales(this.userAddress) expect(sales).to.be.an('array') - expect(sales.length).to.equal(2) - expect(sales[1].offer.listingId).to.equal('999-000-0') - expect(sales[1].listing.title).to.equal('my listing') // not 'my listing EDITED!' + expect(sales.length).to.equal(1) + expect(sales[0].offer.listingId).to.equal('999-000-0') + expect(sales[0].listing.title).to.equal('my listing') // not 'my listing EDITED!' }) }) @@ -475,7 +468,9 @@ describe('Marketplace Resource', function() { let offer = await marketplace.getOffer('999-000-0-0') expect(offer.status).to.equal('created') await marketplace.acceptOffer('999-000-0-0') - await marketplace.finalizeOffer('999-000-0-0', reviewData) + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.finalizeOffer('999-000-0-0', reviewData) + }) await marketplace.addData(0, offer.id, reviewData) offer = await marketplace.getOffer('999-000-0-0') @@ -487,7 +482,9 @@ describe('Marketplace Resource', function() { describe('getListingReviews', () => { it('should get reviews', async () => { await marketplace.acceptOffer('999-000-0-0') - await marketplace.finalizeOffer('999-000-0-0', reviewData) + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.finalizeOffer('999-000-0-0', reviewData) + }) const reviews = await marketplace.getListingReviews('999-000-0') expect(reviews.length).to.equal(1) expect(reviews[0].rating).to.equal(3) @@ -498,41 +495,100 @@ describe('Marketplace Resource', function() { describe('getNotifications', () => { let notifications - beforeEach(async function() { - notifications = await marketplace.getNotifications() + function expectNotification(type, eventName) { expect(notifications.length).to.equal(1) validateNotification(notifications[0]) - expect(notifications[0].type).to.equal('seller_listing_purchased') + expect(notifications[0].type).to.equal(type) expect(notifications[0].status).to.equal('unread') + expect(notifications[0].event.event).to.equal(eventName) + } + + beforeEach(async function() { + // Before each test a listing is created with an offer from a buyer. + // Therefore seller should receive a notification for it. + notifications = await marketplace.getNotifications() + expectNotification('seller_offer_created', 'OfferCreated') }) it('should return notifications', async () => { + // Seller accepts the offer. Buyer should receive a notification. await marketplace.acceptOffer('999-000-0-0') + await asAccount(contractService.web3, validBuyer, async () => { + notifications = await marketplace.getNotifications() + }) + expectNotification('buyer_offer_accepted', 'OfferAccepted') + + // Buyer finalizes, seller should receive a notification. + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.finalizeOffer('999-000-0-0', reviewData) + }) notifications = await marketplace.getNotifications() - expect(notifications.length).to.equal(1) - validateNotification(notifications[0]) + expectNotification('seller_offer_finalized', 'OfferFinalized') - expect(notifications[0].type).to.equal('buyer_listing_shipped') - expect(notifications[0].status).to.equal('unread') - expect(notifications[0].event.event).to.equal('OfferAccepted') + // Seller writes a review, buyer should receive a notification. + await marketplace.addData(0, '999-000-0-0', reviewData) + await asAccount(contractService.web3, validBuyer, async () => { + notifications = await marketplace.getNotifications() + }) + expectNotification('buyer_offer_review', 'OfferData') + }) + + it('buyer should get a notifications when offer rejected by seller', async () => { + // Seller rejects offer, buyer should receive a notification. + await marketplace.withdrawOffer('999-000-0-0') + await asAccount(contractService.web3, validBuyer, async () => { + notifications = await marketplace.getNotifications() + }) + expectNotification('buyer_offer_withdrawn', 'OfferWithdrawn') + }) + + it('seller should get a notifications when offer withdrawn by buyer', async () => { + // Seller rejects offer, buyer should receive a notification. + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.withdrawOffer('999-000-0-0') + }) + notifications = await marketplace.getNotifications() + expectNotification('seller_offer_withdrawn', 'OfferWithdrawn') + }) - await marketplace.finalizeOffer('999-000-0-0', reviewData) + it('Should get a notifications when offer disputed and ruled', async () => { + await marketplace.acceptOffer('999-000-0-0') + // Buyer initiates dispute, seller should get a notification. + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.initiateDispute('999-000-0-0') + }) notifications = await marketplace.getNotifications() - expect(notifications.length).to.equal(1) - validateNotification(notifications[0]) + expectNotification('seller_offer_disputed', 'OfferDisputed') - expect(notifications[0].type).to.equal('seller_review_received') - expect(notifications[0].status).to.equal('unread') - expect(notifications[0].event.event).to.equal('OfferFinalized') + // Dispute ruled, both buyer and seller should get a notification. + await asAccount(contractService.web3, validArbitrator, async () => { + await marketplace.resolveDispute('999-000-0-0', {}, 1, 0) + }) + notifications = await marketplace.getNotifications() + expectNotification('seller_offer_ruling', 'OfferRuling') + + await asAccount(contractService.web3, validBuyer, async () => { + notifications = await marketplace.getNotifications() + }) + expectNotification('buyer_offer_ruling', 'OfferRuling') }) - it('should exclude notifications for invalid offers', async () => { - await marketplace.makeOffer('999-000-0', invalidPriceOffer) + it('Buyer should get a notifications when offer disputed by seller', async () => { + await marketplace.acceptOffer('999-000-0-0') + await marketplace.initiateDispute('999-000-0-0') + await asAccount(contractService.web3, validBuyer, async () => { + notifications = await marketplace.getNotifications() + }) + expectNotification('buyer_offer_disputed', 'OfferDisputed') + }) - const notifications = await marketplace.getNotifications() + it('should exclude notifications for invalid offers', async () => { + await asAccount(contractService.web3, validBuyer, async () => { + await marketplace.makeOffer('999-000-0', invalidPriceOffer) + }) + notifications = await marketplace.getNotifications() expect(notifications.length).to.equal(1) - validateNotification(notifications[0]) expect(notifications).to.not.include(invalidPriceOffer) }) }) @@ -700,7 +756,7 @@ describe('Marketplace Resource', function() { // Create a second offer for 1 unit. await marketplace.makeOffer('999-000-1', offerData) - let offer2 = await marketplace.getOffer('999-000-1-2') + const offer2 = await marketplace.getOffer('999-000-1-2') expect(offer2.status).to.equal('created') validateOffer(offer2) @@ -783,7 +839,7 @@ describe('Marketplace Resource', function() { // Create and withdraw an offer for 1 unit. await marketplace.makeOffer('999-000-1', offerData) - let offer2 = await marketplace.getOffer('999-000-1-1') + const offer2 = await marketplace.getOffer('999-000-1-1') expect(offer2.status).to.equal('created') await marketplace.withdrawOffer('999-000-1-1') @@ -804,7 +860,7 @@ describe('Marketplace Resource', function() { const newOfferData = Object.assign( {}, offerData, - { unitsPurchased: newUnitsTotal} + { unitsPurchased: newUnitsTotal } ) // Make an offer for too many units, which should fail.