Files
metabase/enterprise/backend/test/metabase_enterprise/remote_sync/events_test.clj
2026-10-06 02:26:31 +00:00

1550 lines
95 KiB
Clojure

(ns metabase-enterprise.remote-sync.events-test
"Tests for the remote-sync events system.
Tests event publishing, handling, and model change tracking functionality."
{:clj-kondo/config '{:linters {:deprecated-var {:exclude {metabase.test.data/mbql-query {:namespaces [metabase-enterprise.remote-sync.events-test]}}}}}}
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.remote-sync.core :as remote-sync.core]
[metabase-enterprise.remote-sync.events :as remote-sync.events]
[metabase-enterprise.remote-sync.spec :as spec]
[metabase.collections.models.collection :as collection]
[metabase.collections.test-utils :refer [with-library-synced]]
[metabase.events.core :as events]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.test.util.thread-local :as tu.thread-local]
[metabase.util :as u]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db))
(defn- mark-synced!
"Populate the RemoteSyncObject table with synced rows for `imported-data` (a `:by-entity-id` map), as a
successful import does — used to set up a synced baseline before firing an event."
[imported-data]
(t2/delete! :model/RemoteSyncObject)
(when-let [rows (seq (spec/sync-all-entities! (t/instant) imported-data))]
(t2/insert! :model/RemoteSyncObject rows)))
;;; Model Change Event Tests
(deftest card-create-event-creates-entry-test
(testing "card-create event creates remote sync object entry with create status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Card remote-sync-card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-create
{:object remote-sync-card :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Card"
:model_id (:id remote-sync-card)
:status "create"}
(first entries))))))))
(deftest card-update-event-creates-entry-test
(testing "card-update event creates or updates remote sync object entry with update status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Card remote-sync-card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-update
{:object remote-sync-card :previous-object remote-sync-card :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Card"
:model_id (:id remote-sync-card)
:status "update"}
(first entries))))))))
(deftest card-update-archived-sets-delete-test
(testing "card-update event with archived=true sets delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Card remote-sync-card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-update
{:object (assoc remote-sync-card :archived true)
:previous-object remote-sync-card
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Card"
:model_id (:id remote-sync-card)
:status "delete"}
(first entries))))))))
(deftest card-delete-event-creates-entry-test
(testing "card-delete event creates remote sync object entry with delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Card remote-sync-card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-delete
{:object remote-sync-card :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Card"
:model_id (:id remote-sync-card)
:status "delete"}
(first entries))))))))
(deftest card-multiple-events-update-same-object-test
(testing "multiple card events do not update object when status is create"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Card remote-sync-card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(let [clock-t1 (t/mock-clock (t/instant "2024-01-01T10:00:00Z") (t/zone-id "UTC"))]
(t/with-clock clock-t1
(events/publish-event! :event/card-create
{:object remote-sync-card :user-id (mt/user->id :rasta)}))
(let [initial-entry (t2/select-one :model/RemoteSyncObject
:model_type "Card"
:model_id (:id remote-sync-card))]
(let [clock-t2 (t/mock-clock (t/instant "2024-01-01T11:00:00Z") (t/zone-id "UTC"))]
(t/with-clock clock-t2
(events/publish-event! :event/card-update
{:object remote-sync-card :previous-object remote-sync-card :user-id (mt/user->id :rasta)})))
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(let [update-entry (first entries)]
(is (= (:id initial-entry) (:id update-entry)))
(is (= "create" (:status update-entry)))
(is (= (:status_changed_at initial-entry) (:status_changed_at update-entry)))))))))))
(deftest card-event-in-normal-collection-no-entry-test
(testing "card events in non-remote-sync collections don't create entries"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}
:model/Card normal-card {:name "Normal Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id normal-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-create
{:object normal-card :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(defn- action-rso [action-id]
(t2/select-one :model/RemoteSyncObject :model_type "Action" :model_id action-id))
(deftest action-create-event-creates-entry-test
(testing "action-create on a synced model tracks the action under its model's collection"
;; remote-sync-type defaults to :read-only, where synced items are not tracked
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection {coll-id :id} {:is_remote_synced true :name "Remote-Sync"}
:model/Card {model-id :id} {:type :model :collection_id coll-id}
:model/Action action {:type :implicit :name "Create Venue" :model_id model-id}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/action-create {:object action :user-id (mt/user->id :rasta)})
(is (=? {:status "create" :model_name "Create Venue" :model_collection_id coll-id}
(action-rso (:id action))))))))
(deftest action-event-on-unsynced-model-no-entry-test
(testing "GHY-4722: action events on a model outside synced collections are not tracked"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection {coll-id :id} {:name "Normal"}
:model/Card {model-id :id} {:type :model :collection_id coll-id}
:model/Action action {:type :implicit :name "Create Venue" :model_id model-id}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/action-create {:object action :user-id (mt/user->id :rasta)})
(is (nil? (action-rso (:id action))))))))
(deftest action-moved-with-its-model-test
(testing "an action that moves out of synced collections with its model is marked removed"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection {synced-id :id} {:is_remote_synced true :name "Remote-Sync"}
:model/Collection {plain-id :id} {:name "Normal"}
:model/Card {model-id :id} {:type :model :collection_id synced-id}
:model/Action {action-id :id} {:type :implicit :name "Create Venue" :model_id model-id}]
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject {:model_type "Action" :model_id action-id :model_name "Create Venue"
:model_collection_id synced-id :status "synced"
:status_changed_at (t/offset-date-time)})
(t2/update! :model/Card model-id {:collection_id plain-id})
(events/publish-event! :event/action-update {:object (t2/select-one :model/Action :id action-id)
:user-id (mt/user->id :rasta)})
(is (=? {:status "removed"} (action-rso action-id)))))))
(deftest dashboard-create-event-creates-entry-test
(testing "dashboard-create event creates remote sync object entry with create status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Dashboard dashboard {:name "Test Dashboard"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/dashboard-create
{:object dashboard :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Dashboard"
:model_id (:id dashboard)
:status "create"}
(first entries))))))))
(deftest dashboard-update-event-creates-entry-test
(testing "dashboard-update event creates or updates remote sync object entry"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Dashboard dashboard {:name "Test Dashboard"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/dashboard-update
{:object dashboard :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Dashboard"
:model_id (:id dashboard)
:status "update"}
(first entries))))))))
(deftest public-link-events-create-update-entry-test
(testing "GHY-4650: on a read-write instance, public-link events mark the card or dashboard as updated"
(doseq [[model topics] {:model/Card [:event/card-public-link-created :event/card-public-link-deleted]
:model/Dashboard [:event/dashboard-public-link-created :event/dashboard-public-link-deleted]}
topic topics]
(testing topic
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
model instance {:name "Shared" :collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! topic {:object instance :object-id (:id instance) :user-id (mt/user->id :rasta)})
(is (=? [{:model_type (name model)
:model_id (:id instance)
:status "update"}]
(t2/select :model/RemoteSyncObject)))))))))
(deftest events-on-read-only-instance-do-not-track-synced-items-test
(testing "On a read-only instance, no event marks a synced item dirty, since that change can never be pushed"
(mt/with-temporary-setting-values [remote-sync-type :read-only]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Dashboard dashboard {:name "Test Dashboard" :collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/dashboard-update {:object dashboard :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject)))))))
(deftest public-link-events-on-read-only-instance-test
(testing "GHY-4650: on a read-only instance, public-link events leave synced cards and dashboards untracked"
(doseq [[model topics] {:model/Card [:event/card-public-link-created :event/card-public-link-deleted]
:model/Dashboard [:event/dashboard-public-link-created :event/dashboard-public-link-deleted]}
topic topics]
(testing topic
(mt/with-temporary-setting-values [remote-sync-type :read-only]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
model instance {:name "Shared" :collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! topic {:object instance :object-id (:id instance) :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject)))))))))
(deftest dashboard-update-archived-sets-delete-test
(testing "dashboard-update event with archived=true sets delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Dashboard dashboard {:name "Test Dashboard"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/dashboard-update
{:object (assoc dashboard :archived true)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Dashboard"
:model_id (:id dashboard)
:status "delete"}
(first entries))))))))
(deftest dashboard-delete-event-creates-entry-test
(testing "dashboard-delete event creates remote sync object entry with delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Dashboard dashboard {:name "Test Dashboard"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/dashboard-delete
{:object dashboard :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Dashboard"
:model_id (:id dashboard)
:status "delete"}
(first entries))))))))
(deftest document-create-event-creates-entry-test
(testing "document-create event creates remote sync object entry with create status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Document document {:collection_id (u/the-id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/document-create
{:object document :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Document"
:model_id (:id document)
:status "create"}
(first entries))))))))
(deftest document-update-event-creates-entry-test
(testing "document-update event creates or updates remote sync object entry with update status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Document document {:collection_id (u/the-id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/document-update
{:object document :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Document"
:model_id (:id document)
:status "update"}
(first entries))))))))
(deftest document-update-archived-sets-delete-test
(testing "document-update event with archived=true sets delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Document document {:collection_id (u/the-id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/document-update
{:object (assoc document :archived true)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Document"
:model_id (:id document)
:status "delete"}
(first entries))))))))
(deftest document-delete-event-creates-entry-test
(testing "document-delete event creates remote sync object entry with delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Document document {:collection_id (u/the-id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/document-delete
{:object document :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Document"
:model_id (:id document)
:status "delete"}
(first entries))))))))
(deftest snippet-create-event-creates-entry-test
(testing "snippet-create event creates remote sync object entry with create status when Library is synced"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(with-library-synced
(mt/with-temp [:model/Collection snippet-collection {:name "Snippets" :namespace "snippets"}
:model/NativeQuerySnippet snippet {:name "Test Snippet"
:content "SELECT 1"
:collection_id (:id snippet-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/snippet-create
{:object snippet :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "NativeQuerySnippet"
:model_id (:id snippet)
:status "create"}
(first entries)))))))))
(deftest snippet-update-event-creates-entry-test
(testing "snippet-update event creates or updates remote sync object entry with update status when Library is synced"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(with-library-synced
(mt/with-temp [:model/Collection snippet-collection {:name "Snippets" :namespace "snippets"}
:model/NativeQuerySnippet snippet {:name "Test Snippet"
:content "SELECT 1"
:collection_id (:id snippet-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/snippet-update
{:object snippet :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "NativeQuerySnippet"
:model_id (:id snippet)
:status "update"}
(first entries)))))))))
(deftest snippet-update-archived-sets-delete-test
(testing "snippet-update event with archived=true sets delete status when Library is synced"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(with-library-synced
(mt/with-temp [:model/Collection snippet-collection {:name "Snippets" :namespace "snippets"}
:model/NativeQuerySnippet snippet {:name "Test Snippet"
:content "SELECT 1"
:collection_id (:id snippet-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/snippet-update
{:object (assoc snippet :archived true)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "NativeQuerySnippet"
:model_id (:id snippet)
:status "delete"}
(first entries)))))))))
(deftest snippet-delete-event-creates-entry-test
(testing "snippet-delete event creates remote sync object entry with delete status when Library is synced"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(with-library-synced
(mt/with-temp [:model/Collection snippet-collection {:name "Snippets" :namespace "snippets"}
:model/NativeQuerySnippet snippet {:name "Test Snippet"
:content "SELECT 1"
:collection_id (:id snippet-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/snippet-delete
{:object snippet :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "NativeQuerySnippet"
:model_id (:id snippet)
:status "delete"}
(first entries)))))))))
(deftest snippet-event-when-library-not-synced-no-entry-test
(testing "snippet events don't create entries when Library is not synced"
(when-let [library (collection/library-collection)]
(t2/update! :model/Collection (:id library) {:is_remote_synced false}))
(mt/with-temp [:model/Collection snippet-collection {:name "Snippets" :namespace "snippets"}
:model/NativeQuerySnippet snippet {:name "Test Snippet"
:content "SELECT 1"
:collection_id (:id snippet-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/snippet-create
{:object snippet :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest collection-create-event-creates-entry-test
(testing "collection-create event creates remote sync object entry with create status"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true
:name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-create
{:object remote-sync-collection :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Collection"
:model_id (:id remote-sync-collection)
:status "create"}
(first entries)))))))
(deftest collection-create-event-no-entry-for-normal-collection-test
(testing "collection-create event doesn't create entry for non-remote-synced collections"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-create
{:object normal-collection :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest collection-create-event-archived-sets-delete-test
(testing "collection-create event with archived=true sets delete status"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true
:name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-create
{:object (assoc remote-sync-collection :archived true)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Collection"
:model_id (:id remote-sync-collection)
:status "delete"}
(first entries)))))))
(deftest collection-update-event-creates-entry-test
(testing "collection-update event creates or updates remote sync object entry with update status"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true
:name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-update
{:object remote-sync-collection :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Collection"
:model_id (:id remote-sync-collection)
:status "update"}
(first entries)))))))
(deftest collection-update-event-archived-sets-delete-test
(testing "collection-update event with archived=true sets delete status"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true
:name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-update
{:object (assoc remote-sync-collection :archived true)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Collection"
:model_id (:id remote-sync-collection)
:status "delete"}
(first entries)))))))
(deftest collection-update-event-unarchived-sets-update-test
(testing "collection-update event with archived=false sets update status"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true
:name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-update
{:object (assoc remote-sync-collection :archived false)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Collection"
:model_id (:id remote-sync-collection)
:status "update"}
(first entries)))))))
(defn- rso-statuses
"`{[model_type model_id] status}` for every RemoteSyncObject."
[]
(into {} (map (juxt (juxt :model_type :model_id) :status)) (t2/select :model/RemoteSyncObject)))
(defn- with-synced-nested-subtree!
"Calls `f` with `{:root :folder :sub-folder :card :sub-card :sibling-card}` ids: a remote-synced root holding a
sibling card and a folder, which holds a card and a sub-folder with its own card. Every entity except the root
has a synced RemoteSyncObject row."
[f]
(mt/with-temp [:model/Collection {root :id} {:name "Root" :is_remote_synced true :location "/"}
:model/Collection {folder :id} {:name "Folder" :is_remote_synced true :location (str "/" root "/")}
:model/Collection {sub-folder :id} {:name "Sub" :is_remote_synced true :location (str "/" root "/" folder "/")}
:model/Card {card :id} {:name "Card" :collection_id folder}
:model/Card {sub-card :id} {:name "Sub Card" :collection_id sub-folder}
:model/Card {sibling-card :id} {:name "Sibling Card" :collection_id root}]
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject
(for [[model-type model-id coll-id] [["Collection" folder folder]
["Collection" sub-folder sub-folder]
["Card" card folder]
["Card" sub-card sub-folder]
["Card" sibling-card root]]]
{:model_type model-type :model_id model-id :model_collection_id coll-id
:model_name "x" :status "synced" :status_changed_at (t/offset-date-time)}))
(f {:root root :folder folder :sub-folder sub-folder :card card :sub-card sub-card :sibling-card sibling-card})))
(defn- set-subtree-archived!
"Sets `archived` on `folder` and its contents, as archiving and unarchiving a collection do in bulk SQL."
[{:keys [folder sub-folder card sub-card]} archived]
(t2/update! :model/Collection :id [:in [folder sub-folder]] {:archived archived})
(t2/update! :model/Card :id [:in [card sub-card]] {:archived archived}))
(deftest collection-update-event-archived-cascades-delete-to-subtree-test
(testing "GHY-4399: archiving a nested collection marks its descendant collections and all contents of its subtree
'delete', and leaves content outside the subtree alone"
(with-synced-nested-subtree!
(fn [{:keys [folder sub-folder card sub-card sibling-card] :as ids}]
(set-subtree-archived! ids true)
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
(is (= {["Collection" folder] "delete"
["Collection" sub-folder] "delete"
["Card" card] "delete"
["Card" sub-card] "delete"
["Card" sibling-card] "synced"}
(rso-statuses)))))))
(deftest collection-update-event-unarchived-restores-subtree-test
(testing "GHY-4399: unarchiving a collection before the next push marks its unarchived contents 'update' again, so
the push does not delete their files; content still archived on its own stays 'delete'"
(with-synced-nested-subtree!
(fn [{:keys [folder sub-folder card sub-card sibling-card] :as ids}]
(set-subtree-archived! ids true)
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
(set-subtree-archived! ids false)
(t2/update! :model/Card :id sub-card {:archived true})
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder) :user-id (mt/user->id :rasta)})
(is (= {["Collection" folder] "update"
["Collection" sub-folder] "update"
["Card" card] "update"
["Card" sub-card] "delete"
["Card" sibling-card] "synced"}
(rso-statuses)))))))
(deftest collection-update-event-archive-cascades-to-transforms-test
(testing "GHY-4399: archiving a Transforms folder marks the transforms in it 'delete', and unarchiving it marks them
'update' again; transforms have no archived column, so their folder's archived state decides"
(mt/with-temporary-setting-values [remote-sync-transforms true]
(mt/with-temp [:model/Collection {folder :id} {:name "Folder" :namespace "transforms" :location "/"}
:model/Transform {transform :id} {:name "Transform" :collection_id folder}]
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject
(for [[model-type model-id] [["Collection" folder] ["Transform" transform]]]
{:model_type model-type :model_id model-id :model_collection_id folder
:model_name "x" :status "synced" :status_changed_at (t/offset-date-time)}))
(let [set-archived! (fn [archived]
(t2/update! :model/Collection :id folder {:archived archived})
(events/publish-event! :event/collection-update
{:object (t2/select-one :model/Collection :id folder)
:user-id (mt/user->id :rasta)}))]
(set-archived! true)
(is (= {["Collection" folder] "delete"
["Transform" transform] "delete"}
(rso-statuses)))
(set-archived! false)
(is (= {["Collection" folder] "update"
["Transform" transform] "update"}
(rso-statuses))))))))
(deftest collection-update-event-no-entry-for-normal-collection-test
(testing "collection-update event doesn't create entry for non-remote-synced collections"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-update
{:object normal-collection :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest collection-multiple-update-events-update-same-object-test
(testing "multiple collection-update events update the same remote sync object"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true
:name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(let [clock-t1 (t/mock-clock (t/instant "2024-01-01T10:00:00Z") (t/zone-id "UTC"))]
(t/with-clock clock-t1
(events/publish-event! :event/collection-update
{:object remote-sync-collection :user-id (mt/user->id :rasta)}))
(let [initial-entry (t2/select-one :model/RemoteSyncObject
:model_type "Collection"
:model_id (:id remote-sync-collection))
clock-t2 (t/mock-clock (t/instant "2024-01-01T11:00:00Z") (t/zone-id "UTC"))]
(t/with-clock clock-t2
(events/publish-event! :event/collection-update
{:object remote-sync-collection :user-id (mt/user->id :rasta)}))
(let [entries (t2/select :model/RemoteSyncObject :model_id (:id remote-sync-collection))]
(is (= 1 (count entries)))
(is (= (:id initial-entry) (:id (first entries))))
(is (t/after? (:status_changed_at (first entries))
(:status_changed_at initial-entry)))))))))
(deftest create-remote-sync-object-entry-creates-new-entry-test
(testing "create-remote-sync-object-entry! creates new entry when none exists"
(mt/with-temp [:model/Card card {:name "Test Card"
:dataset_query (mt/mbql-query venues)}]
(mt/with-current-user (mt/user->id :rasta)
(t2/delete! :model/RemoteSyncObject)
(let [hydrate-fn (fn [id] (t2/select-one [:model/Card :name :collection_id :display] :id id))]
(#'remote-sync.events/create-or-update-remote-sync-object-entry! "Card" (:id card) "create" hydrate-fn)
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Card"
:model_id (:id card)
:status "create"}
(first entries)))))))))
(deftest create-remote-sync-object-entry-updates-existing-entry-test
(testing "create-remote-sync-object-entry! updates existing entry when one exists"
(mt/with-temp [:model/Dashboard dashboard {:name "Test Dashboard"}]
(mt/with-current-user (mt/user->id :rasta)
(t2/delete! :model/RemoteSyncObject)
(let [clock-t1 (t/mock-clock (t/instant "2024-01-01T10:00:00Z") (t/zone-id "UTC"))
hydrate-fn (fn [id] (t2/select-one [:model/Dashboard :name :collection_id] :id id))]
(t/with-clock clock-t1
(#'remote-sync.events/create-or-update-remote-sync-object-entry! "Dashboard" (:id dashboard) "update" hydrate-fn))
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))
initial-time (:status_changed_at initial-entry)
clock-t2 (t/mock-clock (t/instant "2024-01-01T11:00:00Z") (t/zone-id "UTC"))]
(t/with-clock clock-t2
(#'remote-sync.events/create-or-update-remote-sync-object-entry! "Dashboard" (:id dashboard) "synced" hydrate-fn))
(let [entries (t2/select :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))]
(is (= 1 (count entries)))
(let [update-entry (first entries)]
(is (= (:id initial-entry) (:id update-entry)))
(is (= "synced" (:status update-entry)))
(is (t/after? (:status_changed_at update-entry) initial-time))))))))))
(deftest create-remote-sync-object-entry-does-not-update-create-status-test
(testing "create-remote-sync-object-entry! does not update entry when status is 'create'"
(mt/with-temp [:model/Dashboard dashboard {:name "Test Dashboard"}]
(mt/with-current-user (mt/user->id :rasta)
(t2/delete! :model/RemoteSyncObject)
(let [clock-t1 (t/mock-clock (t/instant "2024-01-01T10:00:00Z") (t/zone-id "UTC"))
hydrate-fn (fn [id] (t2/select-one [:model/Dashboard :name :collection_id] :id id))]
(t/with-clock clock-t1
(#'remote-sync.events/create-or-update-remote-sync-object-entry! "Dashboard" (:id dashboard) "create" hydrate-fn))
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))
clock-t2 (t/mock-clock (t/instant "2024-01-01T11:00:00Z") (t/zone-id "UTC"))]
(t/with-clock clock-t2
(#'remote-sync.events/create-or-update-remote-sync-object-entry! "Dashboard" (:id dashboard) "synced" hydrate-fn))
(let [entries (t2/select :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))]
(is (= 1 (count entries)))
(let [update-entry (first entries)]
(is (= (:id initial-entry) (:id update-entry)))
(is (= "create" (:status update-entry)))
(is (= (:status_changed_at initial-entry) (:status_changed_at update-entry)))))))))))
(deftest create-remote-sync-object-entry-with-hydrate-fn-test
(testing "create-remote-sync-object-entry! creates entry with hydrated details from hydrate-fn"
(mt/with-current-user (mt/user->id :rasta)
(t2/delete! :model/RemoteSyncObject)
(let [existing-collection-id (t2/select-one-fn :id [:model/Collection :id])
hydrate-fn (fn [id] (t2/select-one [:model/Collection :name [:id :collection_id]] :id id))]
(#'remote-sync.events/create-or-update-remote-sync-object-entry! "Collection" existing-collection-id "create" hydrate-fn)
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Collection"
:model_id existing-collection-id
:status "create"}
(first entries))))))))
(deftest existing-card-moved-out-of-remote-synced-collection-test
(testing "existing card moved out of remote-synced collection is marked as removed"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Card card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(mark-synced! {:by-entity-id {"Card" #{(:entity_id card)}}})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Card" :model_id (:id card))]
(is (= "synced" (:status initial-entry)))
(events/publish-event! :event/card-update
{:object (assoc card :collection_id (:id normal-collection))
:previous-object card
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Card" :model_id (:id card))]
(is (= "removed" (:status update-entry)))
(is (= (:id initial-entry) (:id update-entry))))))))
(deftest new-card-moved-out-of-remote-synced-collection-test
(testing "new card moved out of remote-synced collection is no longer tracked"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Card card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-create
{:object card :user-id (mt/user->id :rasta)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Card" :model_id (:id card))]
(is (= "create" (:status initial-entry)))
(events/publish-event! :event/card-update
{:object (assoc card :collection_id (:id normal-collection))
:previous-object card
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Card" :model_id (:id card))]
(is (nil? update-entry))))))))
(deftest existing-dashboard-moved-out-of-remote-synced-collection-test
(testing "existing dashboard moved out of remote-synced collection is marked as removed"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Dashboard dashboard {:name "Test Dashboard"
:collection_id (:id remote-sync-collection)}]
(mark-synced! {:by-entity-id {"Dashboard" #{(:entity_id dashboard)}}})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))]
(is (= "synced" (:status initial-entry)))
(events/publish-event! :event/dashboard-update
{:object (assoc dashboard :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))]
(is (= "removed" (:status update-entry)))
(is (= (:id initial-entry) (:id update-entry))))))))
(deftest new-dashboard-moved-out-of-remote-synced-collection-test
(testing "new dashboard moved out of remote-synced collection is no longer tracked"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Dashboard dashboard {:name "Test Dashboard"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/dashboard-create
{:object dashboard :user-id (mt/user->id :rasta)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))]
(is (= "create" (:status initial-entry)))
(events/publish-event! :event/dashboard-update
{:object (assoc dashboard :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Dashboard" :model_id (:id dashboard))]
(is (nil? update-entry))))))))
(deftest existing-document-moved-out-of-remote-synced-collection-test
(testing "existing document moved out of remote-synced collection is marked as removed"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Document document {:collection_id (u/the-id remote-sync-collection)}]
(mark-synced! {:by-entity-id {"Document" #{(:entity_id document)}}})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id document))]
(is (= "synced" (:status initial-entry)))
(events/publish-event! :event/document-update
{:object (assoc document :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id document))]
(is (= "removed" (:status update-entry)))
(is (= (:id initial-entry) (:id update-entry))))))))
(deftest new-document-moved-out-of-remote-synced-collection-test
(testing "new document moved out of remote-synced collection is no longer tracked"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Document document {:collection_id (u/the-id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/document-create
{:object document :user-id (mt/user->id :rasta)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id document))]
(is (= "create" (:status initial-entry)))
(events/publish-event! :event/document-update
{:object (assoc document :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id document))]
(is (nil? update-entry))))))))
(deftest existing-collection-type-changed-from-remote-synced-test
(testing "existing collection type changed from remote-synced is marked as removed"
(mt/with-temp [:model/Collection collection {:is_remote_synced true :name "Remote-Sync"}]
(mark-synced! {:by-entity-id {"Collection" #{(:entity_id collection)}}})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id collection))]
(is (= "synced" (:status initial-entry)))
(events/publish-event! :event/collection-update
{:object (assoc collection :is_remote_synced false)
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id collection))]
(is (= "removed" (:status update-entry)))
(is (= (:id initial-entry) (:id update-entry))))))))
(deftest new-collection-type-changed-from-remote-synced-test
(testing "new collection type changed from remote-synced is marked as removed"
(mt/with-temp [:model/Collection collection {:is_remote_synced true :name "Remote-Sync"}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/collection-create
{:object collection :user-id (mt/user->id :rasta)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id collection))]
(is (= "create" (:status initial-entry)))
(events/publish-event! :event/collection-update
{:object (assoc collection :is_remote_synced false)
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Collection" :model_id (:id collection))]
(is (nil? update-entry)))))))
(deftest model-not-tracked-moved-to-normal-collection-test
(testing "model not previously tracked doesn't create removed entry when moved to normal collection"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}
:model/Card card {:name "Test Card"
:dataset_query (mt/mbql-query venues)
:collection_id (:id normal-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/card-update
{:object card :previous-object card :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject :model_type "Card" :model_id (:id card))]
(is (= 0 (count entries)))))))
(deftest ^:parallel card-event-derivation-test
(testing "card events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/card-change-event :metabase/event))
(is (events/isa? :event/card-create ::remote-sync.events/card-change-event))
(is (events/isa? :event/card-update ::remote-sync.events/card-change-event))
(is (events/isa? :event/card-delete ::remote-sync.events/card-change-event))))
(deftest ^:parallel dashboard-event-derivation-test
(testing "dashboard events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/dashboard-change-event :metabase/event))
(is (events/isa? :event/dashboard-create ::remote-sync.events/dashboard-change-event))
(is (events/isa? :event/dashboard-update ::remote-sync.events/dashboard-change-event))
(is (events/isa? :event/dashboard-delete ::remote-sync.events/dashboard-change-event))))
(deftest ^:parallel document-event-derivation-test
(testing "document events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/document-change-event :metabase/event))
(is (events/isa? :event/document-create ::remote-sync.events/document-change-event))
(is (events/isa? :event/document-update ::remote-sync.events/document-change-event))
(is (events/isa? :event/document-delete ::remote-sync.events/document-change-event))))
(deftest ^:parallel snippet-event-derivation-test
(testing "snippet events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/snippet-change-event :metabase/event))
(is (events/isa? :event/snippet-create ::remote-sync.events/snippet-change-event))
(is (events/isa? :event/snippet-update ::remote-sync.events/snippet-change-event))
(is (events/isa? :event/snippet-delete ::remote-sync.events/snippet-change-event))))
(deftest ^:parallel collection-event-derivation-test
(testing "collection events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/collection-change-event :metabase/event))
(is (events/isa? :event/collection-create ::remote-sync.events/collection-change-event))
(is (events/isa? :event/collection-update ::remote-sync.events/collection-change-event))))
(deftest timeline-create-event-creates-entry-test
(testing "timeline-create event creates remote sync object entry with create status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/timeline-create
{:object timeline :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Timeline"
:model_id (:id timeline)
:status "create"}
(first entries))))))))
(deftest timeline-event-api-create-tracks-timeline-test
(testing "timeline-create event with a Timeline object creates a remote sync entry (fixed behavior)"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)}
:model/TimelineEvent timeline-event {:name "Test Event"
:timestamp (t/offset-date-time)
:timezone "UTC"
:time_matters false
:timeline_id (:id timeline)}]
(t2/delete! :model/RemoteSyncObject)
(testing "publishing with a TimelineEvent (old buggy behavior) does NOT create a sync entry"
(events/publish-event! :event/timeline-create
{:object timeline-event :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries))
"TimelineEvent lacks collection_id, so no sync entry should be created")))
(testing "publishing with a Timeline (correct behavior) creates a sync entry"
(events/publish-event! :event/timeline-create
{:object timeline :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries))
"Should create exactly one remote sync entry")
(is (=? {:model_type "Timeline"
:model_id (:id timeline)
:status "create"}
(first entries))
"Remote sync entry should track the Timeline, not the TimelineEvent")))))))
(deftest timeline-update-event-creates-entry-test
(testing "timeline-update event creates remote sync object entry with update status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/timeline-update
{:object timeline :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Timeline"
:model_id (:id timeline)
:status "update"}
(first entries))))))))
(deftest timeline-update-archived-sets-delete-test
(testing "timeline-update event with archived timeline sets delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)
:archived true}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/timeline-update
{:object timeline :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Timeline"
:model_id (:id timeline)
:status "delete"}
(first entries))))))))
(deftest timeline-delete-event-creates-entry-test
(testing "timeline-delete event creates remote sync object entry with delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/timeline-delete
{:object timeline :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Timeline"
:model_id (:id timeline)
:status "delete"}
(first entries))))))))
(deftest timeline-event-in-normal-collection-no-entry-test
(testing "timeline events in normal collections don't create remote sync entries"
(mt/with-temp [:model/Collection normal-collection {:name "Normal Collection"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id normal-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/timeline-create
{:object timeline :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest existing-timeline-moved-out-of-remote-synced-collection-test
(testing "existing timeline moved out of remote-synced collection gets marked as removed"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal Collection"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)}]
(mark-synced! {:by-entity-id {"Timeline" #{(:entity_id timeline)}}})
(is (= 1 (count (t2/select :model/RemoteSyncObject))))
(events/publish-event! :event/timeline-update
{:object (assoc timeline :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Timeline"
:model_id (:id timeline)
:status "removed"}
(first entries)))))))
(deftest new-timeline-moved-out-of-remote-synced-collection-test
(testing "new timeline moved out of remote-synced collection is no longer tracked"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal Collection"}
:model/Timeline timeline {:name "Test Timeline"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/timeline-create
{:object timeline :user-id (mt/user->id :rasta)})
(is (= 1 (count (t2/select :model/RemoteSyncObject))))
(events/publish-event! :event/timeline-update
{:object (assoc timeline :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries))))))))
(deftest soft-delete-then-hard-delete-preserves-dirty-status-test
(testing "doc that is soft-deleted then hard-deleted should maintain dirty status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Document doc {:name "Test Doc"
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
;; Trash the doc
(t2/update! :model/Document (:id doc) {:archived true})
(events/publish-event! :event/document-update
{:object (assoc doc :archived true)
:user-id (mt/user->id :rasta)})
(let [soft-delete-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id doc))]
(is (= "delete" (:status soft-delete-entry))))
;; Permanently delete from trash
(t2/delete! :model/Document (:id doc))
(events/publish-event! :event/document-delete
{:object doc :user-id (mt/user->id :rasta)})
;; The remote sync object entry should still exist with delete status
;; so the collection remains dirty
(let [hard-delete-entry (t2/select-one :model/RemoteSyncObject :model_type "Document" :model_id (:id doc))]
(is (= "delete" (:status hard-delete-entry)))
(is (not (nil? hard-delete-entry))))))))
;;; Table Event Tests
(deftest table-update-event-creates-entry-for-published-table-test
(testing "table-update event creates remote sync object entry for published table in remote-synced collection"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/table-update
{:object table :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Table"
:model_id (:id table)
:status "update"}
(first entries))))))))
(deftest table-update-event-no-entry-for-unpublished-table-test
(testing "table-update event doesn't create entry for unpublished table"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published false
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/table-update
{:object table :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest table-update-event-no-entry-for-normal-collection-test
(testing "table-update event doesn't create entry for table in non-remote-synced collection"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id normal-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/table-update
{:object table :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest table-update-archived-sets-delete-test
(testing "table-update event with archived_at sets delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/table-update
{:object (assoc table :archived_at (t/offset-date-time))
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Table"
:model_id (:id table)
:status "delete"}
(first entries))))))))
(deftest table-unpublished-marks-as-removed-test
(testing "table that becomes unpublished is marked as removed"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
;; First, create an entry with status "synced" directly
(t2/insert! :model/RemoteSyncObject {:model_type "Table"
:model_id (:id table)
:model_name "Test Table"
:model_table_id (:id table)
:model_table_name "Test Table"
:status "synced"
:status_changed_at (t/offset-date-time)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Table" :model_id (:id table))]
(is (= "synced" (:status initial-entry)))
;; Now unpublish the table
(events/publish-event! :event/table-update
{:object (assoc table :is_published false)
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Table" :model_id (:id table))]
(is (= "removed" (:status update-entry))))))))
(deftest table-moved-to-normal-collection-marks-as-removed-test
(testing "table moved to non-remote-synced collection is marked as removed"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Collection normal-collection {:name "Normal"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}]
(t2/delete! :model/RemoteSyncObject)
;; First, create an entry with status "synced" directly
(t2/insert! :model/RemoteSyncObject {:model_type "Table"
:model_id (:id table)
:model_name "Test Table"
:model_table_id (:id table)
:model_table_name "Test Table"
:status "synced"
:status_changed_at (t/offset-date-time)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Table" :model_id (:id table))]
(is (= "synced" (:status initial-entry)))
;; Now move to normal collection
(events/publish-event! :event/table-update
{:object (assoc table :collection_id (:id normal-collection))
:user-id (mt/user->id :rasta)})
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Table" :model_id (:id table))]
(is (= "removed" (:status update-entry))))))))
(deftest ^:parallel table-event-derivation-test
(testing "table events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/table-change-event :metabase/event))
(is (events/isa? :event/table-create ::remote-sync.events/table-change-event))
(is (events/isa? :event/table-update ::remote-sync.events/table-change-event))
(is (events/isa? :event/table-delete ::remote-sync.events/table-change-event))))
;;; Segment Event Tests
(deftest segment-create-event-creates-entry-test
(testing "segment-create event creates remote sync object entry for segment in published table in remote-synced collection"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/segment-create
{:object segment :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Segment"
:model_id (:id segment)
:status "create"}
(first entries))))))))
(deftest segment-update-event-creates-entry-test
(testing "segment-update event creates remote sync object entry with update status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/segment-update
{:object segment :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Segment"
:model_id (:id segment)
:status "update"}
(first entries))))))))
(deftest segment-update-archived-sets-delete-test
(testing "segment-update event with archived=true sets delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/segment-update
{:object (assoc segment :archived true)
:user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Segment"
:model_id (:id segment)
:status "delete"}
(first entries))))))))
(deftest segment-delete-event-creates-entry-test
(testing "segment-delete event creates remote sync object entry with delete status"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/segment-delete
{:object segment :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)))
(is (=? {:model_type "Segment"
:model_id (:id segment)
:status "delete"}
(first entries))))))))
(deftest segment-event-in-normal-collection-no-entry-test
(testing "segment events in non-remote-synced collections don't create entries"
(mt/with-temp [:model/Collection normal-collection {:name "Normal"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id normal-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/segment-create
{:object segment :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest segment-event-unpublished-table-no-entry-test
(testing "segment events for unpublished tables don't create entries"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published false
:collection_id (:id remote-sync-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/segment-create
{:object segment :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 0 (count entries)))))))
(deftest existing-segment-table-unpublished-marks-as-removed-test
(testing "existing segment is marked as removed when its table is unpublished"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id remote-sync-collection)}
:model/Segment segment {:name "Test Segment"
:table_id (:id table)
:definition {:source-table (:id table)
:filter [:> [:field 1 nil] 0]}}]
(t2/delete! :model/RemoteSyncObject)
;; First create a synced entry
(t2/insert! :model/RemoteSyncObject {:model_type "Segment"
:model_id (:id segment)
:model_name "Test Segment"
:model_table_id (:id table)
:model_table_name "Test Table"
:status "synced"
:status_changed_at (t/offset-date-time)})
(let [initial-entry (t2/select-one :model/RemoteSyncObject :model_type "Segment" :model_id (:id segment))]
(is (= "synced" (:status initial-entry)))
;; Now "unpublish" the table by simulating an update where the segment's table is no longer in sync scope
(events/publish-event! :event/segment-update
{:object (assoc segment :table_id (:id table))
:user-id (mt/user->id :rasta)})
;; Since table is still published, should be "update"
(let [update-entry (t2/select-one :model/RemoteSyncObject :model_type "Segment" :model_id (:id segment))]
(is (= "update" (:status update-entry)))))))))
(deftest ^:parallel segment-event-derivation-test
(testing "segment events properly derive from :metabase/event"
(is (events/isa? ::remote-sync.events/segment-change-event :metabase/event))
(is (events/isa? :event/segment-create ::remote-sync.events/segment-change-event))
(is (events/isa? :event/segment-update ::remote-sync.events/segment-change-event))
(is (events/isa? :event/segment-delete ::remote-sync.events/segment-change-event))))
;;; Field Event Tests
(deftest field-update-event-no-entry-for-unpublished-table-test
(testing "field-update event creates no RSOs for an unpublished table"
(mt/with-temp [:model/Collection remote-sync-collection {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "Test Table"
:is_published false
:collection_id (:id remote-sync-collection)}
:model/Field field {:name "test_field"
:table_id (:id table)
:base_type :type/Text}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update
{:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))))))
(deftest field-update-event-no-entry-for-normal-collection-test
(testing "field-update event creates no RSOs for a table in a non-remote-synced collection"
(mt/with-temp [:model/Collection normal-collection {:name "Normal" :type "library-data"}
:model/Table table {:name "Test Table"
:is_published true
:collection_id (:id normal-collection)}
:model/Field field {:name "test_field"
:table_id (:id table)
:base_type :type/Text}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update
{:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))))))
;;; ----------------------------------------- TableUserSettings RSO Tracking from Field Events --------------------------
(deftest field-update-creates-field-user-settings-rso-when-fus-exists-test
(testing "field-update on an eligible field with a FUS row creates a FieldUserSettings RSO for the Field — no
Field or TableUserSettings RSO"
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/insert! :model/FieldUserSettings {:field_id (:id field) :description "curated"})
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(let [entries (t2/select :model/RemoteSyncObject)]
(is (= 1 (count entries)) "exactly one RSO — the Field's FieldUserSettings")
(is (=? {:model_type "FieldUserSettings"
:model_id (:id field)
:status "update"
:model_table_id (:id table)}
(first entries)))))))
(deftest field-update-tracks-field-of-table-published-in-its-user-settings-test
(testing "field-update on a field of a Table published through its TableUserSettings creates a FieldUserSettings RSO"
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T"}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/insert! :model/TableUserSettings {:table_id (:id table) :is_published true :collection_id (:id coll)})
(t2/insert! :model/FieldUserSettings {:field_id (:id field) :description "curated"})
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (=? [{:model_type "FieldUserSettings" :model_id (:id field) :status "update"}]
(t2/select :model/RemoteSyncObject))))))
;;; ------------------------------------- Concurrent Un-Sync Race Tests -------------------------------------
;;;
;;; When a card-update event and a collection disable run concurrently, the handler must not resurrect the
;;; pending 'removed' status the disable records. Two orderings, each guarded by a different mechanism:
;;; - disable commits first -> the handler's write-time eligibility re-check keeps the removal
;;; - handler locks first -> the disable blocks on the row lock and records the removal last
;;; Both are exercised below by parking the handler at a chosen point and driving the disable around it.
(defn- once-parking-select-one!
"Wraps `t2/select-one` to park the first call whose args match `park-pred` — signalling `reached`, then
blocking on `release` (10s cap) — so a test can freeze the handler at a precise point."
[park-pred reached release]
(let [orig t2/select-one
fired (atom false)]
(fn [& args]
(let [res (apply orig args)]
(when (and (park-pred args) (compare-and-set! fired false true))
(deliver reached true)
(deref release 10000 nil))
res))))
(defn- rso-lookup-select?
"The handler's plain (non-locking) existing-entry read: `(t2/select-one :model/RemoteSyncObject :model_type … :model_id …)`."
[[model second-arg]]
(and (= model :model/RemoteSyncObject) (keyword? second-arg)))
(defn- rso-locking-select?
"The `FOR UPDATE` read inside create-or-update: `(t2/select-one :model/RemoteSyncObject {:where … :for :update})`."
[[model second-arg]]
(and (= model :model/RemoteSyncObject) (map? second-arg) (= :update (:for second-arg))))
(defn- do-with-unsync-race-fixture
"Sets up a remote-synced collection + card already on the remote (status 'synced', as after an export;
no content_hash, so the handler's no-op suppression stays out of the way) and calls `(f coll-id card)`.
Rows are committed because the event handler runs on another thread with its own connection."
[f]
(binding [tu.thread-local/*thread-local* false]
(mt/with-temp [:model/Collection {coll-id :id} {:name "Race-Sync" :location "/" :is_remote_synced true}
:model/Card card {:name "Race Card" :collection_id coll-id
:database_id (mt/id)
:dataset_query (mt/mbql-query venues)}]
(try
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject
[{:model_type "Collection" :model_id coll-id :model_name "Race-Sync"
:status "synced" :status_changed_at (t/offset-date-time)
:file_path "collections/rs/rs.yaml"}
{:model_type "Card" :model_id (:id card) :model_name "Race Card"
:model_collection_id coll-id :status "synced"
:status_changed_at (t/offset-date-time)
:file_path "collections/rs/cards/race.yaml"}])
(f coll-id card)
(finally
(t2/delete! :model/RemoteSyncObject))))))
(defn- fire-card-update! [card]
(future (events/publish-event! :event/card-update
{:object card :previous-object card :user-id (mt/user->id :rasta)})))
(defn- assert-removal-survived [coll-id card]
(is (= "removed" (t2/select-one-fn :status :model/RemoteSyncObject :model_type "Card" :model_id (:id card)))
"the card's pending removal survives the concurrent event")
(is (= "removed" (t2/select-one-fn :status :model/RemoteSyncObject :model_type "Collection" :model_id coll-id))
"the collection's pending removal is untouched"))
(deftest disable-first-recheck-preserves-removal-test
(testing "disable commits between the handler's stale eligibility read and its write: the write-time
eligibility re-check must keep the pending removal (GHY-4189)"
(do-with-unsync-race-fixture
(fn [coll-id card]
(let [reached (promise)
release (promise)]
;; Park at the handler's plain existing-entry read — before it takes any row lock. Nothing is
;; locked while parked, so the disable runs to completion first (the disable-first ordering).
(with-redefs [t2/select-one (once-parking-select-one! rso-lookup-select? reached release)]
(let [handler (fire-card-update! card)]
(is (true? (deref reached 10000 false)) "handler parked after reading pre-disable state")
(remote-sync.core/bulk-set-remote-sync {coll-id false})
(deliver release true)
(deref handler 10000 nil))))
(assert-removal-survived coll-id card)))))
(deftest handler-first-lock-preserves-removal-test
(testing "the handler holds the entry's row lock when the disable arrives: the disable blocks until the
handler commits, then records the removal last, so it survives (GHY-4189)"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(do-with-unsync-race-fixture
(fn [coll-id card]
(let [reached (promise)
release (promise)]
;; Park at the FOR UPDATE read, i.e. while the handler holds the entry's row lock (the
;; handler-first ordering).
(with-redefs [t2/select-one (once-parking-select-one! rso-locking-select? reached release)]
(let [handler (fire-card-update! card)]
(is (true? (deref reached 10000 false)) "handler parked holding the row lock")
(let [disable (future (remote-sync.core/bulk-set-remote-sync {coll-id false}))]
(is (= ::blocked (deref disable 300 ::blocked))
"the disable blocks on the row lock the handler holds")
(deliver release true)
(deref disable 10000 nil)
(deref handler 10000 nil)))))
(assert-removal-survived coll-id card))))))
(deftest dimension-change-creates-dimension-rso-test
(testing "adding or removing a Field's Dimension through the API tracks the Field's Dimension"
(mt/with-temporary-setting-values [remote-sync-type :read-write]
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/delete! :model/RemoteSyncObject)
(mt/user-http-request :crowberto :post 200 (format "field/%d/dimension" (:id field))
{:type "internal" :name "Remapped"})
(is (=? [{:model_type "Dimension" :model_id (:id field) :status "update"}]
(t2/select :model/RemoteSyncObject)))
(mt/user-http-request :crowberto :delete 204 (format "field/%d/dimension" (:id field)))
(is (=? [{:model_type "Dimension" :model_id (:id field) :status "removed"}]
(t2/select :model/RemoteSyncObject)))))))
(deftest field-update-no-rso-when-no-fus-row-test
(testing "field-update on an eligible field with NO FUS row, and no TableUserSettings row either, creates no
RSOs at all"
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))
"no RSOs created — neither the Field nor the Table has user-curated metadata to track"))))
(deftest field-update-no-rso-for-unpublished-table-test
(testing "field-update creates no RSOs when the table is not published"
(mt/with-temp [:model/Collection coll {:is_remote_synced true :name "Remote-Sync" :type "library-data"}
:model/Table table {:name "T" :is_published false :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/insert! :model/FieldUserSettings {:field_id (:id field) :description "curated"})
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))))))
(deftest field-update-no-rso-for-non-synced-collection-test
(testing "field-update creates no RSOs when the collection is not remote-synced"
(mt/with-temp [:model/Collection coll {:name "Normal" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id coll)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/insert! :model/FieldUserSettings {:field_id (:id field) :description "curated"})
(t2/delete! :model/RemoteSyncObject)
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (empty? (t2/select :model/RemoteSyncObject))))))
(deftest field-update-field-user-settings-rso-marks-removed-when-table-leaves-sync-scope-test
(testing "an existing FieldUserSettings RSO is marked removed when the field's table leaves sync scope"
(mt/with-temp [:model/Collection synced {:is_remote_synced true :name "Synced" :type "library-data"}
:model/Collection normal {:name "Normal" :type "library-data"}
:model/Table table {:name "T" :is_published true :collection_id (:id synced)}
:model/Field field {:name "f" :table_id (:id table) :base_type :type/Text}]
(t2/insert! :model/FieldUserSettings {:field_id (:id field) :description "curated"})
(t2/delete! :model/RemoteSyncObject)
(t2/insert! :model/RemoteSyncObject {:model_type "FieldUserSettings"
:model_id (:id field)
:model_name "f"
:model_table_id (:id table)
:status "synced"
:status_changed_at (t/offset-date-time)})
(t2/update! :model/Table (:id table) {:collection_id (:id normal)})
(events/publish-event! :event/field-update {:object field :user-id (mt/user->id :rasta)})
(is (=? [{:model_type "FieldUserSettings" :model_id (:id field) :status "removed"}]
(t2/select :model/RemoteSyncObject))))))