mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 14:00:37 +00:00
* ✨ Enrich backend audit events with team and entity ids Backend audit events now carry the data a consumer needs to rebuild state without joining production tables: - prepare-rpc-event adds :team-id when the event carries a project or file id; commands that already loaded the team set it themselves. - add-profile-to-team! emits add-team-member on every real insert. - create-project and create-file emit the new row id. - clone-template, import-binfile and duplicate-project emit one event per created entity. - both binfile versions return {:file-ids [...]} from import-files!. Closes #12219 AI-assisted-by: deepseek-v4.1-flash * ✨ Emit delete and hard-delete audit events with entity data The event stream must let Nexus rebuild local state without joining production tables. This completes the enrichment with the delete paths: - delete-team keeps returning nil but now carries the deleted row (id, name, timestamps) in the audit props, like delete-project. - create-team adds created-at to its audit props. - delete-profile carries id, email, fullname and deleted-at; its props used to be empty because every param is namespaced. - objects-gc emits hard-delete-team/project/file per purged batch, with the deleted ids, only when the audit log is on. - delete-team-member needs no change: the delete is physical and the params already carry team-id and member-id. The nitrate push-audit-events tests now drop the membership events emitted by their own profile setup before counting. Closes #12219 AI-assisted-by: muse-spark-1.3-free * ✨ Announce cascade deletes and missing ids in audit events The :delete-object worker task marked teams, projects and files without leaving any event, so an event-sourced projection kept them alive for weeks. It now emits one cascade-deleted event per execution with every affected id, in the same transaction, only with :audit-log on. Rows purged by objects-gc and snapshot cascades stay silent on purpose. Also: create-team-with-invitations carries the new team id, and update-team-member-role names the demoted member on owner transfers. Closes #12219 AI-assisted-by: muse-spark-1.3-free * 🐛 Identify the SREPL profile delete in audit events The SREPL delete-profile! emitted no profile-id and empty props, so the row could not be attributed by any consumer. It now carries the profile id in both fields. delete-team! named the wrong cause in its context. Closes #12219 AI-assisted-by: muse-spark-1.3-free * 🐛 Identify the SREPL profile restore in audit events The SREPL restore-profile! emitted no profile-id, so the consumer keyed on the zero profile instead of the restored one. It now carries the profile id like a normal profile event, and no consumer special case is needed. Closes #12219 AI-assisted-by: muse-spark-1.3-free * 🐛 Stop attributing SREPL events to the subject profile profile-id names the actor, and SREPL calls have none: setting the subject id blamed the user for an admin action and would have leaked those rows into PostHog as user activity. The subject id stays in props, where the consumer resolves it, and the event profile stays zero. Closes #12219 AI-assisted-by: muse-spark-1.3-free * ✨ Expose file analysis data for the nexus pipeline The nexus file-security pipeline needs file content and scheduling state from the backend. This adds a read-only management method, `get-file-for-analysis`, returning the semantic projection of one file (pages, frames, texts, links, interactions; ids as strings; capped with a truncated flag), plus identity, revn, timestamps and share state. No prior projection code existed, so the method ships its own pure `file->projection`. Audit events now carry what the `:local` consumer schedules from: revn and modified-at on update-file, name and timestamps on every create-file emitter, deleted-at on delete-file, and new create/delete-share-link events with file and link ids. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Rename deletion audit events to action names Event names are actions, not past tense: `cascade-deleted` becomes `delete-object-with-cascade` with the same shape and transaction. The three `hard-delete-*` events merge into `hard-delete-objects`: one event per purged chunk carrying only that chunk's entity key (`:team-ids`, `:project-ids` or `:file-ids`), because each GC section purges in its own chunk transactions and a single event could never share them all. Every submit stays inside its delete transaction, `triggered-by` stays annotated, and silent paths stay silent. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Read worker parallelism knobs with plain config keys The runner wiring looked up `:app.main/worker-*-parallelism` (namespaced) while the schema and the environment provide plain keys, so `PENPOT_WORKER_DEFAULT_PARALLELISM` and `PENPOT_WORKER_WEBHOOK_PARALLELISM` never applied and workers always ran with the fallback. Read the keys the config holds. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix binfile audit test with a real file row `submit-file-created-event` reads the light columns back from the row, so the mocked importer must return an existing file id: a phantom id emits no event and the test saw zero `create-file` events. Create the file first and use its id. The audit-log memory now tells both bulk-event helpers apart. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix audit event fidelity from review findings The review of the audit enrichment found data-fidelity gaps; fix them while the branch is fresh. Membership events now belong to the actor: the flows where the owner adds somebody else pass an explicit actor id, the role is derived from the flags (the table has no role column), and the event carries the request context like its siblings. Also: team-id resolution skips read events, duplicate-project emits the project name and timestamps, delete-profile is idempotent again, the GC tests assert physical purge, and get-file-for-analysis leaves an audit trace. AI-assisted-by: muse-spark-1.3-contributor * ♻️ Nest cascade ids under :cascade in delete event The cascade event carried every affected id flat next to the object identity; nest them under `:cascade` so the payload reads as header plus affected set. The event is an action like its siblings, not a command. Drop the `:audit-log` flag re-check: `submit` already fans out per flag. AI-assisted-by: muse-spark-1.3-contributor * 📎 Update copyright name on file header The company name changed; a single header in this branch still carried the old one. Align it with the current form. AI-assisted-by: muse-spark-1.3-contributor * 🐛 Fix nil event-id on webhook trigger audit rows process-event generated the row id inside append-audit-entry, so the :process-webhook-event task never received it and the trigger row stored a nil :event-id. Assign the id up-front and share it between the stored row and the worker params; the telemetry shadow row keeps its own id to avoid a PK collision. Add regression tests covering the handoff and the end-to-end trigger row. AI-assisted-by: muse-spark-1.3-contributor
510 lines
23 KiB
Clojure
510 lines
23 KiB
Clojure
;; This Source Code Form is subject to the terms of the Mozilla Public
|
|
;; License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
;;
|
|
;; Copyright (c) KALEIDOS SUBSIDIARY SL
|
|
|
|
(ns backend-tests.rpc-comment-test
|
|
(:require
|
|
[app.common.geom.point :as gpt]
|
|
[app.common.time :as ct]
|
|
[app.common.uuid :as uuid]
|
|
[app.db :as db]
|
|
[app.http :as http]
|
|
[app.rpc :as-alias rpc]
|
|
[app.rpc.commands.comments :as comments]
|
|
[app.rpc.cond :as cond]
|
|
[app.rpc.quotes :as-alias quotes]
|
|
[backend-tests.helpers :as th]
|
|
[clojure.test :as t]
|
|
[datoteka.fs :as fs]
|
|
[mockery.core :refer [with-mocks]]))
|
|
|
|
(t/use-fixtures :once th/state-init)
|
|
(t/use-fixtures :each th/database-reset)
|
|
|
|
(t/deftest comment-and-threads-crud
|
|
(with-mocks [mock {:target 'app.config/get
|
|
:return (th/config-get-mock
|
|
{:quotes-teams-per-profile 200})}]
|
|
|
|
(let [profile-1 (th/create-profile* 1 {:is-active true})
|
|
profile-2 (th/create-profile* 2 {:is-active true})
|
|
|
|
team (th/create-team* 1 {:profile-id (:id profile-1)})
|
|
;; role (th/create-team-role* {:team-id (:id team)
|
|
;; :profile-id (:id profile-2)
|
|
;; :role :admin})
|
|
|
|
project (th/create-project* 1 {:team-id (:id team)
|
|
:profile-id (:id profile-1)})
|
|
file-1 (th/create-file* 1 {:profile-id (:id profile-1)
|
|
:project-id (:id project)})
|
|
file-2 (th/create-file* 2 {:profile-id (:id profile-1)
|
|
:project-id (:id project)})
|
|
page-id (get-in file-1 [:data :pages 0])]
|
|
|
|
(t/testing "comment thread creation"
|
|
(let [data {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id profile-1)
|
|
:file-id (:id file-1)
|
|
:page-id page-id
|
|
:position (gpt/point 0)
|
|
:content "hello world"
|
|
:frame-id uuid/zero}
|
|
out (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [result (:result out)]
|
|
(t/is (uuid? (:id result)))
|
|
(t/is (uuid? (:file-id result)))
|
|
(t/is (uuid? (:page-id result)))
|
|
(t/is (uuid? (:comment-id result)))
|
|
(t/is (= (:file-id result) (:id file-1)))
|
|
(t/is (= (:page-id result) page-id)))))
|
|
|
|
(t/testing "comment thread status update"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
;; comment (-> (th/db-query :comment {:thread-id (:id thread)}) first)
|
|
data {::th/type :update-comment-thread-status
|
|
::rpc/profile-id (:id profile-1)
|
|
:id (:id thread)}
|
|
status (th/db-get :comment-thread-status
|
|
{:thread-id (:id thread)
|
|
:profile-id (:id profile-1)})]
|
|
|
|
|
|
(t/is (= (:modified-at status) (:modified-at thread)))
|
|
|
|
(let [{:keys [result] :as out} (th/command! data)]
|
|
(t/is (th/success? out))
|
|
(t/is (ct/inst? (:modified-at result))))
|
|
|
|
(let [status' (th/db-get :comment-thread-status
|
|
{:thread-id (:id thread)
|
|
:profile-id (:id profile-1)})]
|
|
(t/is (not= (:modified-at status') (:modified-at thread))))))
|
|
|
|
(t/testing "comment thread status update 2"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :update-comment-thread-status
|
|
::rpc/profile-id (:id profile-2)
|
|
:id (:id thread)}]
|
|
|
|
(let [{:keys [error] :as out} (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type error))))))
|
|
|
|
(t/testing "update comment thread"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :update-comment-thread
|
|
::rpc/profile-id (:id profile-1)
|
|
:is-resolved true
|
|
:id (:id thread)}]
|
|
|
|
(t/is (false? (:is-resolved thread)))
|
|
|
|
(let [{:keys [result] :as out} (th/command! data)]
|
|
(t/is (th/success? out))
|
|
(t/is (nil? result)))
|
|
|
|
(let [thread (th/db-get :comment-thread {:id (:id thread)})]
|
|
(t/is (true? (:is-resolved thread))))))
|
|
|
|
(t/testing "create comment"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :create-comment
|
|
::rpc/profile-id (:id profile-1)
|
|
:thread-id (:id thread)
|
|
:content "comment 2"}]
|
|
(let [{:keys [result] :as out} (th/command! data)
|
|
{:keys [modified-at]} (th/db-get :comment-thread-status
|
|
{:thread-id (:id thread)
|
|
:profile-id (:id profile-1)})]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(t/is (uuid? (:id result)))
|
|
(t/is (= (:owner-id result) (:id profile-1)))
|
|
(t/is (:modified-at result) modified-at))))
|
|
|
|
(t/testing "update comment"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2"}) first)
|
|
data {::th/type :update-comment
|
|
::rpc/profile-id (:id profile-1)
|
|
:id (:id comment)
|
|
:content "comment 2 mod"}]
|
|
(let [{:keys [result] :as out} (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(t/is (nil? result)))
|
|
|
|
(let [comment' (th/db-get :comment {:id (:id comment)})]
|
|
(t/is (not= (:modified-at comment) (:modified-at comment')))
|
|
(t/is (= (:content data) (:content comment'))))))
|
|
|
|
(t/testing "retrieve threads"
|
|
(let [data {::th/type :get-comment-threads
|
|
::rpc/profile-id (:id profile-1)
|
|
:file-id (:id file-1)}
|
|
out (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [[thread :as result] (:result out)]
|
|
(t/is (= 1 (count result)))
|
|
(t/is (= "Page 1" (:page-name thread)))
|
|
(t/is (= "hello world" (:content thread)))
|
|
(t/is (= 2 (:count-comments thread)))
|
|
(t/is (true? (:is-resolved thread))))))
|
|
|
|
|
|
(t/testing "unread comment threads"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :get-unread-comment-threads
|
|
::rpc/profile-id (:id profile-1)}]
|
|
|
|
(let [{:keys [result] :as out} (th/command! (assoc data :team-id (:default-team-id profile-1)))]
|
|
(t/is (th/success? out))
|
|
(t/is (= [] result)))
|
|
|
|
(let [{:keys [error] :as out} (th/command! (assoc data :team-id (:default-team-id profile-2)))]
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type error))))
|
|
|
|
(let [{:keys [result] :as out} (th/command! (assoc data :team-id (:id team)))]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [[thread :as result] (:result out)]
|
|
(t/is (= 0 (count result)))))
|
|
|
|
(let [data {::th/type :update-comment-thread-status
|
|
::rpc/profile-id (:id profile-1)
|
|
:id (:id thread)}
|
|
out (th/command! data)]
|
|
(t/is (th/success? out)))
|
|
|
|
(let [{:keys [result] :as out} (th/command! (assoc data :team-id (:id team)))]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [result (:result out)]
|
|
(t/is (= 0 (count result)))))))
|
|
|
|
(t/testing "get comment thread"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :get-comment-thread
|
|
::rpc/profile-id (:id profile-1)
|
|
:file-id (:id file-1)
|
|
:id (:id thread)}]
|
|
|
|
(let [{:keys [result] :as out} (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(t/is (= (:id thread) (:id result))))))
|
|
|
|
(t/testing "get comments"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :get-comments
|
|
::rpc/profile-id (:id profile-1)
|
|
:thread-id (:id thread)}
|
|
out (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [comments (:result out)]
|
|
(t/is (= 2 (count comments))))))
|
|
|
|
(t/testing "get profiles"
|
|
(let [data {::th/type :get-profiles-for-file-comments
|
|
::rpc/profile-id (:id profile-1)
|
|
:file-id (:id file-1)}
|
|
out (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [[profile :as profiles] (:result out)]
|
|
(t/is (= 1 (count profiles)))
|
|
(t/is (= (:id profile-1) (:id profile))))))
|
|
|
|
(t/testing "get profiles 2"
|
|
(let [data {::th/type :get-profiles-for-file-comments
|
|
::rpc/profile-id (:id profile-2)
|
|
:file-id (:id file-1)}
|
|
out (th/command! data)]
|
|
;; (th/print-result! out)
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))))
|
|
|
|
(t/testing "delete comment"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2 mod"}) first)
|
|
data {::th/type :delete-comment
|
|
::rpc/profile-id (:id profile-2)
|
|
:id (:id comment)}
|
|
out (th/command! data)]
|
|
|
|
;; (th/print-result! out)
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))
|
|
(let [comments (th/db-query :comment {:thread-id (:id thread)})]
|
|
(t/is (= 2 (count comments))))))
|
|
|
|
(t/testing "delete comment 2"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
comment (-> (th/db-query :comment {:thread-id (:id thread) :content "comment 2 mod"}) first)
|
|
data {::th/type :delete-comment
|
|
::rpc/profile-id (:id profile-1)
|
|
:id (:id comment)}
|
|
out (th/command! data)]
|
|
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
(let [comments (th/db-query :comment {:thread-id (:id thread)})]
|
|
(t/is (= 1 (count comments))))))
|
|
|
|
(t/testing "delete comment thread"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :delete-comment-thread
|
|
::rpc/profile-id (:id profile-2)
|
|
:id (:id thread)}
|
|
out (th/command! data)]
|
|
|
|
;; (th/print-result! out)
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))
|
|
(let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
|
|
(t/is (= 1 (count threads))))))
|
|
|
|
(t/testing "delete comment thread 2"
|
|
(let [thread (-> (th/db-query :comment-thread {:file-id (:id file-1)}) first)
|
|
data {::th/type :delete-comment-thread
|
|
::rpc/profile-id (:id profile-1)
|
|
:id (:id thread)}
|
|
out (th/command! data)]
|
|
|
|
;; (th/print-result! out)
|
|
(t/is (th/success? out))
|
|
|
|
(let [threads (th/db-query :comment-thread {:file-id (:id file-1)})]
|
|
(t/is (= 0 (count threads)))))))))
|
|
|
|
(t/deftest share-link-who-comment-team-cannot-comment
|
|
(let [owner (th/create-profile* 1 {:is-active true})
|
|
outsider (th/create-profile* 2 {:is-active true})
|
|
|
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
|
project (th/create-project* 1 {:team-id (:id team)
|
|
:profile-id (:id owner)})
|
|
file (th/create-file* 1 {:profile-id (:id owner)
|
|
:project-id (:id project)})
|
|
page-id (get-in file [:data :pages 0])
|
|
|
|
share (th/command! {::th/type :create-share-link
|
|
::rpc/profile-id (:id owner)
|
|
:file-id (:id file)
|
|
:pages #{page-id}
|
|
:who-comment "team"
|
|
:who-inspect "all"})
|
|
share-id (get-in share [:result :id])]
|
|
|
|
(t/testing "outsider with who-comment=team share-link cannot get-comment-threads"
|
|
(let [out (th/command! {::th/type :get-comment-threads
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:share-id share-id})]
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))))
|
|
|
|
(t/testing "outsider with who-comment=team share-link cannot create-comment-thread"
|
|
(let [out (th/command! {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:page-id page-id
|
|
:position (gpt/point 0)
|
|
:content "outsider comment"
|
|
:frame-id uuid/zero
|
|
:share-id share-id})]
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))))))
|
|
|
|
(t/deftest share-link-who-comment-all-can-comment
|
|
(let [owner (th/create-profile* 1 {:is-active true})
|
|
outsider (th/create-profile* 2 {:is-active true})
|
|
|
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
|
project (th/create-project* 1 {:team-id (:id team)
|
|
:profile-id (:id owner)})
|
|
file (th/create-file* 1 {:profile-id (:id owner)
|
|
:project-id (:id project)})
|
|
page-id (get-in file [:data :pages 0])
|
|
|
|
share (th/command! {::th/type :create-share-link
|
|
::rpc/profile-id (:id owner)
|
|
:file-id (:id file)
|
|
:pages #{page-id}
|
|
:who-comment "all"
|
|
:who-inspect "all"})
|
|
share-id (get-in share [:result :id])]
|
|
|
|
(t/testing "outsider with who-comment=all share-link can get-comment-threads"
|
|
(let [out (th/command! {::th/type :get-comment-threads
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:share-id share-id})]
|
|
(t/is (th/success? out))))
|
|
|
|
(t/testing "outsider with who-comment=all share-link can create-comment-thread"
|
|
(let [out (th/command! {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:page-id page-id
|
|
:position (gpt/point 0)
|
|
:content "outsider comment"
|
|
:frame-id uuid/zero
|
|
:share-id share-id})]
|
|
(t/is (th/success? out))))))
|
|
|
|
(t/deftest share-link-page-scope-enforced
|
|
(let [owner (th/create-profile* 1 {:is-active true})
|
|
outsider (th/create-profile* 2 {:is-active true})
|
|
|
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
|
project (th/create-project* 1 {:team-id (:id team)
|
|
:profile-id (:id owner)})
|
|
file (th/create-file* 1 {:profile-id (:id owner)
|
|
:project-id (:id project)})
|
|
|
|
page-a (get-in file [:data :pages 0])
|
|
page-b (uuid/random)
|
|
|
|
_ (th/command! {::th/type :update-file
|
|
::rpc/profile-id (:id owner)
|
|
:id (:id file)
|
|
:session-id (uuid/random)
|
|
:revn 0
|
|
:vern 0
|
|
:changes [{:type :add-page
|
|
:id page-b
|
|
:page {:id page-b
|
|
:name "Page B"
|
|
:options {}
|
|
:objects {}}}]})
|
|
|
|
thread-a (th/command! {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id owner)
|
|
:file-id (:id file)
|
|
:page-id page-a
|
|
:position (gpt/point 0)
|
|
:content "comment on page A"
|
|
:frame-id uuid/zero})
|
|
thread-b (th/command! {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id owner)
|
|
:file-id (:id file)
|
|
:page-id page-b
|
|
:position (gpt/point 0)
|
|
:content "comment on page B"
|
|
:frame-id uuid/zero})
|
|
|
|
thread-a-id (get-in thread-a [:result :id])
|
|
thread-b-id (get-in thread-b [:result :id])
|
|
|
|
share (th/command! {::th/type :create-share-link
|
|
::rpc/profile-id (:id owner)
|
|
:file-id (:id file)
|
|
:pages #{page-a}
|
|
:who-comment "all"
|
|
:who-inspect "all"})
|
|
share-id (get-in share [:result :id])]
|
|
|
|
(t/testing "share-link holder can get-comment-threads for shared page only"
|
|
(let [out (th/command! {::th/type :get-comment-threads
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:share-id share-id})
|
|
result (:result out)]
|
|
(t/is (th/success? out))
|
|
(t/is (= 1 (count result)))
|
|
(t/is (= page-a (:page-id (first result))))))
|
|
|
|
(t/testing "share-link holder cannot get-comment-thread for unshared page"
|
|
(let [out (th/command! {::th/type :get-comment-thread
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:id thread-b-id
|
|
:share-id share-id})]
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))))
|
|
|
|
(t/testing "share-link holder can get-comment-thread for shared page"
|
|
(let [out (th/command! {::th/type :get-comment-thread
|
|
::rpc/profile-id (:id outsider)
|
|
:file-id (:id file)
|
|
:id thread-a-id
|
|
:share-id share-id})]
|
|
(t/is (th/success? out))))
|
|
|
|
(t/testing "share-link holder cannot get-comments for thread on unshared page"
|
|
(let [out (th/command! {::th/type :get-comments
|
|
::rpc/profile-id (:id outsider)
|
|
:thread-id thread-b-id
|
|
:share-id share-id})]
|
|
(t/is (not (th/success? out)))
|
|
(t/is (= :not-found (th/ex-type (:error out))))))))
|
|
|
|
(t/deftest membership-can-still-comment
|
|
(let [owner (th/create-profile* 1 {:is-active true})
|
|
member (th/create-profile* 2 {:is-active true})
|
|
|
|
team (th/create-team* 1 {:profile-id (:id owner)})
|
|
_ (th/create-team-role* {:team-id (:id team)
|
|
:profile-id (:id member)
|
|
:role :editor})
|
|
project (th/create-project* 1 {:team-id (:id team)
|
|
:profile-id (:id owner)})
|
|
file (th/create-file* 1 {:profile-id (:id owner)
|
|
:project-id (:id project)})
|
|
page-id (get-in file [:data :pages 0])]
|
|
|
|
(t/testing "team member can get-comment-threads without share-id"
|
|
(let [out (th/command! {::th/type :get-comment-threads
|
|
::rpc/profile-id (:id member)
|
|
:file-id (:id file)})]
|
|
(t/is (th/success? out))))
|
|
|
|
(t/testing "team member can create-comment-thread without share-id"
|
|
(let [out (th/command! {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id member)
|
|
:file-id (:id file)
|
|
:page-id page-id
|
|
:position (gpt/point 0)
|
|
:content "member comment"
|
|
:frame-id uuid/zero})]
|
|
(t/is (th/success? out))))))
|
|
|
|
(t/deftest comment-audit-events-carry-team-id
|
|
;; Comment events carry the file id, so the resolver could find the team; the
|
|
;; command already has the file with its team id and sets it without a join.
|
|
(with-mocks [mock {:target 'app.config/get
|
|
:return (th/config-get-mock
|
|
{:quotes-teams-per-profile 200})}]
|
|
(let [profile (th/create-profile* 1 {:is-active true})
|
|
team (th/create-team* 1 {:profile-id (:id profile)})
|
|
project (th/create-project* 1 {:team-id (:id team)
|
|
:profile-id (:id profile)})
|
|
file (th/create-file* 1 {:profile-id (:id profile)
|
|
:project-id (:id project)})
|
|
page-id (get-in file [:data :pages 0])
|
|
thread (th/command! {::th/type :create-comment-thread
|
|
::rpc/profile-id (:id profile)
|
|
:file-id (:id file)
|
|
:page-id page-id
|
|
:position (gpt/point 0)
|
|
:content "hello"
|
|
:frame-id uuid/zero})
|
|
comment (th/command! {::th/type :create-comment
|
|
::rpc/profile-id (:id profile)
|
|
:thread-id (get-in thread [:result :id])
|
|
:content "comment"})]
|
|
(t/is (= (:id team)
|
|
(get-in (meta (:result thread)) [:app.loggers.audit/props :team-id])))
|
|
(t/is (= (:id team)
|
|
(get-in (meta (:result comment)) [:app.loggers.audit/props :team-id]))))))
|