diff --git a/src/V2/WorkflowStub.php b/src/V2/WorkflowStub.php index e8af8ffc..8d3317d4 100644 --- a/src/V2/WorkflowStub.php +++ b/src/V2/WorkflowStub.php @@ -549,12 +549,16 @@ public static function runReadyTasks(int $limit = 100): int $processed = 0; while ($processed < $limit) { + $query = self::taskQuery(); + $dueAt = $query->getModel() + ->fromDateTime(now()); + /** @var WorkflowTask|null $task */ - $task = self::taskQuery() + $task = $query ->where('status', TaskStatus::Ready->value) - ->where(static function ($query): void { + ->where(static function ($query) use ($dueAt): void { $query->whereNull('available_at') - ->orWhere('available_at', '<=', now()); + ->orWhere('available_at', '<=', $dueAt); }) ->orderBy('available_at') ->orderBy('created_at') diff --git a/tests/Feature/V2/V2WorkflowStubFakeTest.php b/tests/Feature/V2/V2WorkflowStubFakeTest.php index b68a1317..49d35306 100644 --- a/tests/Feature/V2/V2WorkflowStubFakeTest.php +++ b/tests/Feature/V2/V2WorkflowStubFakeTest.php @@ -8,6 +8,7 @@ use Carbon\CarbonImmutable; use Illuminate\Support\Facades\Date; use LogicException; +use PHPUnit\Framework\Attributes\DataProvider; use RuntimeException; use Tests\Fixtures\V2\TestGreetingActivity; use Tests\Fixtures\V2\TestGreetingWorkflow; @@ -358,6 +359,34 @@ public function testFakeModeCanDrainDueTimerTasksAfterTimeTravel(): void ->all()); } + #[DataProvider('dueTaskTimestampCases')] + public function testFakeModePreservesTaskTimestampPrecision(string $startedAt, string $observedAt, bool $due): void + { + $this->travelTo(CarbonImmutable::parse($startedAt, 'UTC')); + WorkflowStub::fake(); + + $workflow = WorkflowStub::make(TestTimerWorkflow::class, 'fake-precise-timer'); + $workflow->start(1); + $this->assertFalse($workflow->refresh()->completed()); + + $this->travelTo(CarbonImmutable::parse($observedAt, 'UTC')); + + $this->assertSame($due ? 1 : 0, WorkflowStub::runReadyTasks()); + $this->assertSame($due, $workflow->refresh()->completed()); + $this->assertSame($due ? TimerStatus::Fired : TimerStatus::Pending, WorkflowTimer::query()->sole()->status); + } + + /** + * @return iterable + */ + public static function dueTaskTimestampCases(): iterable + { + yield 'whole-second equality' => ['2026-01-02 00:00:00.000000', '2026-01-02 00:00:01.000000', true]; + yield 'fractional equality' => ['2026-01-02 00:00:00.500000', '2026-01-02 00:00:01.500000', true]; + yield 'elapsed within the same second' => ['2026-01-02 00:00:00.100000', '2026-01-02 00:00:01.400000', true]; + yield 'future fractional deadline' => ['2026-01-02 00:00:00.500000', '2026-01-02 00:00:01.499999', false]; + } + public function testFakeModeRunsActivityAfterTimerWithImmutableApplicationDates(): void { Date::use(CarbonImmutable::class);