penpot/backend/test/backend_tests/rpc_media_test.clj
Andrey Antukh ad7e035b63
Add indirection for upload-chunks storage via new table (#11651)
*  Add upload_session_chunk indirection for chunked uploads

Chunks now live in the upload_session_chunk table with non-deleting
foreign keys to storage_object and upload_session, instead of tempfile
objects with session metadata. Reads go through a JOIN, so chunk state
never scans storage_object.

Uploads validate the live session, reject duplicate indexes, and store
objects in the new upload-session bucket without extra metadata.
Assemble removes mappings and marks the session consumed; objects-gc
procedurally purges consumed and stalled sessions, touching referenced
objects first. Touched-gc and deleted-gc handle the new bucket, and
upload-session-gc is removed.

Closes #11644

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix quota, give-up and coverage for session chunks

Exclude consumed sessions from the sessions-per-profile quota so
finished uploads free their slot at once. Remove chunk mappings before
the gc-deleted give-up delete to respect the NO ACTION keys. Catch
java.sql.SQLException for duplicate chunks. Cover the profile-owned
session purge and the UNIQUE race backstop with tests.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Align chunked upload tests with upload_session_chunk

Drop the duplicate-index tests written against metadata-backed
chunks; the UNIQUE mapping makes those cases unrepresentable and
the new tests cover them. Rewrite the rejected-duplicate tests to
expect :validation/:chunk-already-exists and assert against the
upload_session_chunk table, and scope the chunk-too-large "nothing
stored" check to the mapping table.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Use NO ACTION DEFERRABLE session FKs in single migration

Fold the profile FK change into 0154 so the feature ships one
migration. All three upload session FKs use ON DELETE NO ACTION
DEFERRABLE: identical to RESTRICT in normal operation, but deferrable
for tooling that relies on SET CONSTRAINTS ALL DEFERRED. Extend the
RESTRICT test to the direct profile delete.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Reserve chunk slot before writing blob in upload-chunk

Make object_id nullable and insert the mapping with NULL inside the
session-locking transaction, then write the blob outside it and link
it with a conditional update. A failed write removes the mapping and
reraises; a mid-flight death leaves a NULL row and the client starts
a new session.

AI-assisted-by: muse-spark-1.3-contributor

* 🔥 Remove redundant session_id index on upload_session_chunk

The UNIQUE(session_id, chunk_index) btree already serves
session_id-only lookups and the session FK check through its
leftmost column, so the standalone index only taxed the
per-chunk INSERT path. Verified with EXPLAIN on an equivalent
table shape.

AI-assisted-by: muse-spark-1.3-contributor

*  Merge chunk touch and delete into single RETURNING query

Replace the SELECT-then-DELETE round-trip in
delete-upload-sessions! with DELETE ... RETURNING object_id,
touching each returned object. Same semantics, one less query
per purged session. Follows the RETURNING pattern already used
in file-gc.

AI-assisted-by: muse-spark-1.3-contributor

* ♻️ Let objects-gc own chunk mapping deletion

Assemble-chunks now only marks the session as consumed; the
chunk mappings stay until objects-gc purges them (touching the
chunk objects first), leaving a single procedural deletion
path for consumed, stalled and profile-purge sessions.

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Fix font-deletion GC expectations for chunk objects

Update final storage-gc-touched counts to include the two
chunk objects touched by objects-gc when purging consumed
upload sessions (8/5/5 instead of 6/3/3).

AI-assisted-by: muse-spark-1.3-contributor

* 🐛 Release chunk reservation when the link UPDATE fails

Review feedback on #11651: the link UPDATE in upload-chunk could
leave a NULL reservation behind, blocking retries of the same
index with :chunk-already-exists. Remove the reservation when
the link fails so the client can retry in the same session;
the orphaned blob stays touched for touched-gc. Also realign
the process-bucket! cond branches in gc-touched.

Tests: chunked-upload-link-failure-releases-slot and
chunked-upload-null-reservation-blocks-retry.

AI-assisted-by: muse-spark-1.3-contributor
2026-09-15 17:26:43 +02:00

1265 lines
59 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-media-test
(:require
[app.common.uuid :as uuid]
[app.db :as db]
[app.http.client :as http]
[app.media :as media]
[app.rpc :as-alias rpc]
[app.storage :as sto]
[backend-tests.helpers :as th]
[clojure.test :as t]
[datoteka.fs :as fs]
[datoteka.io :as io]
[mockery.core :refer [with-mocks]])
(:import
java.io.RandomAccessFile))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest media-object-from-url
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
url "https://raw.githubusercontent.com/uxbox/uxbox/develop/sample_media/images/unsplash/anna-pelzer.jpg"
params {::th/type :create-file-media-object-from-url
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:url url}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result out)]
(t/is (= (:id file) (:file-id result)))
(t/is (= 1024 (:width result)))
(t/is (= 683 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? media-id))
(t/is (uuid? thumbnail-id))
(let [storage (:app.storage/storage th/*system*)
mobj1 (sto/get-object storage media-id)
mobj2 (sto/get-object storage thumbnail-id)]
(t/is (sto/object? mobj1))
(t/is (sto/object? mobj2))
(t/is (= 122785 (:size mobj1)))
(t/is (= 3297 (:size mobj2)))))))
(t/deftest media-object-upload
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result out)]
(t/is (= (:id file) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? media-id))
(t/is (uuid? thumbnail-id))
(let [storage (:app.storage/storage th/*system*)
mobj1 (sto/get-object storage media-id)
mobj2 (sto/get-object storage thumbnail-id)]
(t/is (sto/object? mobj1))
(t/is (sto/object? mobj2))
(t/is (= 312043 (:size mobj1)))
(t/is (= 3890 (:size mobj2)))))))
(t/deftest media-object-upload-idempotency
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id (uuid/next)}]
;; First try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))
;; Second try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))))
(t/deftest media-object-from-url-command
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
url "https://raw.githubusercontent.com/uxbox/uxbox/develop/sample_media/images/unsplash/anna-pelzer.jpg"
params {::th/type :create-file-media-object-from-url
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:url url}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result out)]
(t/is (= (:id file) (:file-id result)))
(t/is (= 1024 (:width result)))
(t/is (= 683 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? media-id))
(t/is (uuid? thumbnail-id))
(let [storage (:app.storage/storage th/*system*)
mobj1 (sto/get-object storage media-id)
mobj2 (sto/get-object storage thumbnail-id)]
(t/is (sto/object? mobj1))
(t/is (sto/object? mobj2))
(t/is (= 122785 (:size mobj1)))
(t/is (= 3297 (:size mobj2)))))))
(t/deftest media-object-upload-command
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
;; (th/print-result! out)
(t/is (nil? (:error out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result out)]
(t/is (= (:id file) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? media-id))
(t/is (uuid? thumbnail-id))
(let [storage (:app.storage/storage th/*system*)
mobj1 (sto/get-object storage media-id)
mobj2 (sto/get-object storage thumbnail-id)]
(t/is (sto/object? mobj1))
(t/is (sto/object? mobj2))
(t/is (= 312043 (:size mobj1)))
(t/is (= 3890 (:size mobj2)))))))
(t/deftest media-object-upload-idempotency-command
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile
:id (uuid/next)}]
;; First try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))
;; Second try
(let [{:keys [result error] :as out} (th/command! params)]
;; (th/print-result! out)
(t/is (nil? error))
(t/is (= (:id params) (:id result)))
(t/is (= (:file-id params) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= "image/jpeg" (:mtype result)))
(t/is (uuid? (:media-id result)))
(t/is (uuid? (:thumbnail-id result))))))
(t/deftest media-object-upload-command-when-file-is-deleted
(let [prof (th/create-profile* 1)
proj (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
_ (th/db-update! :file
{:deleted-at (app.common.time/now)}
{:id (:id file)})
mfile {:filename "sample.jpg"
:path (th/tempfile "backend_tests/test_files/sample.jpg")
:mtype "image/jpeg"
:size 312043}
params {::th/type :upload-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:name "testfile"
:content mfile}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= (:type error-data) :not-found)))))
(t/deftest download-image-connection-error
(t/testing "connection refused raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.ConnectException. "Connection refused")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "connection timeout raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.http.HttpConnectTimeoutException. "Connect timed out")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "request timeout raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.net.http.HttpTimeoutException. "Request timed out")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "I/O error raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:throw (java.io.IOException. "Stream closed")}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/image.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
(t/deftest download-image-status-code-error
(t/testing "404 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 404
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/not-found.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "500 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 500
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/server-error.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err)))))))
(t/testing "302 status raises validation error"
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 302
:headers {"content-type" "text/html"
"content-length" "0"}
:body nil}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/redirect.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :validation (:type (ex-data err))))
(t/is (= :unable-to-download-image (:code (ex-data err))))))))
(t/deftest download-image-closes-stream
(t/testing "response body stream is closed on success"
(let [closed? (atom false)
;; Minimal valid PNG (1x1 pixel, red)
png-data (byte-array [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A 0x00 0x00 0x00 0x0D 0x49 0x48 0x44 0x52 0x00 0x00 0x00 0x01 0x00 0x00 0x00 0x01 0x08 0x02 0x00 0x00 0x00 0x90 0x77 0x53 0xDE 0x00 0x00 0x00 0x0C 0x49 0x44 0x41 0x54 0x08 0xD7 0x63 0xF8 0xCF 0xC0 0x00 0x00 0x00 0x02 0x00 0x01 0xE2 0x21 0xBC 0x33 0x00 0x00 0x00 0x00 0x49 0x45 0x4E 0x44 0xAE 0x42 0x60 0x82])
body (proxy [java.io.ByteArrayInputStream] [png-data]
(close [] (reset! closed? true)))]
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 200
:headers {"content-type" "image/png"
"content-length" (str (alength png-data))}
:body body}}]
(let [cfg {::http/client :mock-client}
result (media/download-image cfg "https://example.com/image.png")]
(t/is (some? result))
(t/is @closed? "body stream should be closed after successful download")))))
(t/testing "response body stream is closed on validation error"
(let [closed? (atom false)
body (proxy [java.io.ByteArrayInputStream] [(byte-array 100)]
(close [] (reset! closed? true)))]
(with-mocks [http-mock {:target 'app.http.client/req-with-redirects
:return {:status 404
:headers {"content-type" "text/html"
"content-length" "100"}
:body body}}]
(let [cfg {::http/client :mock-client}
err (try
(media/download-image cfg "https://example.com/not-found.png")
nil
(catch clojure.lang.ExceptionInfo e e))]
(t/is (some? err))
(t/is (= :unable-to-download-image (:code (ex-data err))))
(t/is @closed? "body stream should be closed even on validation error"))))))
;; --------------------------------------------------------------------
(defn- split-file-into-chunks
"Splits the file at `path` into byte-array chunks of at most
`chunk-size` bytes. Returns a vector of byte arrays."
[path chunk-size]
(let [file (RandomAccessFile. (str path) "r")
length (.length file)]
(try
(loop [offset 0 chunks []]
(if (>= offset length)
chunks
(let [remaining (- length offset)
size (min chunk-size remaining)
buf (byte-array size)]
(.seek file offset)
(.readFully file buf)
(recur (+ offset size) (conj chunks buf)))))
(finally
(.close file)))))
(defn- make-chunk-mfile
"Writes `data` (byte array) to a tempfile and returns a map
compatible with `media/schema:upload`."
[data mtype]
(let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-chunk-")]
(io/write* tmp data)
{:filename "chunk"
:path tmp
:mtype mtype
:size (alength data)}))
;; --------------------------------------------------------------------
;; Chunked-upload tests
;; --------------------------------------------------------------------
(defn- create-session!
"Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID."
[prof total-chunks]
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks total-chunks})]
(t/is (nil? (:error out)))
(:session-id (:result out))))
(t/deftest chunked-upload-happy-path
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 110000) ; ~107 KB each
mtype "image/jpeg"
total-size (reduce + (map alength chunks))
session-id (create-session! prof (count chunks))]
(t/is (= 3 (count chunks)))
;; --- 1. Upload chunks ---
(doseq [[idx chunk-data] (map-indexed vector chunks)]
(let [mfile (make-chunk-mfile chunk-data mtype)
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index idx
:content mfile})]
(t/is (nil? (:error out)))
(t/is (= session-id (:session-id (:result out))))
(t/is (= idx (:index (:result out))))))
;; --- 2. Assemble ---
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype mtype})]
(t/is (nil? (:error assemble-out)))
(let [{:keys [media-id thumbnail-id] :as result} (:result assemble-out)]
(t/is (= (:id file) (:file-id result)))
(t/is (= 800 (:width result)))
(t/is (= 800 (:height result)))
(t/is (= mtype (:mtype result)))
(t/is (uuid? media-id))
(t/is (uuid? thumbnail-id))
(let [storage (:app.storage/storage th/*system*)
mobj1 (sto/get-object storage media-id)
mobj2 (sto/get-object storage thumbnail-id)]
(t/is (sto/object? mobj1))
(t/is (sto/object? mobj2))
(t/is (= total-size (:size mobj1))))))))
(t/deftest chunked-upload-idempotency
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
media-id (uuid/next)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043) ; single chunk = whole file
mtype "image/jpeg"
mfile (make-chunk-mfile (first chunks) mtype)
session-id (create-session! prof 1)]
(th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
;; First assemble succeeds; session row is marked as consumed afterwards
(let [out1 (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "sample"
:mtype mtype
:id media-id})]
(t/is (nil? (:error out1)))
(t/is (= media-id (:id (:result out1)))))
;; Second assemble with the same session-id must fail because the
;; session row has been marked as consumed after the first assembly
(let [out2 (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "sample"
:mtype mtype
:id media-id})]
(t/is (some? (:error out2)))
(t/is (= :not-found (-> out2 :error ex-data :type)))
(t/is (= :object-not-found (-> out2 :error ex-data :code))))))
(t/deftest chunked-upload-no-permission
;; A second profile must not be able to upload chunks into a session
;; that belongs to another profile: the DB lookup includes profile-id,
;; so the session will not be found.
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
session-id (create-session! prof1 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
;; prof2 tries to upload a chunk into prof1's session
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof2)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))))
(t/deftest chunked-upload-other-profile-cannot-assemble
;; assemble-chunks must scope the session lookup to the requesting
;; profile so that a different profile cannot assemble chunks from
;; a session they do not own (BOLA / CWE-639).
(let [prof1 (th/create-profile* 1)
prof2 (th/create-profile* 2)
session-id (create-session! prof1 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; prof1 uploads a chunk into their own session
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof1)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out))))
;; prof2 tries to assemble prof1's session via create-font-variant
;; (which calls assemble-chunks without ownership check)
(let [out (th/command! {::th/type :create-font-variant
::rpc/profile-id (:id prof2)
:team-id (:default-team-id prof2)
:font-id (uuid/next)
:font-family "TestFont"
:font-weight 400
:font-style "normal"
:uploads {"font/ttf" session-id}})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest chunked-upload-invalid-media-type
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
(th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
;; Assemble with a wrong mtype should fail validation
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "bad-type"
:mtype "application/octet-stream"})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))))
(t/deftest chunked-upload-missing-chunks
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
;; Session expects 3 chunks
session-id (create-session! prof 3)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; Upload only 1 chunk
(th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})
;; Assemble: session says 3 expected, only 1 stored → :missing-chunks
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "incomplete"
:mtype "image/jpeg"})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :missing-chunks (-> out :error ex-data :code))))))
(t/deftest chunked-upload-duplicate-then-assemble
;; A rejected duplicate must leave the first chunk intact: upload 0,
;; re-upload 0 (rejected), then assemble succeeds with the original size.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"
size (alength (first chunks))]
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "after-dupe"
:mtype mtype})]
(t/is (nil? (:error out)))
(let [storage (:app.storage/storage th/*system*)
mobj (sto/get-object storage (:media-id (:result out)))]
(t/is (= size (:size mobj)))))))
(t/deftest chunked-upload-rejected-duplicate-keeps-session-usable
;; Rejecting a duplicate must not poison the session: the remaining
;; distinct indices still accumulate and assemble normally.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 2)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 110000)
mtype "image/jpeg"]
(t/is (= 3 (count chunks)))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (nil? (:error out))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (nth chunks 0) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out :error ex-data :code))))
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 1
:content (make-chunk-mfile (nth chunks 1) mtype)})]
(t/is (nil? (:error out))))
;; The mapping table holds exactly the two distinct indices: the
;; rejected duplicate stored nothing.
(let [rows (th/db-exec! ["SELECT chunk_index FROM upload_session_chunk WHERE session_id = ? ORDER BY chunk_index"
session-id])]
(t/is (= [0 1] (mapv :chunk-index rows))))))
(t/deftest chunked-upload-session-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
bogus-id (uuid/next)]
;; Assemble with a session-id that was never created
(let [out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id bogus-id
:file-id (:id file)
:is-local true
:name "ghost"
:mtype "image/jpeg"})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code))))))
(t/deftest chunked-upload-over-chunk-limit
;; Verify that requesting more chunks than the configured maximum
;; (quotes-upload-chunks-per-session) raises a :restriction error.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-upload-chunks-per-session 3})}]
(let [prof (th/create-profile* 1)
out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 4})]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))
(t/is (= "upload-chunks-per-session" (-> out :error ex-data :target))))))
(t/deftest chunked-upload-consumed-session-frees-quota
;; Consumed sessions must not count against the sessions-per-profile
;; quota: with the limit set to 1, assembling a session frees the slot
;; for a new one.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-upload-sessions-per-profile 1})}]
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
session-id (create-session! prof 1)
upload-out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error upload-out)))
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype "image/jpeg"})]
(t/is (nil? (:error assemble-out))))
;; the consumed session frees the quota slot
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})]
(t/is (nil? (:error out)))
(t/is (uuid? (:session-id (:result out))))))))
(t/deftest chunked-upload-invalid-total-chunks
;; total-chunks must be at least 1; zero and negative values are rejected
;; with a :validation error.
(let [prof (th/create-profile* 1)]
;; zero total-chunks
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 0})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))
;; negative total-chunks
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks -1})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type))))))
(t/deftest chunked-upload-invalid-chunk-index
;; Both a negative index and an index >= total-chunks must be
;; rejected with a :validation / :invalid-chunk-index error.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 2)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
;; index == total-chunks (out of range)
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 2
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))
;; negative index
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index -1
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :invalid-chunk-index (-> out :error ex-data :code))))))
(t/deftest chunked-upload-chunk-too-large
;; Chunks larger than the configured cap must be rejected with
;; :validation / :chunk-too-large before anything is stored, while a
;; chunk exactly at the cap still uploads fine.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:upload-max-chunk-size 1024})}]
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
chunks (split-file-into-chunks source-path 312043)
mtype "image/jpeg"]
;; 312043 bytes exceeds the mocked 1024-byte cap: rejected
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (first chunks) mtype)})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-too-large (-> out :error ex-data :code))))
;; Nothing stored for the rejected chunk
(t/is (= 0 (:count (th/db-exec-one! ["SELECT count(*) FROM upload_session_chunk WHERE session_id = ?"
session-id]))))
;; A chunk exactly at the cap still uploads fine
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content (make-chunk-mfile (byte-array 1024 (byte 1)) mtype)})]
(t/is (nil? (:error out)))))))
(t/deftest chunked-upload-sessions-per-profile-quota
;; With the session limit set to 2, creating a third session for the
;; same profile must fail with :restriction / :max-quote-reached.
;; The :quotes flag is already enabled by the test fixture.
(with-mocks [mock {:target 'app.config/get
:return (th/config-get-mock
{:quotes-upload-sessions-per-profile 2})}]
(let [prof (th/create-profile* 1)]
;; First two sessions succeed
(create-session! prof 1)
(create-session! prof 1)
;; Third session must be rejected
(let [out (th/command! {::th/type :create-upload-session
::rpc/profile-id (:id prof)
:total-chunks 1})]
(t/is (some? (:error out)))
(t/is (= :restriction (-> out :error ex-data :type)))
(t/is (= :max-quote-reached (-> out :error ex-data :code)))))))
;; --- upload_session_chunk mapping tests ---
(t/deftest chunked-upload-creates-chunk-mapping
;; Uploading a chunk creates a row in upload_session_chunk pointing to the
;; storage object, and the object itself carries no session metadata.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(let [row (th/db-exec-one! ["select session_id, object_id, chunk_index from upload_session_chunk where session_id = ?"
session-id])]
(t/is (= session-id (:session-id row)))
(t/is (= 0 (:chunk-index row)))
(let [storage (:app.storage/storage th/*system*)
obj (sto/get-object storage (:object-id row))]
(t/is (sto/object? obj))
(t/is (= "upload-session" (-> obj meta :bucket)))
(t/is (nil? (-> obj meta :upload-id)))
(t/is (nil? (-> obj meta :chunk-index)))))))
(t/deftest chunked-upload-duplicate-index-fails
;; Re-uploading an already stored index fails with
;; :validation/:chunk-already-exists and creates no new storage object.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out1 (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out1)))
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
out2 (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out2)))
(t/is (= :validation (-> out2 :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out2 :error ex-data :code)))
(t/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
(t/deftest chunked-upload-null-reservation-blocks-retry
;; A reserved slot with NULL object_id (an upload that died between the
;; reserve and the link) counts as occupied: retrying the index in the
;; same session fails with :validation/:chunk-already-exists and stores
;; nothing, so the client must start a new session.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}]
(th/db-exec! ["insert into upload_session_chunk (session_id, chunk_index, object_id) values (?, ?, null)"
session-id 0])
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :validation (-> out :error ex-data :type)))
(t/is (= :chunk-already-exists (-> out :error ex-data :code)))
(t/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
(t/deftest chunked-upload-link-failure-releases-slot
;; When the link UPDATE fails after a successful blob write, the
;; reservation is removed so the client can retry the index in the same
;; session; the orphaned blob stays touched for touched-gc.
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
orig @#'app.rpc.commands.media/link-upload-session-chunk!
failed? (atom false)]
(with-mocks [mock {:target 'app.rpc.commands.media/link-upload-session-chunk!
:return (fn [pool object-id session-id index]
(if (compare-and-set! failed? false true)
(throw (ex-info "link boom" {}))
(orig pool object-id session-id index)))}]
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out))))
;; the failed link left no reservation behind
(t/is (= 0 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
;; retrying the same index in the same session succeeds
(let [out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))))))
(t/deftest chunked-upload-to-consumed-session-fails
;; Once assembled, the session is consumed: uploading another chunk fails
;; with :not-found and the session row stays, marked with deleted_at.
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out1 (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out1)))
(let [assemble-out (th/command! {::th/type :assemble-file-media-object
::rpc/profile-id (:id prof)
:session-id session-id
:file-id (:id file)
:is-local true
:name "assembled-image"
:mtype "image/jpeg"})]
(t/is (nil? (:error assemble-out))))
;; chunk mappings stay until objects-gc purges them, session row
;; stays marked as consumed
(t/is (= 1 (:count (th/db-exec-one! ["select count(*) from upload_session_chunk where session_id = ?"
session-id]))))
(t/is (some? (:deleted-at (th/db-exec-one! ["select deleted_at from upload_session where id = ?"
session-id]))))
;; uploading to the consumed session fails without creating an object
(let [before (:count (th/db-exec-one! ["select count(*) from storage_object"]))
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (some? (:error out)))
(t/is (= :not-found (-> out :error ex-data :type)))
(t/is (= :object-not-found (-> out :error ex-data :code)))
(t/is (= before (:count (th/db-exec-one! ["select count(*) from storage_object"])))))))
(defn- sql-state-of
"Runs thunk (a db statement) and returns the SQLState of the raised
SQLException, or nil when no error is raised."
[thunk]
(try
(thunk)
nil
(catch java.sql.SQLException cause
(.getSQLState cause))))
(t/deftest upload-session-chunk-restrict-blocks-direct-deletes
;; With a live mapping row, deleting the storage object or the session
;; directly violates the RESTRICT foreign keys (SQLState 23503).
(let [prof (th/create-profile* 1)
session-id (create-session! prof 1)
source-path (th/tempfile "backend_tests/test_files/sample.jpg")
mfile {:filename "sample.jpg"
:path source-path
:mtype "image/jpeg"
:size 312043}
out (th/command! {::th/type :upload-chunk
::rpc/profile-id (:id prof)
:session-id session-id
:index 0
:content mfile})]
(t/is (nil? (:error out)))
(let [object-id (:object-id (th/db-exec-one! ["select object_id from upload_session_chunk where session_id = ?"
session-id]))]
(t/is (= "23503" (sql-state-of #(th/db-exec! ["delete from storage_object where id = ?"
object-id]))))
(t/is (= "23503" (sql-state-of #(th/db-exec! ["delete from upload_session where id = ?"
session-id]))))
;; the profile cannot disappear either while its session is live
;; (profile_id FK is NO ACTION DEFERRABLE; purge goes through
;; objects-gc). The deletion_protection rule is disabled here so the
;; statement reaches the FK check.
(t/is (= "23503" (sql-state-of #(db/transact! th/*pool*
(fn [conn]
(db/exec-one! conn ["SET LOCAL rules.deletion_protection TO off"])
(db/exec! conn ["delete from profile where id = ?"
(:id prof)])))))))))
;; --- Clone File Media Object BOLA tests ---
(defn- create-storage-object!
[content content-type]
(let [storage (:app.storage/storage th/*system*)]
(sto/put-object! storage {::sto/content (sto/content content)
:content-type content-type})))
(t/deftest clone-file-media-object-success
(let [prof1 (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:default-team-id prof1)})
file1 (th/create-file* 1 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
sobj (create-storage-object! "image-content" "image/png")
mobj (th/create-file-media-object* {:file-id (:id file1)
:name "test-media"
:width 100
:height 100
:mtype "image/png"
:media-id (:id sobj)})
file2 (th/create-file* 2 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof1)
:file-id (:id file2)
:is-local true
:id (:id mobj)}
out (th/command! params)]
(t/is (nil? (:error out)))
(let [result (:result out)]
(t/is (= (:id file2) (:file-id result)))
(t/is (= (:name mobj) (:name result)))
(t/is (= (:media-id mobj) (:media-id result)))
(t/is (uuid? (:id result)))
(t/is (not= (:id mobj) (:id result))))))
(t/deftest clone-file-media-object-no-read-access
(let [prof1 (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof1)
:team-id (:default-team-id prof1)})
file1 (th/create-file* 1 {:profile-id (:id prof1)
:project-id (:default-project-id prof1)
:is-shared false})
sobj (create-storage-object! "private-content" "image/png")
mobj (th/create-file-media-object* {:file-id (:id file1)
:name "private-media"
:width 100
:height 100
:mtype "image/png"
:media-id (:id sobj)})
prof2 (th/create-profile* 2)
_ (th/create-project* 2 {:profile-id (:id prof2)
:team-id (:default-team-id prof2)})
file2 (th/create-file* 2 {:profile-id (:id prof2)
:project-id (:default-project-id prof2)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof2)
:file-id (:id file2)
:is-local true
:id (:id mobj)}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data))))))
(t/deftest clone-file-media-object-source-not-found
(let [prof (th/create-profile* 1)
_ (th/create-project* 1 {:profile-id (:id prof)
:team-id (:default-team-id prof)})
file (th/create-file* 1 {:profile-id (:id prof)
:project-id (:default-project-id prof)
:is-shared false})
params {::th/type :clone-file-media-object
::rpc/profile-id (:id prof)
:file-id (:id file)
:is-local true
:id (uuid/random)}
out (th/command! params)]
(let [error (:error out)
error-data (ex-data error)]
(t/is (th/ex-info? error))
(t/is (= :not-found (:type error-data)))
(t/is (= :object-not-found (:code error-data))))))