Repository: CSCfi/rems (clojure)

Issue:
vTHL4: Adding and removing resources to approved application
(split out from #842)

- [x] when the application has been approved, resources can be added or removed by the handler
- [x] entitlements should update

Pull request description:
Closes #1104 

I had to rewrite the entitlement giving logic, but fortunately we have some coverage in the acceptance tests and I added tests for changing resources there.

There is possibly some refactoring opportunity still left.

Patch:
diff --git a/resources/sql/queries.sql b/resources/sql/queries.sql
index 76e912d6c1..0ea847cbf7 100644
--- a/resources/sql/queries.sql
+++ b/resources/sql/queries.sql
@@ -92,7 +92,14 @@ SELECT
   enabled,
   archived
 FROM resource
-WHERE id = :id;
+WHERE 1=1
+/*~ (when (:id params) */
+  AND id = :id
+/*~ ) ~*/
+/*~ (when (:resid params) */
+  AND resid = :resid
+/*~ ) ~*/
+;
 
 -- :name create-resource! :insert
 -- :doc Create a single resource
@@ -329,13 +336,16 @@ WHERE 1=1
 INSERT INTO entitlement (catAppId, userId, resId)
 VALUES (:application, :user, :resource);
 
--- :name end-entitlement! :!
+-- :name end-entitlements! :!
 UPDATE entitlement
 SET endt = current_timestamp
 WHERE catAppId = :application
 /*~ (when (:user params) */
   AND entitlement.userId = :user
 /*~ ) ~*/
+/*~ (when (:resource params) */
+  AND entitlement.resId = :resource
+/*~ ) ~*/
 ;
 
 -- :name get-entitlements :?
@@ -345,7 +355,7 @@ WHERE catAppId = :application
 --   :user -- user id to limit select to
 --   :resource -- resid to limit select to
 --   :is-active? -- entitlement is without end date
-SELECT res.resId, catAppId, entitlement.userId, entitlement.start, users.userAttrs->>'mail' AS mail FROM entitlement
+SELECT res.id AS resourceId, res.resId, catAppId, entitlement.userId, entitlement.start, entitlement.endt AS "end", users.userAttrs->>'mail' AS mail FROM entitlement
 LEFT OUTER JOIN resource res ON entitlement.resId = res.id
 LEFT OUTER JOIN users on entitlement.userId = users.userId
 WHERE 1=1
@@ -356,7 +366,7 @@ WHERE 1=1
   AND entitlement.userId = :user
 /*~ ) ~*/
 /*~ (when (:resource params) */
-  AND res.resId = :resource
+  AND res.id = :resource
 /*~ ) ~*/
 /*~ (when (:is-active? params) */
   AND entitlement.endt IS NULL
diff --git a/src/clj/rems/application/model.clj b/src/clj/rems/application/model.clj
index 1b35ae710c..2a5cb7b4a7 100644
--- a/src/clj/rems/application/model.clj
+++ b/src/clj/rems/application/model.clj
@@ -66,6 +66,7 @@
                                      :member [:application.command/accept-licenses]
                                      :handler [:see-everything
                                                :application.command/add-member
+                                               :application.command/change-resources
                                                :application.command/remove-member
                                                :application.command/invite-member
                                                :application.command/uninvite-member
diff --git a/src/clj/rems/db/entitlements.clj b/src/clj/rems/db/entitlements.clj
index b97a774e07..76972a84ee 100644
--- a/src/clj/rems/db/entitlements.clj
+++ b/src/clj/rems/db/entitlements.clj
@@ -1,6 +1,7 @@
 (ns rems.db.entitlements
   "Creating and fetching entitlements."
   (:require [clj-http.client :as http]
+            [clojure.set :refer [union]]
             [clojure.string :refer [join]]
             [clojure.tools.logging :as log]
             [rems.auth.util :refer [throw-forbidden]]
@@ -63,52 +64,69 @@
           (log/warnf "Post failed: %s", response))
         (db/log-entitlement-post! {:target target :payload json-payload :status status})))))
 
