diff --git a/tests/phpunit/Unit/Modules/MediaLibrary/AdminTest.php b/tests/phpunit/Unit/Modules/MediaLibrary/AdminTest.php
new file mode 100644
index 0000000..32254ed
--- /dev/null
+++ b/tests/phpunit/Unit/Modules/MediaLibrary/AdminTest.php
@@ -0,0 +1,178 @@
+
+ */
+ private array $original_request;
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function setUp(): void {
+ parent::setUp();
+
+ $this->original_request = $_REQUEST; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function tearDown(): void {
+ $_REQUEST = $this->original_request; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests no errors on hook registration.
+ */
+ public function test_register_hooks_adds_expected_hooks(): void {
+ $admin = new Admin();
+ $admin->register_hooks();
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Tests filter_ajax_query_attachments_args returns unchanged when meta_query is already set.
+ */
+ public function test_filter_ajax_query_attachments_args_passthrough_when_meta_query_present(): void {
+ $admin = new Admin();
+ $query = [
+ 'meta_query' => [ // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ [
+ 'key' => 'custom_key',
+ 'value' => '1',
+ 'compare' => '=',
+ ],
+ ],
+ ];
+
+ $this->assertSame( $query, $admin->filter_ajax_query_attachments_args( $query ) );
+ }
+
+ /**
+ * Tests filter_ajax_query_attachments_args returns unchanged when no sync filter is in the request.
+ */
+ public function test_filter_ajax_query_attachments_args_passthrough_without_sync_filter(): void {
+ $admin = new Admin();
+ $query = [ 'post_type' => 'attachment' ];
+
+ $this->assertSame( $query, $admin->filter_ajax_query_attachments_args( $query ) );
+ }
+
+ /**
+ * Tests filter_ajax_query_attachments_args adds sync meta_query for onemedia_sync_status = sync.
+ */
+ public function test_filter_ajax_query_attachments_args_adds_meta_query_for_sync_status(): void {
+ $_REQUEST['query'] = [ 'onemedia_sync_status' => Attachment::SYNC_STATUS_SYNC ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ $admin = new Admin();
+ $result = $admin->filter_ajax_query_attachments_args( [ 'post_type' => 'attachment' ] );
+
+ $this->assertSame(
+ [
+ [
+ 'key' => Attachment::IS_SYNC_POSTMETA_KEY,
+ 'value' => '1',
+ 'compare' => '=',
+ ],
+ ],
+ $result['meta_query'] // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ );
+ }
+
+ /**
+ * Tests filter_ajax_query_attachments_args adds no_sync meta_query for onemedia_sync_status = no_sync.
+ */
+ public function test_filter_ajax_query_attachments_args_adds_meta_query_for_no_sync_status(): void {
+ $_REQUEST['query'] = [ 'onemedia_sync_status' => Attachment::SYNC_STATUS_NO_SYNC ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ $admin = new Admin();
+ $result = $admin->filter_ajax_query_attachments_args( [ 'post_type' => 'attachment' ] );
+
+ $this->assertSame(
+ [
+ 'relation' => 'OR',
+ [
+ 'key' => Attachment::IS_SYNC_POSTMETA_KEY,
+ 'value' => '0',
+ 'compare' => '=',
+ ],
+ [
+ 'key' => Attachment::IS_SYNC_POSTMETA_KEY,
+ 'compare' => 'NOT EXISTS',
+ ],
+ ],
+ $result['meta_query'] // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ );
+ }
+
+ /**
+ * Tests filter_ajax_query_attachments_args adds sync meta_query for is_onemedia_sync = true.
+ */
+ public function test_filter_ajax_query_attachments_args_adds_meta_query_for_is_onemedia_sync_true(): void {
+ $_REQUEST['query'] = [ 'is_onemedia_sync' => 'true' ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ $admin = new Admin();
+ $result = $admin->filter_ajax_query_attachments_args( [ 'post_type' => 'attachment' ] );
+
+ $this->assertSame(
+ [
+ [
+ 'key' => Attachment::IS_SYNC_POSTMETA_KEY,
+ 'value' => '1',
+ 'compare' => '=',
+ ],
+ ],
+ $result['meta_query'] // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ );
+ }
+
+ /**
+ * Tests filter_ajax_query_attachments_args adds no_sync meta_query for is_onemedia_sync = false.
+ */
+ public function test_filter_ajax_query_attachments_args_adds_meta_query_for_is_onemedia_sync_false(): void {
+ $_REQUEST['query'] = [ 'is_onemedia_sync' => 'false' ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+
+ $admin = new Admin();
+ $result = $admin->filter_ajax_query_attachments_args( [ 'post_type' => 'attachment' ] );
+
+ $this->assertSame(
+ [
+ 'relation' => 'OR',
+ [
+ 'key' => Attachment::IS_SYNC_POSTMETA_KEY,
+ 'value' => '0',
+ 'compare' => '=',
+ ],
+ [
+ 'key' => Attachment::IS_SYNC_POSTMETA_KEY,
+ 'compare' => 'NOT EXISTS',
+ ],
+ ],
+ $result['meta_query'] // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+ );
+ }
+}
diff --git a/tests/phpunit/Unit/Modules/MediaLibrary/ConsumerAdminTest.php b/tests/phpunit/Unit/Modules/MediaLibrary/ConsumerAdminTest.php
new file mode 100644
index 0000000..27184f1
--- /dev/null
+++ b/tests/phpunit/Unit/Modules/MediaLibrary/ConsumerAdminTest.php
@@ -0,0 +1,103 @@
+attachment = get_post( self::factory()->attachment->create() );
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ protected function tearDown(): void {
+ delete_option( Settings::OPTION_SITE_TYPE );
+
+ parent::tearDown();
+ }
+
+ /**
+ * Tests no errors on hook registration when not on a governing site.
+ */
+ public function test_register_hooks_adds_expected_hooks(): void {
+ $consumer_admin = new ConsumerAdmin();
+ $consumer_admin->register_hooks();
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Tests register_hooks skips all hooks when on a governing site.
+ */
+ public function test_register_hooks_skips_hooks_on_governing_site(): void {
+ update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
+
+ $consumer_admin = new ConsumerAdmin();
+ $consumer_admin->register_hooks();
+
+ $this->assertFalse( has_filter( 'delete_attachment', [ $consumer_admin, 'prevent_attachment_deletion' ] ) );
+ }
+
+ /**
+ * Tests remove_edit_delete_links removes edit and delete actions for a synced attachment.
+ */
+ public function test_remove_edit_delete_links_removes_actions_for_synced(): void {
+ Attachment::set_is_synced( $this->attachment->ID, true );
+
+ $consumer_admin = new ConsumerAdmin();
+ $result = $consumer_admin->remove_edit_delete_links(
+ [
+ 'edit' => 'Edit',
+ 'delete' => 'Delete',
+ ],
+ $this->attachment
+ );
+
+ $this->assertArrayNotHasKey( 'edit', $result );
+ $this->assertArrayNotHasKey( 'delete', $result );
+ }
+
+ /**
+ * Tests remove_edit_delete_links keeps all actions for a non-synced attachment.
+ */
+ public function test_remove_edit_delete_links_keeps_actions_for_non_synced(): void {
+ $consumer_admin = new ConsumerAdmin();
+ $actions = [
+ 'edit' => 'Edit',
+ 'delete' => 'Delete',
+ ];
+
+ $result = $consumer_admin->remove_edit_delete_links( $actions, $this->attachment );
+
+ $this->assertSame( $actions, $result );
+ }
+}
diff --git a/tests/phpunit/Unit/Modules/MediaSharing/AttachmentTest.php b/tests/phpunit/Unit/Modules/MediaSharing/AttachmentTest.php
new file mode 100644
index 0000000..225fa90
--- /dev/null
+++ b/tests/phpunit/Unit/Modules/MediaSharing/AttachmentTest.php
@@ -0,0 +1,108 @@
+attachment_id = self::factory()->attachment->create();
+ }
+
+ /**
+ * Tests no errors on hook registration.
+ */
+ public function test_register_hooks_adds_expected_hooks(): void {
+ $attachment = new Attachment();
+ $attachment->register_hooks();
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Tests is_sync_attachment returns false for a new attachment with no meta.
+ */
+ public function test_is_sync_attachment_returns_false_for_new_attachment(): void {
+ $this->assertFalse( Attachment::is_sync_attachment( $this->attachment_id ) );
+ }
+
+ /**
+ * Tests set_is_synced and is_sync_attachment round-trip.
+ */
+ public function test_set_is_synced_and_is_sync_attachment(): void {
+ Attachment::set_is_synced( $this->attachment_id, true );
+ $this->assertTrue( Attachment::is_sync_attachment( $this->attachment_id ) );
+
+ Attachment::set_is_synced( $this->attachment_id, false );
+ $this->assertFalse( Attachment::is_sync_attachment( $this->attachment_id ) );
+ }
+
+ /**
+ * Tests get_sync_sites returns empty array when not on a governing site.
+ */
+ public function test_get_sync_sites_returns_empty_when_not_governing(): void {
+ $this->assertSame( [], Attachment::get_sync_sites( $this->attachment_id ) );
+ }
+
+ /**
+ * Tests get_sync_sites returns empty array on governing site when no meta is stored.
+ */
+ public function test_get_sync_sites_returns_empty_on_governing_when_no_meta(): void {
+ update_option( Settings::OPTION_SITE_TYPE, Settings::SITE_TYPE_GOVERNING );
+
+ $this->assertSame( [], Attachment::get_sync_sites( $this->attachment_id ) );
+
+ delete_option( Settings::OPTION_SITE_TYPE );
+ }
+
+ /**
+ * Tests update_sync_attachment_versions and get_sync_attachment_versions round-trip.
+ */
+ public function test_update_and_get_sync_attachment_versions(): void {
+ $versions = [
+ [
+ 'last_used' => 1000000,
+ 'file' => [
+ 'path' => '/var/www/html/wp-content/uploads/test.jpg',
+ 'url' => 'https://example.com/wp-content/uploads/test.jpg',
+ ],
+ ],
+ ];
+
+ $this->assertTrue( Attachment::update_sync_attachment_versions( $this->attachment_id, $versions ) );
+ $this->assertSame( $versions, Attachment::get_sync_attachment_versions( $this->attachment_id ) );
+ }
+
+ /**
+ * Tests get_sync_attachment_versions returns empty array when no versions are stored.
+ */
+ public function test_get_sync_attachment_versions_returns_empty_when_not_set(): void {
+ $this->assertSame( [], Attachment::get_sync_attachment_versions( $this->attachment_id ) );
+ }
+}
diff --git a/tests/phpunit/Unit/Modules/MediaSharing/MediaProtectionTest.php b/tests/phpunit/Unit/Modules/MediaSharing/MediaProtectionTest.php
new file mode 100644
index 0000000..30144e8
--- /dev/null
+++ b/tests/phpunit/Unit/Modules/MediaSharing/MediaProtectionTest.php
@@ -0,0 +1,100 @@
+attachment_id = self::factory()->attachment->create();
+ }
+
+ /**
+ * Tests no errors on hook registration.
+ */
+ public function test_register_hooks_adds_expected_hooks(): void {
+ $protection = new MediaProtection();
+ $protection->register_hooks();
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Tests prevent_sync_media_editing returns do_not_allow for a synced attachment on edit_post.
+ */
+ public function test_prevent_sync_media_editing_returns_do_not_allow_for_synced(): void {
+ Attachment::set_is_synced( $this->attachment_id, true );
+
+ $protection = new MediaProtection();
+ $result = $protection->prevent_sync_media_editing(
+ [ 'edit_posts' ],
+ 'edit_post',
+ 1,
+ [ $this->attachment_id ]
+ );
+
+ $this->assertSame( [ 'do_not_allow' ], $result );
+ }
+
+ /**
+ * Tests prevent_sync_media_editing passes through caps for a non-synced attachment.
+ */
+ public function test_prevent_sync_media_editing_passes_through_for_non_synced(): void {
+ $caps = [ 'edit_posts' ];
+ $protection = new MediaProtection();
+
+ $result = $protection->prevent_sync_media_editing(
+ $caps,
+ 'edit_post',
+ 1,
+ [ $this->attachment_id ]
+ );
+
+ $this->assertSame( $caps, $result );
+ }
+
+ /**
+ * Tests prevent_sync_media_editing passes through caps for non-edit capabilities.
+ */
+ public function test_prevent_sync_media_editing_passes_through_for_non_edit_cap(): void {
+ Attachment::set_is_synced( $this->attachment_id, true );
+
+ $caps = [ 'upload_files' ];
+ $protection = new MediaProtection();
+
+ $result = $protection->prevent_sync_media_editing(
+ $caps,
+ 'upload_files',
+ 1,
+ [ $this->attachment_id ]
+ );
+
+ $this->assertSame( $caps, $result );
+ }
+}
diff --git a/tests/phpunit/Unit/Modules/MediaSharing/UserInterfaceTest.php b/tests/phpunit/Unit/Modules/MediaSharing/UserInterfaceTest.php
new file mode 100644
index 0000000..5940e1f
--- /dev/null
+++ b/tests/phpunit/Unit/Modules/MediaSharing/UserInterfaceTest.php
@@ -0,0 +1,96 @@
+attachment = get_post( self::factory()->attachment->create() );
+ }
+
+ /**
+ * Tests no errors on hook registration.
+ */
+ public function test_register_hooks_adds_expected_hooks(): void {
+ $ui = new UserInterface();
+ $ui->register_hooks();
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Tests add_sync_column appends the sync status column.
+ */
+ public function test_add_sync_column_appends_column(): void {
+ $ui = new UserInterface();
+
+ $result = $ui->add_sync_column(
+ [
+ 'title' => 'Title',
+ 'date' => 'Date',
+ ]
+ );
+
+ $this->assertArrayHasKey( 'onemedia_sync_status', $result );
+ }
+
+ /**
+ * Tests filter_media_row_actions removes delete action for synced attachment.
+ */
+ public function test_filter_media_row_actions_removes_delete_for_synced(): void {
+ Attachment::set_is_synced( $this->attachment->ID, true );
+
+ $ui = new UserInterface();
+ $result = $ui->filter_media_row_actions(
+ [
+ 'edit' => 'Edit',
+ 'delete' => 'Delete',
+ ],
+ $this->attachment
+ );
+
+ $this->assertArrayNotHasKey( 'delete', $result );
+ }
+
+ /**
+ * Tests filter_media_row_actions keeps all actions for non-synced attachment.
+ */
+ public function test_filter_media_row_actions_keeps_actions_for_non_synced(): void {
+ $ui = new UserInterface();
+ $actions = [
+ 'edit' => 'Edit',
+ 'delete' => 'Delete',
+ ];
+
+ $result = $ui->filter_media_row_actions( $actions, $this->attachment );
+
+ $this->assertSame( $actions, $result );
+ }
+}
diff --git a/tests/phpunit/Unit/Modules/Settings/AdminTest.php b/tests/phpunit/Unit/Modules/Settings/AdminTest.php
new file mode 100644
index 0000000..8ecf959
--- /dev/null
+++ b/tests/phpunit/Unit/Modules/Settings/AdminTest.php
@@ -0,0 +1,77 @@
+register_hooks();
+
+ $this->assertTrue( true );
+ }
+
+ /**
+ * Tests screen_callback outputs the settings page mount point.
+ */
+ public function test_screen_callback_outputs_expected_html(): void {
+ $admin = new Admin();
+
+ ob_start();
+ $admin->screen_callback();
+ $output = ob_get_clean();
+
+ $this->assertStringContainsString( 'onemedia-settings-page', (string) $output );
+ }
+
+ /**
+ * Tests add_action_links appends a settings link to an empty array.
+ */
+ public function test_add_action_links_appends_settings_link(): void {
+ $admin = new Admin();
+
+ $result = $admin->add_action_links( [] );
+
+ $this->assertCount( 1, $result );
+ $this->assertStringContainsString( 'Settings', $result[0] );
+ }
+
+ /**
+ * Tests add_action_links preserves existing links.
+ */
+ public function test_add_action_links_preserves_existing_links(): void {
+ $admin = new Admin();
+
+ $result = $admin->add_action_links( [ 'Existing' ] );
+
+ $this->assertCount( 2, $result );
+ }
+
+ /**
+ * Tests add_body_classes returns a string.
+ */
+ public function test_add_body_classes_returns_string(): void {
+ $admin = new Admin();
+
+ $result = $admin->add_body_classes( 'existing-class' );
+
+ $this->assertIsString( $result );
+ }
+}