;
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+
+ // Setup default mock returns
+ mockGetFields.mockReturnValue( [
+ { id: 'id', label: 'Task ID' },
+ { id: 'action_id', label: 'Action ID' },
+ { id: 'task_type', label: 'Task Type', filterBy: { operators: [ 'is' ], isPrimary: true } },
+ { id: 'current_try', label: 'Current Try' },
+ { id: 'status', label: 'Status', filterBy: { operators: [ 'is', 'isNot' ], isPrimary: true } },
+ { id: 'scheduled_at', label: 'Scheduled At' },
+ ] );
+
+ mockGetTasks.mockResolvedValue( {
+ data: [
+ {
+ id: 1,
+ action_id: 100,
+ data: { task_class: 'TestTask', args: [ 'arg1', 'arg2' ] },
+ current_try: 1,
+ status: 'pending',
+ scheduled_at: new Date( '2024-01-01' ),
+ logs: [ { id: 1, type: 'created' } ],
+ },
+ {
+ id: 2,
+ action_id: 101,
+ data: { task_class: 'AnotherTask', args: [] },
+ current_try: 2,
+ status: 'complete',
+ scheduled_at: new Date( '2024-01-02' ),
+ logs: [],
+ },
+ ],
+ paginationInfo: {
+ totalItems: 50,
+ totalPages: 5,
+ }
+ } );
+ } );
+
+ it( 'should render the DataViews component', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'dataviews-mock' ) ).toBeInTheDocument();
+ } );
+ } );
+
+ it( 'should fetch and display data', async () => {
+ render( );
+
+ // Check if mocks were called
+ await waitFor( () => {
+ expect( mockGetTasks ).toHaveBeenCalled();
+ } );
+
+ // Check call arguments
+ expect( mockGetTasks ).toHaveBeenCalledWith( {
+ perPage: 10,
+ page: 1,
+ order: 'desc',
+ orderby: 'id',
+ search: '',
+ filters: JSON.stringify( [] ),
+ } );
+
+ // Verify that the component renders (even if data isn't displayed due to mock limitations)
+ expect( screen.getByTestId( 'dataviews-mock' ) ).toBeInTheDocument();
+ } );
+
+ it( 'should pass correct fields to DataViews', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect( mockGetFields ).toHaveBeenCalled();
+ } );
+ } );
+
+ it( 'should configure view with correct settings', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect( screen.getByTestId( 'view-type' ) ).toHaveTextContent( 'table' );
+ } );
+ } );
+
+ it( 'should define three actions (view, edit, and delete)', async () => {
+ render( );
+
+ // Component renders with actions (hardcoded in component, not from mocks)
+ expect( screen.getByTestId( 'actions-count' ) ).toHaveTextContent( '3 actions' );
+ } );
+
+ it( 'should handle view changes and refetch data', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect( mockGetTasks ).toHaveBeenCalled();
+ } );
+
+ // Simulate view change
+ const changeViewButton = screen.getByTestId( 'change-view' );
+ changeViewButton.click();
+
+ // Component should handle the click (testing that it doesn't crash)
+ expect( changeViewButton ).toBeInTheDocument();
+ } );
+
+ describe( 'Actions', () => {
+ let mockDataViews;
+
+ beforeEach( () => {
+ const { DataViews } = require( '@wordpress/dataviews/wp' );
+ mockDataViews = DataViews;
+ } );
+
+ it( 'should define view action with correct properties', async () => {
+ render( );
+
+ await waitFor( () => {
+ const callArgs = mockDataViews.mock.calls[ 0 ][ 0 ];
+ const viewAction = callArgs.actions.find( ( a ) => a.id === 'view' );
+
+ expect( viewAction ).toBeDefined();
+ expect( viewAction.label ).toBe( 'View' );
+ expect( viewAction.isPrimary ).toBe( true );
+ expect( viewAction.icon ).toBeDefined();
+ } );
+ } );
+
+ it( 'should make view action eligible only for items with logs', async () => {
+ render( );
+
+ await waitFor( () => {
+ const callArgs = mockDataViews.mock.calls[ 0 ][ 0 ];
+ const viewAction = callArgs.actions.find( ( a ) => a.id === 'view' );
+
+ // Item with logs
+ expect( viewAction.isEligible( { logs: [ { id: 1 } ] } ) ).toBe( true );
+
+ // Item without logs
+ expect( viewAction.isEligible( { logs: [] } ) ).toBe( false );
+ } );
+ } );
+
+ it( 'should define delete action as destructive with modal', async () => {
+ render( );
+
+ await waitFor( () => {
+ const callArgs = mockDataViews.mock.calls[ 0 ][ 0 ];
+ const deleteAction = callArgs.actions.find( ( a ) => a.id === 'delete' );
+
+ expect( deleteAction ).toBeDefined();
+ expect( deleteAction.label ).toBe( 'Delete' );
+ expect( deleteAction.isDestructive ).toBe( true );
+ expect( deleteAction.supportsBulk ).toBe( true );
+ expect( deleteAction.RenderModal ).toBeDefined();
+ } );
+ } );
+
+ it( 'should render delete confirmation modal', async () => {
+ render( );
+
+ await waitFor( () => {
+ const callArgs = mockDataViews.mock.calls[ 0 ][ 0 ];
+ const deleteAction = callArgs.actions.find( ( a ) => a.id === 'delete' );
+
+ const mockCloseModal = jest.fn();
+ const mockOnActionPerformed = jest.fn();
+ const items = [ { id: 1 }, { id: 2 } ];
+
+ const { container } = render(
+
+ );
+
+ expect( container ).toHaveTextContent( 'Are you sure you want to delete 2 item(s)?' );
+ expect( screen.getAllByTestId( 'button' )[ 1 ] ).toHaveTextContent( 'Confirm Delete' );
+ } );
+ } );
+ } );
+
+ it( 'should handle API errors gracefully', async () => {
+ // Setup error before rendering
+ mockGetTasks.mockClear();
+ mockGetTasks.mockRejectedValue( new Error( 'API Error' ) );
+
+ render( );
+
+ // Should still render (component doesn't crash on errors)
+ expect( screen.getByTestId( 'dataviews-mock' ) ).toBeInTheDocument();
+
+ // Reset mock to prevent affecting other tests
+ mockGetTasks.mockClear();
+ mockGetTasks.mockResolvedValue( {
+ data: [],
+ paginationInfo: { totalItems: 0, totalPages: 0 }
+ } );
+ } );
+
+ it( 'should update data when view parameters change', async () => {
+ render( );
+
+ await waitFor( () => {
+ expect( mockGetTasks ).toHaveBeenCalled();
+ } );
+
+ // Test that initial call was made with default parameters
+ expect( mockGetTasks ).toHaveBeenCalledWith( {
+ perPage: 10,
+ page: 1,
+ order: 'desc',
+ orderby: 'id',
+ search: '',
+ filters: JSON.stringify( [] ),
+ } );
+ } );
+} );
\ No newline at end of file
diff --git a/tests/js/app/data.spec.tsx b/tests/js/app/data.spec.tsx
new file mode 100644
index 0000000..45ee396
--- /dev/null
+++ b/tests/js/app/data.spec.tsx
@@ -0,0 +1,405 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import '@testing-library/jest-dom';
+// Data functions imported per test to avoid module cache issues
+import type { Field } from '@wordpress/dataviews';
+import type { Task } from '../../../app/types';
+
+// Mock WordPress dependencies
+jest.mock( '@wordpress/i18n', () => ( {
+ __: jest.fn( ( text ) => text ),
+} ) );
+
+jest.mock( '@wordpress/date', () => ( {
+ getSettings: jest.fn( () => ( {
+ formats: {
+ datetime: 'Y-m-d H:i:s',
+ date: 'Y-m-d',
+ },
+ } ) ),
+ humanTimeDiff: jest.fn( ( date1, date2 ) => '2 hours ago' ),
+ dateI18n: jest.fn( ( format, date ) => '2024-01-01' ),
+ getDate: jest.fn( ( dateString ) => {
+ if ( dateString === null ) {
+ return new Date();
+ }
+ return new Date( dateString );
+ } ),
+} ) );
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+describe( 'data.tsx', () => {
+ beforeEach( () => {
+ // Reset global window object
+ delete global.window.shepherdData;
+ global.window.ajaxurl = 'http://example.com/wp-admin/admin-ajax.php';
+ jest.clearAllMocks();
+ // Clear module cache to reset unique values and defaultArgs
+ jest.resetModules();
+ } );
+
+ describe( 'getFields', () => {
+ const mockData = [
+ {
+ id: 1,
+ data: { task_class: 'TestTask' },
+ status: 'pending',
+ },
+ {
+ id: 2,
+ data: { task_class: 'AnotherTask' },
+ status: 'complete',
+ },
+ ];
+
+ it( 'should return all field definitions', () => {
+ const { getFields } = require( '../../../app/data' );
+ const fields = getFields( mockData );
+
+ expect( fields ).toHaveLength( 7 );
+ expect( fields.map( ( f ) => f.id ) ).toEqual( [
+ 'id',
+ 'action_id',
+ 'task_type',
+ 'task_args',
+ 'current_try',
+ 'status',
+ 'scheduled_at',
+ ] );
+ } );
+
+ it( 'should configure task ID field correctly', () => {
+ const { getFields } = require( '../../../app/data' );
+ const fields = getFields( mockData );
+ const idField = fields.find( ( f ) => f.id === 'id' );
+
+ expect( idField ).toBeDefined();
+ expect( idField.label ).toBe( 'Task ID' );
+ expect( idField.enableHiding ).toBe( false );
+ expect( idField.enableSorting ).toBe( true );
+ } );
+
+ it( 'should configure task type field with getValue and elements', () => {
+ const { getFields } = require( '../../../app/data' );
+ const fields = getFields( mockData );
+ const taskTypeField = fields.find( ( f ) => f.id === 'task_type' );
+
+ expect( taskTypeField ).toBeDefined();
+ expect( taskTypeField.getValue ).toBeDefined();
+ expect( taskTypeField.filterBy ).toEqual( {
+ operators: [ 'is' ],
+ isPrimary: true,
+ } );
+ expect( taskTypeField.elements ).toBeDefined();
+
+ // Test getValue function
+ const item = { data: { task_class: 'MyTask' } };
+ expect( taskTypeField.getValue( { item } ) ).toBe( 'MyTask' );
+ } );
+
+ it( 'should configure task args field with custom render', () => {
+ const { getFields } = require( '../../../app/data' );
+ const fields = getFields( mockData );
+ const argsField = fields.find( ( f ) => f.id === 'task_args' );
+
+ expect( argsField ).toBeDefined();
+ expect( argsField.render ).toBeDefined();
+
+ // Test render function
+ const item = { data: { args: [ 'arg1', 'arg2' ] } };
+ const { container } = render( { argsField.render( { item } ) }
);
+ const codeElement = container.querySelector( 'code' );
+
+ expect( codeElement ).toBeInTheDocument();
+ expect( codeElement ).toHaveStyle( {
+ whiteSpace: 'pre-wrap',
+ wordWrap: 'break-word',
+ } );
+ } );
+
+ it( 'should configure status field with elements and filtering', () => {
+ const { getFields } = require( '../../../app/data' );
+ const fields = getFields( mockData );
+ const statusField = fields.find( ( f ) => f.id === 'status' );
+
+ expect( statusField ).toBeDefined();
+ expect( statusField.elements ).toHaveLength( 5 );
+ expect( statusField.elements[ 0 ] ).toEqual( {
+ value: 'pending',
+ label: 'Pending',
+ } );
+ expect( statusField.filterBy ).toEqual( {
+ operators: [ 'is', 'isNot' ],
+ isPrimary: true,
+ } );
+ } );
+
+ it( 'should configure scheduled_at field with date rendering', () => {
+ const { getFields } = require( '../../../app/data' );
+ const fields = getFields( mockData );
+ const scheduledField = fields.find( ( f ) => f.id === 'scheduled_at' );
+
+ expect( scheduledField ).toBeDefined();
+ expect( scheduledField.render ).toBeDefined();
+
+ // Test render with null date
+ const itemNoDate = { scheduled_at: null };
+ const { container: containerNoDate } = render(
+ { scheduledField.render( { item: itemNoDate } ) }
+ );
+ expect( containerNoDate ).toHaveTextContent( 'Never' );
+
+ // Test render with recent date
+ const recentDate = new Date();
+ const itemRecent = { scheduled_at: recentDate };
+ const { container: containerRecent } = render(
+ { scheduledField.render( { item: itemRecent } ) }
+ );
+ expect( containerRecent ).toHaveTextContent( '2 hours ago' );
+
+ // Test render with old date
+ const oldDate = new Date( '2020-01-01' );
+ const itemOld = { scheduled_at: oldDate };
+ const { getDate } = require( '@wordpress/date' );
+ getDate.mockReturnValueOnce( new Date( '2024-01-01' ) );
+ const { container: containerOld } = render(
+ { scheduledField.render( { item: itemOld } ) }
+ );
+ expect( containerOld ).toHaveTextContent( '2024-01-01' );
+ } );
+ } );
+
+ describe( 'getTasks', () => {
+ const apiFetch = require( '@wordpress/api-fetch' );
+
+ it( 'should return data from window when using default args', async () => {
+ const defaultArgs = {
+ perPage: 10,
+ page: 1,
+ order: 'desc',
+ orderby: 'id',
+ search: '',
+ filters: '[]',
+ };
+
+ global.window.shepherdData = {
+ defaultArgs,
+ tasks: [
+ {
+ id: 1,
+ action_id: 100,
+ data: { task_class: 'TestTask', args: [ 'arg1' ] },
+ current_try: 1,
+ status: 'pending',
+ scheduled_at: { date: '2024-01-01 12:00:00' },
+ logs: [],
+ },
+ ],
+ totalItems: 1,
+ totalPages: 1,
+ };
+
+ // Import fresh module after setting window data
+ const { getTasks } = require( '../../../app/data' );
+ const result = await getTasks( defaultArgs );
+
+ expect( result.data ).toHaveLength( 1 );
+ expect( result.data[ 0 ].id ).toBe( 1 );
+ expect( result.data[ 0 ].scheduled_at ).toBeInstanceOf( Date );
+ expect( result.paginationInfo.totalItems ).toBe( 1 );
+ expect( apiFetch ).not.toHaveBeenCalled();
+ } );
+
+
+ it( 'should handle API errors gracefully', async () => {
+ global.window.shepherdData = {
+ defaultArgs: { perPage: 10 },
+ nonce: 'test-nonce',
+ };
+
+ // Import fresh module after setting window data
+ const { getTasks } = require( '../../../app/data' );
+
+ apiFetch.mockRejectedValue( new Error( 'Network error' ) );
+
+ const result = await getTasks( { perPage: 20 } );
+
+ expect( result.data ).toEqual( [] );
+ expect( result.paginationInfo ).toEqual( {
+ totalItems: 0,
+ totalPages: 0,
+ } );
+ } );
+
+ it( 'should handle unsuccessful API response', async () => {
+ global.window.shepherdData = {
+ defaultArgs: { perPage: 10 },
+ nonce: 'test-nonce',
+ };
+
+ // Import fresh module after setting window data
+ const { getTasks } = require( '../../../app/data' );
+
+ apiFetch.mockResolvedValue( {
+ success: false,
+ data: { message: 'Error occurred' },
+ } );
+
+ const result = await getTasks( { perPage: 20 } );
+
+ expect( result.data ).toEqual( [] );
+ expect( result.paginationInfo ).toEqual( {
+ totalItems: 0,
+ totalPages: 0,
+ } );
+ } );
+
+ it( 'should handle scheduled_at date conversion', async () => {
+ const { getDate } = require( '@wordpress/date' );
+ const mockDate = new Date( '2024-01-01T12:00:00Z' );
+ getDate.mockReturnValue( mockDate );
+
+ const defaultArgs = { perPage: 10, page: 1, order: 'desc', orderby: 'id', search: '', filters: '[]' };
+ global.window.shepherdData = {
+ defaultArgs,
+ tasks: [
+ {
+ id: 1,
+ action_id: 100,
+ data: { task_class: 'TestTask', args: [] },
+ current_try: 1,
+ status: 'pending',
+ scheduled_at: { date: '2024-01-01 12:00:00' },
+ logs: [],
+ },
+ ],
+ };
+
+ // Import fresh module after setting window data
+ const { getTasks } = require( '../../../app/data' );
+ const result = await getTasks( defaultArgs );
+
+ expect( getDate ).toHaveBeenCalledWith( '2024-01-01 12:00:00' );
+ expect( result.data[ 0 ].scheduled_at ).toBe( mockDate );
+ } );
+ } );
+
+ describe( 'getPaginationInfo', () => {
+ it( 'should return pagination info from window data', () => {
+ global.window.shepherdData = {
+ totalItems: 100,
+ totalPages: 10,
+ };
+
+ const { getPaginationInfo } = require( '../../../app/data' );
+ const paginationInfo = getPaginationInfo();
+
+ expect( paginationInfo ).toEqual( {
+ totalItems: 100,
+ totalPages: 10,
+ } );
+ } );
+
+ it( 'should return zero values when no data', () => {
+ const { getPaginationInfo } = require( '../../../app/data' );
+ const paginationInfo = getPaginationInfo();
+
+ expect( paginationInfo ).toEqual( {
+ totalItems: 0,
+ totalPages: 0,
+ } );
+ } );
+
+ it( 'should handle partial data', () => {
+ global.window.shepherdData = {
+ totalItems: 50,
+ };
+
+ const { getPaginationInfo } = require( '../../../app/data' );
+ const paginationInfo = getPaginationInfo();
+
+ expect( paginationInfo ).toEqual( {
+ totalItems: 50,
+ totalPages: 0,
+ } );
+ } );
+ } );
+
+ describe( 'getUniqueValuesOfData', () => {
+ const mockTasks = [
+ { id: 1, status: 'pending', data: { task_class: 'EmailTask' } },
+ { id: 2, status: 'complete', data: { task_class: 'EmailTask' } },
+ { id: 3, status: 'pending', data: { task_class: 'HTTPTask' } },
+ { id: 4, status: 'failed', data: { task_class: 'HTTPTask' } },
+ ];
+
+
+ it( 'should extract unique values from top-level fields', () => {
+ const { getUniqueValuesOfData } = require( '../../../app/data' );
+ const result = getUniqueValuesOfData( 'status', mockTasks );
+
+ expect( result ).toHaveLength( 3 );
+ expect( result ).toEqual( [
+ { label: 'pending', value: 'pending' },
+ { label: 'complete', value: 'complete' },
+ { label: 'failed', value: 'failed' },
+ ] );
+ } );
+
+ it( 'should extract unique values from nested data fields', () => {
+ const { getUniqueValuesOfData } = require( '../../../app/data' );
+ const result = getUniqueValuesOfData( 'task_class', mockTasks );
+
+ expect( result ).toHaveLength( 2 );
+ expect( result ).toEqual( [
+ { label: 'EmailTask', value: 'EmailTask' },
+ { label: 'HTTPTask', value: 'HTTPTask' },
+ ] );
+ } );
+
+ it( 'should cache unique values across multiple calls', () => {
+ const { getUniqueValuesOfData } = require( '../../../app/data' );
+ // First call
+ getUniqueValuesOfData( 'status', mockTasks.slice( 0, 2 ) );
+
+ // Second call with additional data
+ const result = getUniqueValuesOfData( 'status', mockTasks.slice( 2, 4 ) );
+
+ // Should have all unique values from both calls
+ expect( result ).toHaveLength( 3 );
+ } );
+
+ it( 'should handle undefined values', () => {
+ const { getUniqueValuesOfData } = require( '../../../app/data' );
+ const tasksWithUndefined = [
+ { id: 1, status: 'pending', data: {} },
+ { id: 2, data: {} }, // status is undefined
+ { id: 3, status: 'complete', data: {} },
+ ];
+
+ const result = getUniqueValuesOfData( 'status', tasksWithUndefined );
+
+ expect( result ).toHaveLength( 3 );
+ expect( result ).toEqual( [
+ { label: 'pending', value: 'pending' },
+ { label: undefined, value: undefined },
+ { label: 'complete', value: 'complete' },
+ ] );
+ } );
+ } );
+} );
+
+// Type augmentation for window object
+declare global {
+ interface Window {
+ shepherdData?: {
+ tasks?: any[];
+ totalItems?: number;
+ totalPages?: number;
+ defaultArgs?: any;
+ nonce?: string;
+ };
+ ajaxurl?: string;
+ }
+}
\ No newline at end of file
diff --git a/tests/js/setup.js b/tests/js/setup.js
new file mode 100644
index 0000000..4753ea0
--- /dev/null
+++ b/tests/js/setup.js
@@ -0,0 +1,12 @@
+// Jest setup file
+import '@testing-library/jest-dom';
+
+// Mock console methods to avoid cluttering test output
+global.console = {
+ ...console,
+ log: jest.fn(),
+ debug: jest.fn(),
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+};
\ No newline at end of file
diff --git a/tests/wpunit/Admin/Provider_Test.php b/tests/wpunit/Admin/Provider_Test.php
new file mode 100644
index 0000000..4427552
--- /dev/null
+++ b/tests/wpunit/Admin/Provider_Test.php
@@ -0,0 +1,283 @@
+set_fn_return( 'is_admin', true );
+ Config::set_render_admin_ui( true );
+ $this->provider = Config::get_container()->get( Provider::class );
+ $this->provider->register();
+ }
+
+ /**
+ * @after
+ */
+ public function cleanup_provider(): void {
+ Config::set_render_admin_ui( false );
+ $_POST = [];
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_register_hooks(): void {
+ $this->assertNotFalse(
+ has_action( 'wp_ajax_shepherd_get_tasks', [ $this->provider, 'ajax_get_tasks' ] ),
+ 'AJAX action should be registered'
+ );
+
+ $this->assertNotFalse(
+ has_action( 'admin_menu', [ $this->provider, 'register_admin_menu' ] ),
+ 'Admin menu action should be registered'
+ );
+ }
+
+ /**
+ * Test admin page HTML rendering
+ * @test
+ */
+ public function it_should_render_admin_page(): void {
+ ob_start();
+ $this->provider->render_admin_page();
+ $output = ob_get_clean();
+
+ $this->assertMatchesHtmlSnapshot( $output );
+ }
+
+ /**
+ * Test custom title rendering
+ * @test
+ */
+ public function it_should_render_custom_titles(): void {
+ Config::set_admin_page_in_page_title_callback( fn() => 'Custom Unit Test Title' );
+
+ ob_start();
+ $this->provider->render_admin_page();
+ $output = ob_get_clean();
+
+ $this->assertMatchesHtmlSnapshot( $output );
+ }
+
+ /**
+ * Test asset enqueuing
+ * @test
+ */
+ public function it_should_enqueue_admin_page_assets(): void {
+ global $wp_scripts, $wp_styles;
+
+ $this->provider->enqueue_admin_page_assets();
+
+ // Check script is enqueued
+ $this->assertArrayHasKey( 'shepherd-admin-script', $wp_scripts->registered );
+ $this->assertContains( 'wp-components', $wp_scripts->registered['shepherd-admin-script']->deps );
+ $this->assertContains( 'wp-data', $wp_scripts->registered['shepherd-admin-script']->deps );
+
+ // Check style is enqueued
+ $this->assertArrayHasKey( 'shepherd-admin-style', $wp_styles->registered );
+ }
+
+ /**
+ * Test script localization
+ * @test
+ */
+ public function it_should_localize_script_data(): void {
+ global $wp_scripts;
+
+ $this->provider->enqueue_admin_page_assets();
+
+ // Check localized data exists
+ $localized = $wp_scripts->get_data( 'shepherd-admin-script', 'data' );
+ $this->assertStringContainsString( 'shepherdData', $localized );
+ $this->assertStringContainsString( 'tasks', $localized );
+ $this->assertStringContainsString( 'totalItems', $localized );
+ $this->assertStringContainsString( 'totalPages', $localized );
+ $this->assertStringContainsString( 'nonce', $localized );
+ }
+
+ /**
+ * Test capability configuration
+ * @test
+ */
+ public function it_should_respect_admin_page_capability(): void {
+ // Test default capability
+ $this->assertEquals( 'manage_options', Config::get_admin_page_capability() );
+
+ // Test setting custom capability
+ Config::set_admin_page_capability( 'edit_posts' );
+ $this->assertEquals( 'edit_posts', Config::get_admin_page_capability() );
+
+ // Reset to default
+ Config::set_admin_page_capability( 'manage_options' );
+ }
+
+ /**
+ * Test localized data structure
+ * @test
+ */
+ public function it_should_provide_correct_localized_data_structure(): void {
+ $reflection = new \ReflectionMethod( $this->provider, 'get_localized_data' );
+ $reflection->setAccessible( true );
+ $data = $reflection->invoke( $this->provider );
+
+ // Check required keys exist
+ $this->assertArrayHasKey( 'tasks', $data );
+ $this->assertArrayHasKey( 'totalItems', $data );
+ $this->assertArrayHasKey( 'totalPages', $data );
+ $this->assertArrayHasKey( 'defaultArgs', $data );
+ $this->assertArrayHasKey( 'nonce', $data );
+
+ // Check data types
+ $this->assertIsArray( $data['tasks'] );
+ $this->assertIsInt( $data['totalItems'] );
+ $this->assertIsInt( $data['totalPages'] );
+ $this->assertIsArray( $data['defaultArgs'] );
+ $this->assertIsString( $data['nonce'] );
+
+ // Check default args structure
+ $defaultArgs = $data['defaultArgs'];
+ $this->assertEquals( 10, $defaultArgs['perPage'] );
+ $this->assertEquals( 1, $defaultArgs['page'] );
+ $this->assertEquals( 'desc', $defaultArgs['order'] );
+ $this->assertEquals( 'id', $defaultArgs['orderby'] );
+ $this->assertEquals( '', $defaultArgs['search'] );
+ $this->assertEquals( '[]', $defaultArgs['filters'] );
+ }
+
+ /**
+ * Test get_tasks method parameter handling
+ * @test
+ */
+ public function it_should_handle_get_tasks_method_parameters(): void {
+ $reflection = new \ReflectionMethod( $this->provider, 'get_tasks' );
+ $reflection->setAccessible( true );
+
+ // Test basic call
+ $result = $reflection->invoke( $this->provider, [], 10, 1 );
+
+ $this->assertIsArray( $result );
+ $this->assertArrayHasKey( 'tasks', $result );
+ $this->assertArrayHasKey( 'totalItems', $result );
+ $this->assertArrayHasKey( 'totalPages', $result );
+
+ // Test with different parameters
+ $result2 = $reflection->invoke( $this->provider, [ 'orderby' => 'action_id' ], 5, 2 );
+ $this->assertIsArray( $result2 );
+ $this->assertArrayHasKey( 'tasks', $result2 );
+ }
+
+ /**
+ * Test AJAX method structure
+ * @test
+ */
+ public function it_should_have_proper_ajax_method(): void {
+ // Test method exists
+ $this->assertTrue( method_exists( $this->provider, 'ajax_get_tasks' ) );
+
+ // Test method visibility
+ $reflection = new \ReflectionMethod( $this->provider, 'ajax_get_tasks' );
+ $this->assertTrue( $reflection->isPublic() );
+
+ // Test method parameters
+ $this->assertEquals( 0, $reflection->getNumberOfParameters() );
+ }
+
+ /**
+ * Test provider registration state
+ * @test
+ */
+ public function it_should_track_registration_state(): void {
+ // Test that Provider tracks if it's registered
+ $this->assertTrue( Provider::is_registered() );
+
+ // Test multiple registrations don't cause issues
+ $this->provider->register();
+ $this->provider->register();
+ $this->assertTrue( Provider::is_registered() );
+ }
+
+ /**
+ * Test admin page rendering with various configurations
+ * @test
+ */
+ public function it_should_handle_various_title_configurations(): void {
+ // Test with page title callback
+ Config::set_admin_page_title_callback( fn() => 'Unit Test Page Title' );
+ $this->assertEquals( 'Unit Test Page Title', Config::get_admin_page_title() );
+
+ // Test with menu title callback
+ Config::set_admin_menu_title_callback( fn() => 'Unit Test Menu' );
+ $this->assertEquals( 'Unit Test Menu', Config::get_admin_menu_title() );
+
+ // Test with in-page title callback
+ Config::set_admin_page_in_page_title_callback( fn() => 'Unit Test In-Page' );
+ $this->assertEquals( 'Unit Test In-Page', Config::get_admin_page_in_page_title() );
+
+ // Test rendering reflects the in-page title
+ ob_start();
+ $this->provider->render_admin_page();
+ $output = ob_get_clean();
+ $this->assertStringContainsString( 'Unit Test In-Page', $output );
+
+ // Cleanup
+ Config::set_admin_page_title_callback( null );
+ Config::set_admin_menu_title_callback( null );
+ Config::set_admin_page_in_page_title_callback( null );
+ }
+
+ /**
+ * Test render_admin_ui configuration affects registration
+ * @test
+ */
+ public function it_should_respect_render_admin_ui_configuration(): void {
+ // Test that config setting affects behavior
+ $original_setting = Config::get_render_admin_ui();
+
+ Config::set_render_admin_ui( false );
+ $this->assertFalse( Config::get_render_admin_ui() );
+
+ Config::set_render_admin_ui( true );
+ $this->assertTrue( Config::get_render_admin_ui() );
+
+ // Restore original
+ Config::set_render_admin_ui( $original_setting );
+ }
+
+ /**
+ * Test that provider methods handle edge cases
+ * @test
+ */
+ public function it_should_handle_edge_cases_gracefully(): void {
+ // Test rendering with empty/null configurations
+ Config::set_admin_page_in_page_title_callback( fn() => '' );
+
+ ob_start();
+ $this->provider->render_admin_page();
+ $output = ob_get_clean();
+
+ $this->assertMatchesHtmlSnapshot( $output );
+ }
+}
diff --git a/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_handle_edge_cases_gracefully__0.snapshot.html b/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_handle_edge_cases_gracefully__0.snapshot.html
new file mode 100644
index 0000000..738e1f9
--- /dev/null
+++ b/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_handle_edge_cases_gracefully__0.snapshot.html
@@ -0,0 +1,6 @@
+
+
\ No newline at end of file
diff --git a/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_render_admin_page__0.snapshot.html b/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_render_admin_page__0.snapshot.html
new file mode 100644
index 0000000..dd81dd4
--- /dev/null
+++ b/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_render_admin_page__0.snapshot.html
@@ -0,0 +1,6 @@
+
+
+ Shepherd Task Manager (via foobar)
+
+
+
\ No newline at end of file
diff --git a/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_render_custom_titles__0.snapshot.html b/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_render_custom_titles__0.snapshot.html
new file mode 100644
index 0000000..2ed0abb
--- /dev/null
+++ b/tests/wpunit/Admin/__snapshots__/Provider_Test__it_should_render_custom_titles__0.snapshot.html
@@ -0,0 +1,6 @@
+
+
+ Custom Unit Test Title
+
+
+
\ No newline at end of file
diff --git a/tests/wpunit/Config_Test.php b/tests/wpunit/Config_Test.php
index c658537..b3e3ee5 100644
--- a/tests/wpunit/Config_Test.php
+++ b/tests/wpunit/Config_Test.php
@@ -62,6 +62,117 @@ public function it_should_set_and_get_logger(): void {
$this->assertSame( $null_logger, Config::get_logger() );
}
+ /**
+ * @test
+ */
+ public function it_should_get_and_set_render_admin_ui(): void {
+ $this->assertFalse( Config::get_render_admin_ui() );
+
+ // Test setting to false.
+ Config::set_render_admin_ui( true );
+ $this->assertTrue( Config::get_render_admin_ui() );
+
+ // Reset to default.
+ Config::set_render_admin_ui( false );
+ $this->assertFalse( Config::get_render_admin_ui() );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_get_default_admin_page_title(): void {
+ Config::set_hook_prefix( 'test_foo' );
+ $expected = sprintf( __( 'Shepherd (%s)', 'stellarwp-shepherd' ), 'test_foo' );
+ $this->assertEquals( $expected, Config::get_admin_page_title() );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_use_custom_admin_page_title_callback(): void {
+ Config::set_admin_page_title_callback( fn() => 'Custom Admin Page Title' );
+
+ $this->assertEquals( 'Custom Admin Page Title', Config::get_admin_page_title() );
+
+ // Clean up.
+ Config::set_admin_page_title_callback( null );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_fallback_to_default_if_callback_returns_non_string(): void {
+ Config::set_admin_page_title_callback( fn() => 123 );
+ Config::set_hook_prefix( 'test_foo' );
+
+ $expected = sprintf( __( 'Shepherd (%s)', 'stellarwp-shepherd' ), 'test_foo' );
+ $this->assertEquals( $expected, Config::get_admin_page_title() );
+
+ // Clean up.
+ Config::set_admin_page_title_callback( null );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_get_default_admin_menu_title(): void {
+ Config::set_hook_prefix( 'bar_foo' );
+ $expected = sprintf( __( 'Shepherd (%s)', 'stellarwp-shepherd' ), 'bar_foo' );
+ $this->assertEquals( $expected, Config::get_admin_menu_title() );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_use_custom_admin_menu_title_callback(): void {
+ Config::set_admin_menu_title_callback( fn() => 'Custom Menu Title' );
+
+ $this->assertEquals( 'Custom Menu Title', Config::get_admin_menu_title() );
+
+ // Clean up.
+ Config::set_admin_menu_title_callback( null );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_get_default_admin_page_in_page_title(): void {
+ Config::set_hook_prefix( 'baz_foo' );
+ $expected = sprintf( __( 'Shepherd Task Manager (via %s)', 'stellarwp-shepherd' ), 'baz_foo' );
+ $this->assertEquals( $expected, Config::get_admin_page_in_page_title() );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_use_custom_admin_page_in_page_title_callback(): void {
+ Config::set_admin_page_in_page_title_callback( fn() => 'Custom In-Page Title' );
+
+ $this->assertEquals( 'Custom In-Page Title', Config::get_admin_page_in_page_title() );
+
+ // Clean up.
+ Config::set_admin_page_in_page_title_callback( null );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_get_and_set_admin_page_capability(): void {
+ // Test default capability.
+ $this->assertEquals( 'manage_options', Config::get_admin_page_capability() );
+
+ // Test setting custom capability.
+ Config::set_admin_page_capability( 'edit_posts' );
+ $this->assertEquals( 'edit_posts', Config::get_admin_page_capability() );
+
+ // Test setting another capability.
+ Config::set_admin_page_capability( 'administrator' );
+ $this->assertEquals( 'administrator', Config::get_admin_page_capability() );
+
+ // Reset to default.
+ Config::set_admin_page_capability( 'manage_options' );
+ }
+
/**
* @before
*/
diff --git a/tests/wpunit/Log_Test.php b/tests/wpunit/Log_Test.php
index 374c7e1..c29abc2 100644
--- a/tests/wpunit/Log_Test.php
+++ b/tests/wpunit/Log_Test.php
@@ -94,4 +94,48 @@ public function it_should_get_table_interface(): void {
$log = $this->get_log_instance();
$this->assertInstanceOf( AS_Logs::class, $log->get_table_interface() );
}
+
+ /**
+ * @test
+ */
+ public function it_should_have_all_type_constants_in_valid_types(): void {
+ $expected = [
+ Log::TYPE_CREATED,
+ Log::TYPE_STARTED,
+ Log::TYPE_FINISHED,
+ Log::TYPE_FAILED,
+ Log::TYPE_RESCHEDULED,
+ Log::TYPE_CANCELLED,
+ Log::TYPE_RETRYING,
+ ];
+
+ $this->assertEquals( $expected, Log::VALID_TYPES );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_convert_to_array(): void {
+ $log = $this->get_log_instance();
+ $date = new \DateTime( '2024-01-01 12:00:00' );
+
+ $log->set_id( 999 );
+ $log->set_task_id( 123 );
+ $log->set_action_id( 456 );
+ $log->set_date( $date );
+ $log->set_level( LogLevel::ERROR );
+ $log->set_type( Log::TYPE_FAILED );
+ $log->set_entry( 'Test error message' );
+
+ $array = $log->to_array();
+
+ $this->assertIsArray( $array );
+ $this->assertEquals( 999, $array['id'] );
+ $this->assertEquals( 123, $array['task_id'] );
+ $this->assertEquals( 456, $array['action_id'] );
+ $this->assertEquals( $date, $array['date'] );
+ $this->assertEquals( LogLevel::ERROR, $array['level'] );
+ $this->assertEquals( Log::TYPE_FAILED, $array['type'] );
+ $this->assertEquals( 'Test error message', $array['entry'] );
+ }
}
diff --git a/tests/wpunit/Provider_Test.php b/tests/wpunit/Provider_Test.php
index e40cf35..de89268 100644
--- a/tests/wpunit/Provider_Test.php
+++ b/tests/wpunit/Provider_Test.php
@@ -5,8 +5,15 @@
namespace StellarWP\Shepherd;
use lucatume\WPBrowser\TestCase\WPTestCase;
+use StellarWP\Shepherd\Tables\Task_Logs;
+use StellarWP\Shepherd\Tables\Tasks;
+use StellarWP\Shepherd\Tests\Traits\With_Uopz;
+use StellarWP\Shepherd\Tests\Tasks\Do_Action_Task;
+use StellarWP\DB\DB;
class Provider_Test extends WPTestCase {
+ use With_Uopz;
+
/**
* @test
*/
@@ -20,4 +27,170 @@ public function it_should_assert_that_the_provider_is_not_registered(): void {
public function it_should_evaluate_hook_prefix(): void {
$this->assertEquals( tests_shepherd_get_hook_prefix(), Config::get_hook_prefix() );
}
+
+ /**
+ * @test
+ */
+ public function it_should_register_action_deletion_hook(): void {
+ $provider = Config::get_container()->get( Provider::class );
+
+ $this->assertNotFalse( has_action( 'action_scheduler_deleted_action', [ $provider, 'delete_tasks_on_action_deletion' ] ) );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_delete_tasks_on_action_deletion_when_tasks_exist(): void {
+ $provider = Config::get_container()->get( Provider::class );
+ $shepherd = shepherd();
+
+ // Create one task to get a valid action ID.
+ $test_task = new Do_Action_Task();
+ $shepherd->dispatch( $test_task );
+ $task_id = $shepherd->get_last_scheduled_task_id();
+ $action_id = $this->get_task_action_id( $task_id );
+
+ // Create additional tasks with the same action_id directly in the database.
+ $additional_task_ids = [];
+ for ( $i = 0; $i < 2; $i++ ) {
+ DB::query(
+ DB::prepare(
+ 'INSERT INTO %i (action_id, class_hash, args_hash, data, current_try) VALUES (%d, %s, %s, %s, %d)',
+ Tasks::table_name(),
+ $action_id,
+ 'test_class_hash_' . $i,
+ 'test_args_hash_' . $i,
+ wp_json_encode( [] ),
+ 0
+ )
+ );
+ $additional_task_ids[] = $GLOBALS['wpdb']->insert_id;
+ }
+
+ $all_task_ids = array_merge( [ $task_id ], $additional_task_ids );
+
+ $tasks_before = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE action_id = %d',
+ Tasks::table_name(),
+ $action_id
+ )
+ );
+ $this->assertEquals( 3, $tasks_before );
+
+ $provider->delete_tasks_on_action_deletion( $action_id );
+
+ $tasks_after = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE action_id = %d',
+ Tasks::table_name(),
+ $action_id
+ )
+ );
+ $this->assertEquals( 0, $tasks_after );
+
+ $logs_after = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE task_id IN (%s)',
+ Task_Logs::table_name(),
+ implode( ',', $all_task_ids )
+ )
+ );
+ $this->assertEquals( 0, $logs_after );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_not_delete_when_no_tasks_exist_for_action(): void {
+ $provider = Config::get_container()->get( Provider::class );
+ $action_id = 456;
+
+ $tasks_before = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE action_id = %d',
+ Tasks::table_name(),
+ $action_id
+ )
+ );
+ $this->assertEquals( 0, $tasks_before );
+
+ $provider->delete_tasks_on_action_deletion( $action_id );
+
+ $tasks_after = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE action_id = %d',
+ Tasks::table_name(),
+ $action_id
+ )
+ );
+ $this->assertEquals( 0, $tasks_after );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_sanitize_task_ids_before_deletion(): void {
+ $provider = Config::get_container()->get( Provider::class );
+ $shepherd = shepherd();
+
+ // Create one task to get a valid action ID.
+ $test_task = new Do_Action_Task();
+ $shepherd->dispatch( $test_task );
+ $task_id = $shepherd->get_last_scheduled_task_id();
+ $action_id = $this->get_task_action_id( $task_id );
+
+ // Create additional tasks with the same action_id directly in the database.
+ $additional_task_ids = [];
+ for ( $i = 0; $i < 2; $i++ ) {
+ DB::query(
+ DB::prepare(
+ 'INSERT INTO %i (action_id, class_hash, args_hash, data, current_try) VALUES (%d, %s, %s, %s, %d)',
+ Tasks::table_name(),
+ $action_id,
+ 'test_class_hash_' . $i,
+ 'test_args_hash_' . $i,
+ wp_json_encode( [] ),
+ 0
+ )
+ );
+ $additional_task_ids[] = $GLOBALS['wpdb']->insert_id;
+ }
+
+ $all_task_ids = array_merge( [ $task_id ], $additional_task_ids );
+
+ $provider->delete_tasks_on_action_deletion( $action_id );
+
+ $tasks_after = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE action_id = %d',
+ Tasks::table_name(),
+ $action_id
+ )
+ );
+ $this->assertEquals( 0, $tasks_after );
+
+ $logs_after = DB::get_var(
+ DB::prepare(
+ 'SELECT COUNT(*) FROM %i WHERE task_id IN (%s)',
+ Task_Logs::table_name(),
+ implode( ',', $all_task_ids )
+ )
+ );
+ $this->assertEquals( 0, $logs_after );
+ }
+
+ /**
+ * Helper method to get action_id for a task.
+ */
+ private function get_task_action_id( int $task_id ): int {
+ return (int) DB::get_var(
+ DB::prepare(
+ 'SELECT action_id FROM %i WHERE %i = %d',
+ Tasks::table_name(),
+ Tasks::uid_column(),
+ $task_id
+ )
+ );
+ }
}
diff --git a/tests/wpunit/Regulator_Test.php b/tests/wpunit/Regulator_Test.php
index 10d42fe..ad19a8e 100644
--- a/tests/wpunit/Regulator_Test.php
+++ b/tests/wpunit/Regulator_Test.php
@@ -5,8 +5,12 @@
namespace StellarWP\Shepherd;
use lucatume\WPBrowser\TestCase\WPTestCase;
+use StellarWP\Shepherd\Tasks\Herding;
+use StellarWP\Shepherd\Tests\Traits\With_Uopz;
class Regulator_Test extends WPTestCase {
+ use With_Uopz;
+
/**
* @test
*/
@@ -14,4 +18,36 @@ public function it_should_have_as_hook_registered(): void {
$regulator = Config::get_container()->get( Regulator::class );
$this->assertSame( 10, has_action( 'shepherd_' . Config::get_hook_prefix() . '_process_task', [ $regulator, 'process_task' ] ) );
}
+
+ /**
+ * @test
+ */
+ public function it_should_schedule_cleanup_task_on_init(): void {
+ $regulator = Config::get_container()->get( Regulator::class );
+
+ $herding_dispatched = false;
+
+ add_action( 'shepherd_' . Config::get_hook_prefix() . '_task_created', function( $task ) use ( &$herding_dispatched ) {
+ if ( $task instanceof Herding ) {
+ $herding_dispatched = true;
+ }
+ } );
+
+ $regulator->schedule_cleanup_task();
+
+ $this->assertTrue( $herding_dispatched );
+
+ // Also verify the task was scheduled with the correct delay by checking Action Scheduler
+ $last_task_id = shepherd()->get_last_scheduled_task_id();
+ $this->assertNotNull( $last_task_id );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_register_init_hook_for_cleanup_scheduling(): void {
+ $regulator = Config::get_container()->get( Regulator::class );
+
+ $this->assertSame( 20, has_action( 'init', [ $regulator, 'schedule_cleanup_task' ] ) );
+ }
}
diff --git a/tests/wpunit/Tables/AS_Actions_Test.php b/tests/wpunit/Tables/AS_Actions_Test.php
new file mode 100644
index 0000000..558853f
--- /dev/null
+++ b/tests/wpunit/Tables/AS_Actions_Test.php
@@ -0,0 +1,79 @@
+assertEquals( 'actionscheduler_actions', AS_Actions::table_name( false ) );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_have_correct_uid_column(): void {
+ $this->assertEquals( 'action_id', AS_Actions::uid_column() );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_return_expected_columns(): void {
+ $columns = AS_Actions::get_columns();
+
+ $this->assertArrayHasKey( 'action_id', $columns );
+ $this->assertArrayHasKey( 'status', $columns );
+
+ // Check action_id column configuration
+ $action_id_config = $columns['action_id'];
+ $this->assertEquals( AS_Actions::COLUMN_TYPE_BIGINT, $action_id_config['type'] );
+ $this->assertEquals( AS_Actions::PHP_TYPE_INT, $action_id_config['php_type'] );
+ $this->assertEquals( 20, $action_id_config['length'] );
+ $this->assertTrue( $action_id_config['unsigned'] );
+ $this->assertTrue( $action_id_config['auto_increment'] );
+ $this->assertFalse( $action_id_config['nullable'] );
+
+ // Check status column configuration
+ $status_config = $columns['status'];
+ $this->assertEquals( AS_Actions::COLUMN_TYPE_VARCHAR, $status_config['type'] );
+ $this->assertEquals( AS_Actions::PHP_TYPE_STRING, $status_config['php_type'] );
+ $this->assertEquals( 20, $status_config['length'] );
+ $this->assertFalse( $status_config['nullable'] );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_return_searchable_columns(): void {
+ $searchable = AS_Actions::get_searchable_columns();
+
+ $this->assertCount( 1, $searchable );
+ $this->assertContains( 'status', $searchable );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_return_table_name_with_correct_prefix(): void {
+ global $wpdb;
+
+ $expected = $wpdb->prefix . 'actionscheduler_actions';
+ $actual = AS_Actions::table_name();
+
+ $this->assertEquals( $expected, $actual );
+ }
+}
diff --git a/tests/wpunit/Tasks/Herding_Test.php b/tests/wpunit/Tasks/Herding_Test.php
new file mode 100644
index 0000000..62b4ac0
--- /dev/null
+++ b/tests/wpunit/Tasks/Herding_Test.php
@@ -0,0 +1,141 @@
+assertEquals( 'shepherd_tidy_', $herding->get_task_prefix() );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_delete_orphaned_tasks_and_logs() {
+ $herding = new Herding();
+
+ // Clear existing data
+ DB::query( DB::prepare( 'DELETE FROM %i', Tasks::table_name() ) );
+ DB::query( DB::prepare( 'DELETE FROM %i', Task_Logs::table_name() ) );
+
+ // Create orphaned tasks (tasks without corresponding actions in Action Scheduler)
+ $orphaned_task_ids = [];
+ for ( $i = 1; $i <= 3; $i++ ) {
+ DB::query(
+ DB::prepare(
+ 'INSERT INTO %i (action_id, class_hash, args_hash, data, current_try) VALUES (%d, %s, %s, %s, %d)',
+ Tasks::table_name(),
+ 999990 + $i, // Use high action IDs that won't exist in Action Scheduler
+ 'test_class_hash_' . $i,
+ 'test_args_hash_' . $i,
+ wp_json_encode( [] ),
+ 0
+ )
+ );
+ $orphaned_task_ids[] = $GLOBALS['wpdb']->insert_id;
+
+ // Create logs for these tasks
+ DB::query(
+ DB::prepare(
+ 'INSERT INTO %i (task_id, date, level, type, entry) VALUES (%d, %s, %s, %s, %s)',
+ Task_Logs::table_name(),
+ $GLOBALS['wpdb']->insert_id,
+ gmdate( 'Y-m-d H:i:s' ),
+ 'info',
+ 'test',
+ wp_json_encode( [ 'message' => 'Test log entry' ] )
+ )
+ );
+ }
+
+ // Verify tasks and logs exist before cleanup
+ $tasks_before = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i', Tasks::table_name() ) );
+ $logs_before = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i', Task_Logs::table_name() ) );
+ $this->assertEquals( 3, $tasks_before );
+ $this->assertEquals( 3, $logs_before );
+
+ $herding->process();
+
+ // Verify tasks and logs were deleted
+ $tasks_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i', Tasks::table_name() ) );
+ $logs_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i', Task_Logs::table_name() ) );
+ $this->assertEquals( 0, $tasks_after );
+ $this->assertEquals( 0, $logs_after );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_fire_action_hook_after_processing() {
+ $herding = new Herding();
+
+ // Clear existing data
+ DB::query( DB::prepare( 'DELETE FROM %i', Tasks::table_name() ) );
+ DB::query( DB::prepare( 'DELETE FROM %i', Task_Logs::table_name() ) );
+
+ // Track action hook calls
+ $hook_called = false;
+ $hook_task = null;
+
+ add_action( 'shepherd_' . tests_shepherd_get_hook_prefix() . '_herding_processed', function( $task ) use ( &$hook_called, &$hook_task ) {
+ $hook_called = true;
+ $hook_task = $task;
+ } );
+
+ $herding->process();
+
+ $this->assertTrue( $hook_called );
+ $this->assertSame( $herding, $hook_task );
+ }
+
+ /**
+ * @test
+ */
+ public function it_should_sanitize_task_ids_before_deletion() {
+ $herding = new Herding();
+
+ // Clear existing data
+ DB::query( DB::prepare( 'DELETE FROM %i', Tasks::table_name() ) );
+ DB::query( DB::prepare( 'DELETE FROM %i', Task_Logs::table_name() ) );
+
+ // Create orphaned tasks with high action IDs that won't exist in Action Scheduler
+ $orphaned_task_ids = [];
+ for ( $i = 1; $i <= 3; $i++ ) {
+ DB::query(
+ DB::prepare(
+ 'INSERT INTO %i (action_id, class_hash, args_hash, data, current_try) VALUES (%d, %s, %s, %s, %d)',
+ Tasks::table_name(),
+ 999990 + $i,
+ 'test_class_hash_' . $i,
+ 'test_args_hash_' . $i,
+ wp_json_encode( [] ),
+ 0
+ )
+ );
+ $orphaned_task_ids[] = $GLOBALS['wpdb']->insert_id;
+ }
+
+ // Verify tasks exist before cleanup
+ $tasks_before = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i', Tasks::table_name() ) );
+ $this->assertEquals( 3, $tasks_before );
+
+ $herding->process();
+
+ // Verify all tasks were deleted (testing sanitization worked)
+ $tasks_after = DB::get_var( DB::prepare( 'SELECT COUNT(*) FROM %i', Tasks::table_name() ) );
+ $this->assertEquals( 0, $tasks_after );
+ }
+}
diff --git a/webpack.config.js b/webpack.config.js
new file mode 100644
index 0000000..da0d4dc
--- /dev/null
+++ b/webpack.config.js
@@ -0,0 +1,22 @@
+/**
+ * The default configuration coming from the @wordpress/scripts package.
+ * Customized following the "Advanced Usage" section of the documentation:
+ * See: https://developer.wordpress.org/block-editor/reference-guides/packages/packages-scripts/#advanced-usage
+ */
+const defaultConfig = require( '@wordpress/scripts/config/webpack.config' );
+
+const entryPoints = {
+ main: __dirname + '/app/index.tsx',
+};
+
+module.exports = {
+ ...defaultConfig,
+ ...{
+ entry: (buildType) => {
+ const defaultEntryPoints = defaultConfig.entry( buildType );
+ return {
+ ...defaultEntryPoints, ...entryPoints,
+ };
+ },
+ },
+};