-(defn- add-entitlements-for
-  "If the given application is approved, add an entitlement to the db
-  and call the entitlement REST callback (if defined)."
-  [application]
-  (when (= :application.state/approved (:application/state application))
-    (let [app-id (:application/id application)
-          members (conj (map :userid (:application/members application))
-                        (:application/applicant application))
-          has-entitlement? (set (map :userid (db/get-entitlements {:application app-id})))
-          members-to-update (->> members
-                                 (filter #(accepted-licenses? application %))
-                                 (remove has-entitlement?))]
-      (when (seq members-to-update)
-        (doseq [user-id members-to-update]
-          (log/info "granting entitlements on application" app-id "to" user-id)
-          (doseq [[resource-id ext-id] (->> (:application/resources application)
-                                            (map (juxt :resource/id :resource/ext-id)))]
-            (db/add-entitlement! {:application app-id
-                                  :user user-id
-                                  :resource resource-id})
-            (post-entitlements :add (db/get-entitlements {:application app-id :user user-id :resource ext-id})))))
-      members-to-update)))
+(defn- get-entitlements-by-user [application-id]
+  (->> (db/get-entitlements {:application application-id :is-active? true})
+       (group-by :userid)
+       (map (fn [[userid rows]]
+              [userid (set (map :resourceid rows))]))
+       (into {})))
 
-(defn- end-entitlements-for
-  [application]
-  (let [app-id (:application/id application)
-        members (case (:application/state application)
-                    :application.state/closed
-                    (conj (map :userid (:application/members application))
-                          (:application/applicant application))
+(defn- grant-entitlements! [application-id user-id resource-ids]
+  (log/info "granting entitlements on application" application-id "to" user-id "resources" resource-ids)
+  (doseq [resource-id (sort resource-ids)]
+    (db/add-entitlement! {:application application-id
+                          :user user-id
+                          :resource resource-id})
+    (post-entitlements :add (db/get-entitlements {:application application-id :user user-id :resource resource-id}))))
 
-                    :application.state/approved
-                    (map :userid (:application/past-members application)) ; someone was removed
-
-                    []) ; by default don't do anything
-        has-entitlement? (set (map :userid (db/get-entitlements {:application app-id})))
-        members-to-update (->> members
-                               (filter has-entitlement?))]
-    (when (seq members-to-update)
-      (doseq [user-id members-to-update]
-        (log/info "ending entitlements on application" app-id "to" user-id)
-        (db/end-entitlement! {:application app-id :user user-id})
-        (post-entitlements :remove (db/get-entitlements {:application app-id :user user-id}))))
-    members-to-update))
+(defn- revoke-entitlements! [application-id user-id resource-ids]
+  (log/info "revoking entitlements on application" application-id "to" user-id "resources" resource-ids)
+  (doseq [resource-id (sort resource-ids)]
+    (db/end-entitlements! {:application application-id
+                           :user user-id
+                           :resource resource-id})
+    (post-entitlements :remove (db/get-entitlements {:application application-id :user user-id :resource resource-id}))))
 
 (defn update-entitlements-for
+  "If the given application is approved, licenses accepted etc. add an entitlement to the db
+  and call the entitlement REST callback (if defined). Likewise if a resource is removed, member left etc.
+  then we end the entitlement and call the REST callback."
   [application]
-  (add-entitlements-for application)
-  (end-entitlements-for application))
+  (when (contains? #{:application.state/approved :application.state/closed}
+                   (:application/state application))
+    (let [application-id (:application/id application)
+          current-members (set (concat (map :userid (:application/members application))
+                                       [(:application/applicant application)]))
+          past-members (set (map :userid (:application/past-members application)))
+          application-state (:application/state application)
+          application-resources (->> application
+                                     :application/resources
+                                     (map :resource/id)
+                                     set)
+          application-entitlements (get-entitlements-by-user application-id)
+          is-entitled? (fn [userid resource-id]
+                         (and (= :application.state/approved application-state)
+                              (contains? current-members userid)
+                              (accepted-licenses? application userid)
+                              (contains? application-resources resource-id)))
+          entitlements-by-user (fn [userid] (or (application-entitlements userid) #{}))
+          entitlements-to-add (->> (for [userid (union current-members past-members)
+                                         :let [current-resource-ids (entitlements-by-user userid)]
+                                         resource-id application-resources
+                                         :when (is-entitled? userid resource-id)
+                                         :when (not (contains? current-resource-ids resource-id))]
+                                     {userid #{resource-id}})
+                                   (apply merge-with union))
+          entitlements-to-remove (->> (for [userid (union current-members past-members)
+                                            :let [resource-ids (entitlements-by-user userid)]
+                                            resource-id resource-ids
+                                            :when (not (is-entitled? userid resource-id))]
+                                        {userid #{resource-id}})
+                                      (apply merge-with union))
+          members-to-update (keys (merge entitlements-to-add entitlements-to-remove))]
+      (when (seq members-to-update)
+        (log/info "updating entitlements on application" application-id)
+        (doseq [[userid resource-ids] entitlements-to-add]
+          (grant-entitlements! application-id userid resource-ids))
+        (doseq [[userid resource-ids] entitlements-to-remove]
+          (revoke-entitlements! application-id userid resource-ids))))))
diff --git a/src/clj/rems/poller/common.clj b/src/clj/rems/poller/common.clj
index 0b7fc156d6..e3835e7cb5 100644
--- a/src/clj/rems/poller/common.clj
+++ b/src/clj/rems/poller/common.clj
@@ -25,7 +25,7 @@
         (throw (InterruptedException.)))
       (try
         ;; TODO: add proper monitoring for pollers and caches
-        (log/info name-kw "processing event" (:event/id event))
+        (log/info name-kw "processing event" (:event/id event) (:event/type event))
         (process-event! event)
         (set-poller-state! name-kw {:last-processed-event-id (:event/id event)})
         (catch Throwable t
diff --git a/src/clj/rems/poller/entitlements.clj b/src/clj/rems/poller/entitlements.clj
index de38b46c6d..fea8cd5580 100644
--- a/src/clj/rems/poller/entitlements.clj
+++ b/src/clj/rems/poller/entitlements.clj
@@ -15,6 +15,7 @@
   (when (contains? #{:application.event/approved
                      :application.event/licenses-accepted
                      :application.event/member-removed
+                     :application.event/resources-changed
                      :application.event/closed}
                    (:event/type event))
     (let [application (applications/get-unrestricted-application (:application/id event))]


Test patch:
diff --git a/test/clj/rems/application/test_commands.clj b/test/clj/rems/application/test_commands.clj
index 4e0988b0cd..b639865bf1 100644
--- a/test/clj/rems/application/test_commands.clj
+++ b/test/clj/rems/application/test_commands.clj
@@ -179,6 +179,16 @@
                                                   :event/time test-time
                                                   :event/actor applicant-user-id
                                                   :application/id 123}])
+        approved-application (apply-events nil [dummy-created-event
+                                                {:event/type :application.event/submitted
+                                                 :event/time test-time
+                                                 :event/actor applicant-user-id
+                                                 :application/id 123}
+                                                {:event/type :application.event/approved
+                                                 :event/time test-time
+                                                 :event/actor handler-user-id
+                                                 :application/comment "This is good"
+                                                 :application/id 123}])
         injections {:get-catalogue-item {1 {:id 1 :resid "abc" :formid 1 :wfid 1}
                                          2 {:id 2 :resid "efg" :formid 1 :wfid 1}
                                          3 {:id 3 :resid "hij" :formid 1 :wfid 2}
@@ -263,6 +273,37 @@
                           :catalogue-item-ids [1 2 3 4]}
                          injections))))
 
+    (testing "handler can change approved resources"
+      (is (= {:event/type :application.event/resources-changed
+              :event/time test-time
+              :event/actor handler-user-id
+              :application/id 123
+              :application/comment "Changed these for you"
+              :application/resources [{:catalogue-item/id 1 :resource/ext-id "abc"}
+                                      {:catalogue-item/id 2 :resource/ext-id "efg"}]}
+             (ok-command approved-application
+                         {:type :application.command/change-resources
+                          :actor handler-user-id
+                          :comment "Changed these for you"
+                          :catalogue-item-ids [1 2]}
+                         injections)))
+
+      (is (= {:event/type :application.event/resources-changed
+              :event/time test-time
+              :event/actor handler-user-id
+              :application/id 123
+              :application/comment "Changed these for you"
+              :application/resources [{:catalogue-item/id 1 :resource/ext-id "abc"}
+                                      {:catalogue-item/id 2 :resource/ext-id "efg"}
+                                      {:catalogue-item/id 3 :resource/ext-id "hij"}
+                                      {:catalogue-item/id 4 :resource/ext-id "klm"}]}
+             (ok-command approved-application
+                         {:type :application.command/change-resources
+                          :actor handler-user-id
+                          :comment "Changed these for you"
+                          :catalogue-item-ids [1 2 3 4]}
+                         injections))))
+
     (is (= {:errors [{:type :invalid-catalogue-item :catalogue-item-id 42}]}
            (fail-command application
                          {:type :application.command/change-resources
diff --git a/test/clj/rems/application/test_model.clj b/test/clj/rems/application/test_model.clj
index 3baa2cc6b9..1e86c2a028 100644
--- a/test/clj/rems/application/test_model.clj
+++ b/test/clj/rems/application/test_model.clj
@@ -523,6 +523,43 @@
                                                              :application/state :application.state/approved})]
                             (is (= expected-application (apply-events events)))
 
+                            (testing "> resources changed by handler"
+                              (let [events (conj events
+                                                 {:event/type :application.event/resources-changed
+                                                  :event/time (DateTime. 4500)
+                                                  :event/actor "handler"
+                                                  :application/id 1
+                                                  :application/comment "I changed the resources"
+                                                  :application/resources [{:catalogue-item/id 10 :resource/ext-id "urn:11"}
+                                                                          {:catalogue-item/id 30 :resource/ext-id "urn:31"}]})
+                                    expected-application (deep-merge expected-application
+                                                                     {:application/last-activity (DateTime. 4500)
+                                                                      :application/modified (DateTime. 4500)
+                                                                      :application/events events
+                                                                      :application/resources [{:catalogue-item/id 10
+                                                                                               :resource/id 11
+                                                                                               :resource/ext-id "urn:11"
+                                                                                               :catalogue-item/title {:en "en title"
+                                                                                                                      :fi "fi title"
+                                                                                                                      :default "non-localized title"}
+                                                                                               :catalogue-item/start (DateTime. 100)
+                                                                                               :catalogue-item/end nil
+                                                                                               :catalogue-item/enabled true
+                                                                                               :catalogue-item/expired false
+                                                                                               :catalogue-item/archived false}
+                                                                                              {:catalogue-item/id 30
+                                                                                               :resource/id 31
+                                                                                               :resource/ext-id "urn:31"
+                                                                                               :catalogue-item/title {:en "en title"
+                                                                                                                      :fi "fi title"
+                                                                                                                      :default "non-localized title"}
+                                                                                               :catalogue-item/start (DateTime. 100)
+                                                                                               :catalogue-item/end nil
+                                                                                               :catalogue-item/enabled true
+                                                                                               :catalogue-item/expired false
+                                                                                               :catalogue-item/archived false}]})]
+                                (is (= expected-application (apply-events events)))))
+
                             (testing "> licenses accepted"
                               (let [events (conj events
                                                  {:event/type :application.event/licenses-accepted
diff --git a/test/clj/rems/db/test_entitlements.clj b/test/clj/rems/db/test_entitlements.clj
index b223ff8d6a..6f62b06559 100644
--- a/test/clj/rems/db/test_entitlements.clj
+++ b/test/clj/rems/db/test_entitlements.clj
@@ -69,38 +69,46 @@
             (is (= "exception" status))
             (is (= +expected-payload+ (cheshire/parse-string payload)))))))))
 
+(defmacro with-stub-server [sym & body]
+  `(let [~sym (stub/start! {"/add" {:status 200}
+                            "/remove" {:status 200}})]
+     (with-redefs [rems.config/env {:entitlements-target
+                                    {:add (str (:uri ~sym) "/add")
+                                     :remove (str (:uri ~sym) "/remove")}}]
+       ~@body)))
+
 (deftest test-entitlement-granting
   (entitlements-poller/run) ; clear previous entitlements
-  (with-open [server (stub/start! {"/add" {:status 200}
-                                   "/remove" {:status 200}})]
-    (with-redefs [rems.config/env {:entitlements-target
-                                   {:add (str (:uri server) "/add")
-                                    :remove (str (:uri server) "/remove")}}]
-      (let [uid "bob"
-            memberid "elsa"
-            admin "owner"
-            organization "foo"
-            workflow {:type :workflow/dynamic :handlers [admin]}
-            wfid (:id (db/create-workflow! {:organization "abc" :modifieruserid "owner" :owneruserid "owner" :title "dynamic" :fnlround -1 :workflow (cheshire/generate-string workflow)}))
-            formid (:id (db/create-form! {:organization "abc" :title "internal-title" :user "owner"}))
-            lic-id1 (:id (licenses/create-license! {:licensetype "text"
-                                                    :title "license1"
-                                                    :textcontent "license1 text"
-                                                    :localizations {}}
-                                                   "owner"))
-            lic-id2 (:id (licenses/create-license! {:licensetype "text"
-                                                    :title "license2"
-                                                    :textcontent "license2 text"
-                                                    :localizations {}}
-                                                   "owner"))
-            res1 (:id (resource/create-resource! {:resid "resource1" :organization organization :licenses [lic-id1]} "owner"))
-            res2 (:id (resource/create-resource! {:resid "resource2" :organization organization :licenses [lic-id2]} "owner"))
-            item1 (:id (db/create-catalogue-item! {:title "item1" :form formid :resid res1 :wfid wfid}))
-            item2 (:id (db/create-catalogue-item! {:title "item2" :form formid :resid res2 :wfid wfid}))]
-        (db/add-user! {:user uid :userattrs (cheshire/generate-string {"mail" "b@o.b"})})
-        (db/add-user! {:user memberid :userattrs (cheshire/generate-string {"mail" "e.l@s.a"})})
-        (db/add-user! {:user admin :userattrs nil})
-        (let [app-id (:application-id (applications/create-application! uid [item1 item2]))]
+  (let [uid "bob"
+        memberid "elsa"
+        admin "owner"
+        organization "foo"
+        workflow {:type :workflow/dynamic :handlers [admin]}
+        wfid (:id (db/create-workflow! {:organization "abc" :modifieruserid "owner" :owneruserid "owner" :title "dynamic" :fnlround -1 :workflow (cheshire/generate-string workflow)}))
+        formid (:id (db/create-form! {:organization "abc" :title "internal-title" :user "owner"}))
+        lic-id1 (:id (licenses/create-license! {:licensetype "text"
+                                                :title "license1"
+                                                :textcontent "license1 text"
+                                                :localizations {}}
+                                               "owner"))
+        lic-id2 (:id (licenses/create-license! {:licensetype "text"
+                                                :title "license2"
+                                                :textcontent "license2 text"
+                                                :localizations {}}
+                                               "owner"))
+        res1 (:id (resource/create-resource! {:resid "resource1" :organization organization :licenses [lic-id1]} "owner"))
+        res2 (:id (resource/create-resource! {:resid "resource2" :organization organization :licenses [lic-id2]} "owner"))
+        res3 (:id (resource/create-resource! {:resid "resource3" :organization organization :licenses [lic-id1]} "owner"))
+        item1 (:id (db/create-catalogue-item! {:title "item1" :form formid :resid res1 :wfid wfid}))
+        item2 (:id (db/create-catalogue-item! {:title "item2" :form formid :resid res2 :wfid wfid}))
+        item3 (:id (db/create-catalogue-item! {:title "item3" :form formid :resid res3 :wfid wfid}))]
+    (db/add-user! {:user uid :userattrs (cheshire/generate-string {"mail" "b@o.b"})})
+    (db/add-user! {:user memberid :userattrs (cheshire/generate-string {"mail" "e.l@s.a"})})
+    (db/add-user! {:user admin :userattrs nil})
+
+    (let [app-id (:application-id (applications/create-application! uid [item1 item2]))]
+      (testing "submitted application should not yet cause entitlements"
+        (with-stub-server server
           (is (nil? (applications/command! {:type :application.command/submit
                                             :actor uid
                                             :application-id app-id
@@ -110,39 +118,41 @@
                                             :application-id app-id
                                             :member {:userid memberid}
                                             :time (time/now)})))
-          (testing "submitted application should not yet cause entitlements"
-            (entitlements-poller/run)
-            (is (empty? (db/get-entitlements {:application app-id})))
-            (is (empty? (stub/recorded-requests server))))
+          (entitlements-poller/run)
 
-          (is (nil? (applications/command! {:type :application.command/approve
-                                            :actor admin
-                                            :application-id app-id
-                                            :comment ""
-                                            :time (time/now)})))
+          (is (empty? (db/get-entitlements {:application app-id})))
+          (is (empty? (stub/recorded-requests server))))
 
-          (testing "approved application should not yet cause entitlements"
+        (testing "approved application should not yet cause entitlements"
+          (with-stub-server server
+            (is (nil? (applications/command! {:type :application.command/approve
+                                              :actor admin
+                                              :application-id app-id
+                                              :comment ""
+                                              :time (time/now)})))
             (entitlements-poller/run)
+
             (is (empty? (db/get-entitlements {:application app-id})))
-            (is (empty? (stub/recorded-requests server))))
+            (is (empty? (stub/recorded-requests server)))))
 
-          (is (nil? (applications/command! {:type :application.command/accept-licenses
-                                            :actor uid
-                                            :application-id app-id
-                                            :accepted-licenses [lic-id1 lic-id2]
-                                            :time (time/now)})))
+        (testing "approved application, licenses accepted by one user generates entitlements for that user"
+          (with-stub-server server
+            (is (nil? (applications/command! {:type :application.command/accept-licenses
+                                              :actor uid
+                                              :application-id app-id
+                                              :accepted-licenses [lic-id1 lic-id2]
+                                              :time (time/now)})))
 
-          (is (nil? (applications/command! {:type :application.command/accept-licenses
-                                            :actor memberid
-                                            :application-id app-id
-                                            :accepted-licenses [lic-id1] ; only accept some licenses
-                                            :time (time/now)})))
+            (is (nil? (applications/command! {:type :application.command/accept-licenses
+                                              :actor memberid
+                                              :application-id app-id
+                                              :accepted-licenses [lic-id1] ; only accept some licenses
+                                              :time (time/now)})))
 
-          (is (= {uid #{lic-id1 lic-id2}
-                  memberid #{lic-id1}}
-                 (:application/accepted-licenses (applications/get-unrestricted-application app-id))))
+            (is (= {uid #{lic-id1 lic-id2}
+                    memberid #{lic-id1}}
+                   (:application/accepted-licenses (applications/get-unrestricted-application app-id))))
 
-          (testing "approved application, licenses accepted by one user generates entitlements for that user"
             (entitlements-poller/run)
             (entitlements-poller/run) ;; run twice to check idempotence
             (is (= 2 (count (stub/recorded-requests server))))
@@ -159,72 +169,96 @@
                 (is (every? #{"/add"} targets))
                 (is (= [{:resource "resource1" :application app-id :user "bob" :mail "b@o.b"}
                         {:resource "resource2" :application app-id :user "bob" :mail "b@o.b"}]
-                       bodies)))))
+                       bodies)))))))
 
+      (testing "approved application, more accepted licenses generates more entitlements"
+        (with-stub-server server
           (is (nil? (applications/command! {:type :application.command/accept-licenses
                                             :actor memberid
                                             :application-id app-id
                                             :accepted-licenses [lic-id1 lic-id2] ; now accept all licenses
                                             :time (time/now)})))
+          (entitlements-poller/run)
 
-          (testing "approved application, more accepted licenses generates more entitlements"
-            (entitlements-poller/run)
-            (is (= 4 (count (stub/recorded-requests server))))
-            (testing "db"
-              (is (= [[uid "resource1"] [uid "resource2"]
-                      [memberid "resource1"] [memberid "resource2"]]
-                     (map (juxt :userid :resid) (db/get-entitlements {:application app-id})))))
-            (testing "POST"
-              (let [data (take 2 (drop 2 (stub/recorded-requests server)))
-                    targets (map :path data)
-                    bodies (->> data
-                                (map #(get-in % [:body "postData"]))
-                                (map #(cheshire/parse-string % keyword))
-                                (apply concat))]
-                (is (every? #{"/add"} targets))
-                (is (= [{:resource "resource1" :application app-id :user "elsa" :mail "e.l@s.a"}
-                        {:resource "resource2" :application app-id :user "elsa" :mail "e.l@s.a"}]
-                       bodies)))))
+          (is (= 2 (count (stub/recorded-requests server))))
+          (testing "db"
+            (is (= [[uid "resource1"] [uid "resource2"]
+                    [memberid "resource1"] [memberid "resource2"]]
+                   (map (juxt :userid :resid) (db/get-entitlements {:application app-id})))))
+          (testing "POST"
+            (let [data (stub/recorded-requests server)
+                  targets (map :path data)
+                  bodies (->> data
+                              (map #(get-in % [:body "postData"]))
+                              (map #(cheshire/parse-string % keyword))
+                              (apply concat))]
+              (is (every? #{"/add"} targets))
+              (is (= [{:resource "resource1" :application app-id :user "elsa" :mail "e.l@s.a"}
+                      {:resource "resource2" :application app-id :user "elsa" :mail "e.l@s.a"}]
+                     bodies))))))
 
+      (testing "removing a member ends entitlements"
+        (with-stub-server server
           (is (nil? (applications/command! {:type :application.command/remove-member
                                             :actor admin
                                             :application-id app-id
                                             :member {:userid memberid}
                                             :comment "Left team"
                                             :time (time/now)})))
+          (entitlements-poller/run)
 
-          (testing "removing a member ends entitlements"
-            (entitlements-poller/run)
-            (is (= 5 (count (stub/recorded-requests server))))
-            (testing "db"
-              (is (= [[uid "resource1"] [uid "resource2"]]
-                     (map (juxt :userid :resid) (db/get-entitlements {:application app-id :is-active? true})))))
-            (testing "POST"
-              (let [data (nth (stub/recorded-requests server) 4)
-                    target (:path data)
-                    body (cheshire/parse-string (get-in data [:body "postData"]) keyword)]
-                (is (= "/remove" target))
-                (is (= [{:resource "resource1" :application app-id :user "elsa" :mail "e.l@s.a"}
-                        {:resource "resource2" :application app-id :user "elsa" :mail "e.l@s.a"}]
-                       body)))))
+          (is (= 2 (count (stub/recorded-requests server))))
+          (testing "db"
+            (is (= [[uid "resource1"] [uid "resource2"]]
+                   (map (juxt :userid :resid) (db/get-entitlements {:application app-id :is-active? true})))))
+          (testing "POST"
+            (let [data (stub/recorded-requests server)
+                  targets (map :path data)
+                  bodies (->> data
+                              (map #(get-in % [:body "postData"]))
+                              (map #(cheshire/parse-string % keyword))
+                              (apply concat))]
+              (is (every? #{"/remove"} targets))
+              (is (= [{:resource "resource1" :application app-id :user "elsa" :mail "e.l@s.a"}
+                      {:resource "resource2" :application app-id :user "elsa" :mail "e.l@s.a"}]
+                     bodies))))))
 
+      (testing "changing resources changes entitlements"
+        (with-stub-server server
+          (is (nil? (applications/command! {:type :application.command/change-resources
+                                            :actor admin
+                                            :application-id app-id
+                                            :catalogue-item-ids [item1 item3]
+                                            :comment "Removed second resource, added third resource"
+                                            :time (time/now)})))
+          (entitlements-poller/run)
+
+          (is (= 2 (count (stub/recorded-requests server))))
+          (testing "db"
+            (is (= [[uid "resource1"] [uid "resource3"]]
+                   (map (juxt :userid :resid) (db/get-entitlements {:application app-id :is-active? true})))))
+          (testing "POST"
+            (let [data (stub/recorded-requests server)
+                  data (map #(assoc % :body (cheshire/parse-string (get-in % [:body "postData"]) keyword)) data)]
+              (is (= [{:path "/add" :body [{:resource "resource3" :application app-id :user "bob" :mail "b@o.b"}]}
+                      {:path "/remove" :body [{:resource "resource2" :application app-id :user "bob" :mail "b@o.b"}]}]
+                     (map #(select-keys % [:path :body]) data)))))))
+
+      (testing "closed application should end entitlements"
+        (with-stub-server server
           (is (nil? (applications/command! {:type :application.command/close
                                             :actor admin
                                             :application-id app-id
-                                            :comment ""
+                                            :comment "Finished"
                                             :time (time/now)})))
+          (entitlements-poller/run)
 
-          (testing "closed application should end entitlements"
-            (entitlements-poller/run)
-            (is (= 6 (count (stub/recorded-requests server))))
-            (testing "db"
-              (is (= []
-                     (map (juxt :userid :resid) (db/get-entitlements {:application app-id :is-active? true})))))
-            (testing "POST"
-              (let [data (nth (stub/recorded-requests server) 5)
-                    target (:path data)
-                    body (cheshire/parse-string (get-in data [:body "postData"]) keyword)]
-                (is (= "/remove" target))
-                (is (= [{:resource "resource1" :application app-id :user "bob" :mail "b@o.b"}
-                        {:resource "resource2" :application app-id :user "bob" :mail "b@o.b"}]
-                       body))))))))))
+          (is (= 2 (count (stub/recorded-requests server))))
+          (testing "db"
+            (is (= [] (db/get-entitlements {:application app-id :is-active? true}))))
+          (testing "POST"
+            (let [data (stub/recorded-requests server)
+                  data (map #(assoc % :body (cheshire/parse-string (get-in % [:body "postData"]) keyword)) data)]
+              (is (= [{:path "/remove" :body [{:resource "resource1" :application app-id :user "bob" :mail "b@o.b"}]}
+                      {:path "/remove" :body [{:resource "resource3" :application app-id :user "bob" :mail "b@o.b"}]}]
+                     (map #(select-keys % [:path :body]) data))))))))))
diff --git a/test/clj/rems/test_db.clj b/test/clj/rems/test_db.clj
index 1b5f4b2df5..61f1a15b02 100644
--- a/test/clj/rems/test_db.clj
+++ b/test/clj/rems/test_db.clj
@@ -630,24 +630,24 @@
         item2 (:id (db/create-catalogue-item! {:title "item2" :form formid :resid res2 :wfid wf}))
         jack-app (:application-id (applications/create-application! "jack" [item1]))
         jill-app (:application-id (applications/create-application! "jill" [item1 item2]))]
-    (assert (nil? (applications/command! {:type :application.command/submit
-                                          :time (time/now)
-                                          :actor "jack"
-                                          :application-id jack-app})))
-    (assert (nil? (applications/command! {:type :application.command/approve
-                                          :time (time/now)
-                                          :actor "handler"
-                                          :application-id jack-app
-                                          :comment ""})))
-    (assert (nil? (applications/command! {:type :application.command/submit
-                                          :time (time/now)
-                                          :actor "jill"
-                                          :application-id jill-app})))
-    (assert (nil? (applications/command! {:type :application.command/approve
-                                          :time (time/now)
-                                          :actor "handler"
-                                          :application-id jill-app
-                                          :comment ""})))
+    (is (nil? (applications/command! {:type :application.command/submit
+                                      :time (time/now)
+                                      :actor "jack"
+                                      :application-id jack-app})))
+    (is (nil? (applications/command! {:type :application.command/approve
+                                      :time (time/now)
+                                      :actor "handler"
+                                      :application-id jack-app
+                                      :comment ""})))
+    (is (nil? (applications/command! {:type :application.command/submit
+                                      :time (time/now)
+                                      :actor "jill"
+                                      :application-id jill-app})))
+    (is (nil? (applications/command! {:type :application.command/approve
+                                      :time (time/now)
+                                      :actor "handler"
+                                      :application-id jill-app
+                                      :comment ""})))
     (entitlements-poller/run)
 
     (binding [context/*roles* #{:handler}]