diff --git a/composer.json b/composer.json index cb90f0a..b24fcbf 100644 --- a/composer.json +++ b/composer.json @@ -21,9 +21,9 @@ "illuminate/contracts": "^10.0" }, "require-dev": { + "larastan/larastan": "^2.9", "laravel/pint": "^1.0", "nunomaduro/collision": "^7.9", - "nunomaduro/larastan": "^2.0.1", "orchestra/testbench": "^8.0", "phpstan/extension-installer": "^1.1", "phpstan/phpstan-deprecation-rules": "^1.0", @@ -42,7 +42,7 @@ }, "scripts": { "post-autoload-dump": "@php ./vendor/bin/testbench package:discover --ansi", - "analyse": "vendor/bin/phpstan analyse", + "analyse": "php -d memory_limit=2G vendor/bin/phpstan analyse", "test": "vendor/bin/phpunit", "test-coverage": "vendor/bin/phpunit --coverage", "format": "vendor/bin/pint" diff --git a/database/factories/RepetitionFactory.php b/database/factories/RepetitionFactory.php index 59cc4c8..11a80fd 100644 --- a/database/factories/RepetitionFactory.php +++ b/database/factories/RepetitionFactory.php @@ -8,6 +8,9 @@ use MohammedManssour\LaravelRecurringModels\Enums\RepetitionType; use MohammedManssour\LaravelRecurringModels\Models\Repetition; +/** + * @extends Factory + */ class RepetitionFactory extends Factory { protected $model = Repetition::class; @@ -18,7 +21,7 @@ public function definition() 'repeatable_id' => null, 'repeatable_type' => null, 'type' => RepetitionType::Simple, - 'start_at' => Carbon::createFromDate(fake()->dateTime())->startOfHour(), + 'start_at' => Carbon::make(fake()->dateTime())->startOfHour(), 'interval' => $this->toSeconds(fake()->numberBetween(1, 30)), 'year' => null, 'month' => null, @@ -33,7 +36,7 @@ public function definition() public function morphs(Model $model): static { return $this->state([ - 'repeatable_id' => $model->id, + 'repeatable_id' => $model->getKey(), 'repeatable_type' => $model->getMorphClass(), ]); } @@ -51,7 +54,7 @@ public function complex(string $year = '*', string $month = '*', string $day = ' ]); } - public function interval($days = null): static + public function interval(?int $days = null): static { return $this->state([ 'interval' => $this->toSeconds($days ?? fake()->numberBetween(1, 30)), diff --git a/database/migrations/1682348400_create_recurring_models_table.php b/database/migrations/1682348400_create_recurring_models_table.php index 7fd1782..754afef 100644 --- a/database/migrations/1682348400_create_recurring_models_table.php +++ b/database/migrations/1682348400_create_recurring_models_table.php @@ -6,7 +6,7 @@ return new class extends Migration { - public function up() + public function up(): void { if (Schema::hasTable('repetitions')) { return; @@ -28,7 +28,7 @@ public function up() }); } - public function down() + public function down(): void { Schema::dropIfExists('repetitions'); } diff --git a/database/migrations/1692297663_add_tz_offset_to_repetitions_table.php b/database/migrations/1692297663_add_tz_offset_to_repetitions_table.php index 80103d5..a8810eb 100644 --- a/database/migrations/1692297663_add_tz_offset_to_repetitions_table.php +++ b/database/migrations/1692297663_add_tz_offset_to_repetitions_table.php @@ -6,14 +6,14 @@ return new class extends Migration { - public function up() + public function up(): void { Schema::table('repetitions', function (Blueprint $table) { $table->integer('tz_offset')->default(0)->after('start_at'); }); } - public function down() + public function down(): void { Schema::table('repetitions', function (Blueprint $table) { $table->dropColumn('tz_offset'); diff --git a/database/migrations/1692434186_adds_week_of_month_to_repetitions_table.php b/database/migrations/1692434186_adds_week_of_month_to_repetitions_table.php index 6e1e6bb..34a053d 100644 --- a/database/migrations/1692434186_adds_week_of_month_to_repetitions_table.php +++ b/database/migrations/1692434186_adds_week_of_month_to_repetitions_table.php @@ -6,14 +6,14 @@ return new class extends Migration { - public function up() + public function up(): void { Schema::table('repetitions', function (Blueprint $table) { $table->string('week_of_month')->nullable()->after('week'); }); } - public function down() + public function down(): void { Schema::table('repetitions', function (Blueprint $table) { $table->dropColumn('week_of_month'); diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a91953b..9a013ba 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -2,10 +2,9 @@ includes: - phpstan-baseline.neon parameters: - level: 4 + level: 6 paths: - src - - config - database tmpDir: build/phpstan checkOctaneCompatibility: true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 0f5b579..bdcd3f3 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -7,13 +7,13 @@ - + diff --git a/src/Concerns/Repeatable.php b/src/Concerns/Repeatable.php index df6db19..2fdb3f0 100644 --- a/src/Concerns/Repeatable.php +++ b/src/Concerns/Repeatable.php @@ -8,15 +8,20 @@ use MohammedManssour\LaravelRecurringModels\Enums\RepetitionType; use MohammedManssour\LaravelRecurringModels\Models\Repetition; use MohammedManssour\LaravelRecurringModels\Support\Repeat; +use MohammedManssour\LaravelRecurringModels\Support\RepeatCollection; /** - * @property-read \Illuminate\Support\Collection $repeats + * @property-read RepeatCollection $repetitions + * + * @method Builder whereOccurresOn(Carbon $date) + * @method Builder whereOccurresBetween(Carbon $start, Carbon $end) */ trait Repeatable { /*----------------------------------------------------- * Relations -----------------------------------------------------*/ + /** @return MorphMany */ public function repetitions(): MorphMany { return $this->morphMany(Repetition::class, 'repeatable'); @@ -28,7 +33,7 @@ public function repetitions(): MorphMany /** * define the base date that we would use to calculate repetition start_at */ - public function repetitionBaseDate(RepetitionType $type = null): Carbon + public function repetitionBaseDate(?RepetitionType $type = null): Carbon { return $this->created_at; } @@ -44,6 +49,7 @@ public function repeat(): Repeat /*----------------------------------------------------- * Scopes -----------------------------------------------------*/ + /** @param Builder $query */ public function scopeWhereOccurresOn(Builder $query, Carbon $date): Builder { return $query->whereHas( @@ -52,6 +58,7 @@ public function scopeWhereOccurresOn(Builder $query, Carbon $date): Builder ); } + /** @param Builder $query */ public function scopeWhereOccurresBetween(Builder $query, Carbon $start, Carbon $end): Builder { return $query->whereHas( diff --git a/src/Contracts/Repeatable.php b/src/Contracts/Repeatable.php index 013e68e..f17e733 100644 --- a/src/Contracts/Repeatable.php +++ b/src/Contracts/Repeatable.php @@ -5,13 +5,21 @@ use Carbon\CarbonInterface as Carbon; use Illuminate\Database\Eloquent\Relations\MorphMany; use MohammedManssour\LaravelRecurringModels\Enums\RepetitionType; +use MohammedManssour\LaravelRecurringModels\Models\Repetition; use MohammedManssour\LaravelRecurringModels\Support\Repeat; +use MohammedManssour\LaravelRecurringModels\Support\RepeatCollection; +/** + * @property-read RepeatCollection $repetitions + * + * @method MorphMany repetitions() + */ interface Repeatable { + /** @return MorphMany */ public function repetitions(): MorphMany; - public function repetitionBaseDate(RepetitionType $type = null): Carbon; + public function repetitionBaseDate(?RepetitionType $type = null): Carbon; public function repeat(): Repeat; } diff --git a/src/Exceptions/DriverNotSupportedException.php b/src/Exceptions/DriverNotSupportedException.php index 3307592..244b7a1 100644 --- a/src/Exceptions/DriverNotSupportedException.php +++ b/src/Exceptions/DriverNotSupportedException.php @@ -6,7 +6,7 @@ class DriverNotSupportedException extends \Exception { - public function __construct(string $driver, int $code = 0, Throwable $previous = null) + public function __construct(string $driver, int $code = 0, ?Throwable $previous = null) { parent::__construct("Database driver \"{$driver}\" is not supported.", $code, $previous); } diff --git a/src/Exceptions/RepetitionEndsAfterNotAvailableException.php b/src/Exceptions/RepetitionEndsAfterNotAvailableException.php index 012e6e3..6780921 100644 --- a/src/Exceptions/RepetitionEndsAfterNotAvailableException.php +++ b/src/Exceptions/RepetitionEndsAfterNotAvailableException.php @@ -6,7 +6,7 @@ class RepetitionEndsAfterNotAvailableException extends \Exception { - public function __construct(int $code = 0, Throwable $previous = null) + public function __construct(int $code = 0, ?Throwable $previous = null) { parent::__construct('endsAfter method is not available for complex repetitions. Please use endsAt method instead to explicitly set end date.', $code, $previous); } diff --git a/src/Models/Repetition.php b/src/Models/Repetition.php index 3af8d9e..a57d384 100644 --- a/src/Models/Repetition.php +++ b/src/Models/Repetition.php @@ -4,6 +4,7 @@ use Carbon\CarbonInterface as Carbon; use Carbon\CarbonPeriod; +use Carbon\Exceptions\UnreachableException; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -21,6 +22,19 @@ * @property \Carbon\Carbon $start_at * @property ?int $interval * @property ?\Carbon\Carbon $end_at + * @property int $tz_offset + * @property string $year + * @property string $month + * @property string $day + * @property string $week + * @property string $week_of_month + * @property string $weekday + * + * @method Builder whereActiveForTheDate(Carbon $date) + * @method Builder whereOccurresOn(Carbon $date) + * @method Builder whereOccurresBetween(Carbon $start, Carbon $end) + * @method Builder whereHasSimpleRecurringOn(Carbon $date) + * @method Builder whereHasComplexRecurringOn(Carbon $date) */ class Repetition extends Model { @@ -39,7 +53,7 @@ class Repetition extends Model 'tz_offset' => 'integer', ]; - protected static function newFactory() + protected static function newFactory(): RepetitionFactory { return RepetitionFactory::new(); } @@ -49,9 +63,58 @@ public function newCollection(array $models = []): RepeatCollection return new RepeatCollection($models); } + /** + * Returns CarbonPeriod of Repetition. + */ + public function toPeriod(): CarbonPeriod + { + /** @var CarbonPeriod $period */ + $period = CarbonPeriod::since($this->start_at, true); + + if ($this->end_at) { + $period->until($this->end_at, true); + } + + if ($this->type === RepetitionType::Simple) { + $period->seconds($this->interval); + } else { + $period->addFilter(function (Carbon $date) { + $date = $date->clone()->addSeconds($this->tz_offset); + + return ($this->year === '*' || (int) $this->year === $date->year) + && ($this->month === '*' || (int) $this->month === $date->month) + && ($this->day === '*' || (int) $this->day === $date->day) + && ($this->week === '*' || (int) $this->week === $date->week) + && ($this->week_of_month === '*' || (int) $this->week_of_month === $date->weekOfMonth) + && ($this->weekday === '*' || (int) $this->weekday === $date->dayOfWeek); + }); + } + + return $period; + } + + public function nextOccurrence(Carbon $after): ?Carbon + { + if ($this->end_at?->lessThanOrEqualTo($after)) { + return null; + } + + $period = $this->toPeriod(); + $period->prependFilter(fn (Carbon $date) => $date->greaterThan($after)); + + try { + return $period->current(); + } catch (UnreachableException) { + return null; + } + } + /*----------------------------------------------------- * Relations -----------------------------------------------------*/ + /** + * @return MorphTo + */ public function repeatable(): MorphTo { return $this->morphTo('repeatable'); @@ -60,6 +123,10 @@ public function repeatable(): MorphTo /*----------------------------------------------------- * scopes -----------------------------------------------------*/ + /** + * @param Builder $query + * @return Builder + */ public function scopeWhereActiveForTheDate(Builder $query, Carbon $date): Builder { return $query->where('start_at', '<=', $date->toDateTimeString()) @@ -69,16 +136,25 @@ public function scopeWhereActiveForTheDate(Builder $query, Carbon $date): Builde ); } + /** + * @param Builder $query + * @return Builder + */ public function scopeWhereOccurresOn(Builder $query, Carbon $date): Builder { return $query ->WhereActiveForTheDate($date) ->where( - fn ($query) => $query->whereHasSimpleRecurringOn($date) - ->orWhere(fn ($query) => $query->whereHasComplexRecurringOn($date)) + fn (Builder $query) => $query + ->whereHasSimpleRecurringOn($date) + ->orWhere(fn (Builder $query) => $query->whereHasComplexRecurringOn($date)) ); } + /** + * @param Builder $query + * @return Builder + */ public function scopeWhereOccurresBetween(Builder $query, Carbon $start, Carbon $end): Builder { $dates = CarbonPeriod::create( @@ -88,25 +164,29 @@ public function scopeWhereOccurresBetween(Builder $query, Carbon $start, Carbon $query->where(function (Builder $query) use ($dates) { foreach ($dates as $date) { - $query->orWhere(fn ($query) => $query->whereOccurresOn($date)); + $query->orWhere(fn (Builder $query) => $query->whereOccurresOn($date)); } }); return $query; } + /** + * @param Builder $query + * @return Builder + */ public function scopeWhereHasSimpleRecurringOn(Builder $query, Carbon $date): Builder { $secondsInDay = 86399; - $timestamp = $date->clone()->utc()->timestamp; + $timestamp = $date->clone()->utc()->endOfDay()->timestamp; + $driver = $query->getConnection()->getConfig('driver'); // @phpstan-ignore-line $query ->where('type', RepetitionType::Simple); - $driver = $query->getConnection()->getConfig('driver'); match ($driver) { 'mysql' => $query->whereRaw('(? - UNIX_TIMESTAMP(`start_at`)) % `interval` BETWEEN 0 AND ?', [$timestamp, $secondsInDay]), - 'sqlite' => $query->whereRaw('(? - unixepoch(`start_at`)) % `interval` BETWEEN 0 AND ?', [$timestamp, $secondsInDay]), + 'sqlite' => $query->whereRaw('(? - strftime("%s", `start_at`)) % `interval` BETWEEN 0 AND ?', [$timestamp, $secondsInDay]), 'pgsql' => $query->whereRaw("MOD((? - DATE_PART('EPOCH', start_at))::INTEGER, interval) BETWEEN 0 AND ?", [$timestamp, $secondsInDay]), default => throw new DriverNotSupportedException($driver), }; @@ -114,12 +194,17 @@ public function scopeWhereHasSimpleRecurringOn(Builder $query, Carbon $date): Bu return $query; } - public function scopeWhereHasComplexRecurringOn(Builder $query, Carbon $date) + /** + * @param Builder $query + * @return Builder + */ + public function scopeWhereHasComplexRecurringOn(Builder $query, Carbon $date): Builder { - $timestamp = $date->timestamp; - $driver = $query->getConnection()->getConfig('driver'); + $timestamp = $date->clone()->utc()->endOfDay()->timestamp; + $driver = $query->getConnection()->getConfig('driver'); // @phpstan-ignore-line $query->where('type', RepetitionType::Complex); + if ($driver == 'mysql') { return $query->whereRaw("(`year` = '*' or `year` = YEAR(FROM_UNIXTIME(? + `tz_offset`)))", [$timestamp]) ->whereRaw("(`month` = '*' or `month` = MONTH(FROM_UNIXTIME(? + `tz_offset`)))", [$timestamp]) diff --git a/src/Support/PendingRepeats/PendingComplexRepeat.php b/src/Support/PendingRepeats/PendingComplexRepeat.php index 09f48b2..ccd7d71 100644 --- a/src/Support/PendingRepeats/PendingComplexRepeat.php +++ b/src/Support/PendingRepeats/PendingComplexRepeat.php @@ -2,7 +2,6 @@ namespace MohammedManssour\LaravelRecurringModels\Support\PendingRepeats; -use MohammedManssour\LaravelRecurringModels\Contracts\Repeatable; use MohammedManssour\LaravelRecurringModels\Enums\RepetitionType; use MohammedManssour\LaravelRecurringModels\Exceptions\RepetitionEndsAfterNotAvailableException; @@ -10,13 +9,6 @@ class PendingComplexRepeat extends PendingRepeat { private array $rule; - public function __construct( - Repeatable $model - ) { - parent::__construct($model); - $this->start_at = $this->model->repetitionBaseDate(RepetitionType::Complex)->toImmutable(); - } - public function rule(string $year = '*', string $month = '*', string $day = '*', string $week = '*', string $weekOfMonth = '*', string $weekday = '*'): static { $this->rule = [ @@ -52,4 +44,9 @@ public function rules(): array 'end_at' => $this->end_at, ]]; } + + public function type(): RepetitionType + { + return RepetitionType::Complex; + } } diff --git a/src/Support/PendingRepeats/PendingEveryNDaysRepeat.php b/src/Support/PendingRepeats/PendingEveryNDaysRepeat.php index b22feac..92e0b30 100644 --- a/src/Support/PendingRepeats/PendingEveryNDaysRepeat.php +++ b/src/Support/PendingRepeats/PendingEveryNDaysRepeat.php @@ -10,15 +10,13 @@ class PendingEveryNDaysRepeat extends PendingRepeat public function __construct(Repeatable $model, int $days) { parent::__construct($model); + $this->interval = $days * 86400; - $this->start_at = $this->model->repetitionBaseDate(RepetitionType::Simple)->toImmutable()->addSeconds($this->interval); } public function endsAfter(int $times): static { - $this->end_at = $this->start_at->addSeconds($times * $this->interval); - - return $this; + return $this->endsAt($this->start_at->addSeconds($times * $this->interval)); } public function rules(): array @@ -32,4 +30,9 @@ public function rules(): array ], ]; } + + public function type(): RepetitionType + { + return RepetitionType::Simple; + } } diff --git a/src/Support/PendingRepeats/PendingEveryWeekRepeat.php b/src/Support/PendingRepeats/PendingEveryWeekRepeat.php index cac921b..0eb0d5c 100644 --- a/src/Support/PendingRepeats/PendingEveryWeekRepeat.php +++ b/src/Support/PendingRepeats/PendingEveryWeekRepeat.php @@ -12,23 +12,25 @@ class PendingEveryWeekRepeat extends PendingRepeat /** * days * - * @var Collection + * @var Collection */ private Collection $days; + /** @var Collection> */ private Collection $rules; public function __construct(Repeatable $model) { parent::__construct($model); + $this->days = collect([]); $this->rules = collect([]); } /** - * repeat every week on specific days + * Repeat every week on specific days * - * $days acceptable = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] + * @param string[] $days acceptable = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] */ public function on(array $days): static { @@ -44,6 +46,9 @@ public function endsAfter(int $times): static throw new RepetitionEndsAfterNotAvailableException(); } + /** + * @return array + */ public function rules(): array { if ($this->rules->isEmpty()) { @@ -58,7 +63,7 @@ private function makeRules(): void if ($this->days->isEmpty()) { $this->rules->push( $this->getRule( - strtolower($this->model->repetitionBaseDate(RepetitionType::Complex)->format('l')) + strtolower($this->start_at->format('l')) ) ); @@ -68,6 +73,9 @@ private function makeRules(): void $this->rules = $this->days->map(fn ($day) => $this->getRule($day)); } + /** + * @return array + */ private function getRule(string $day): array { $complexPattern = (new PendingComplexRepeat($this->model)) @@ -87,7 +95,15 @@ public function __destruct() $this->rules(); } - private function weekdays() + public function type(): RepetitionType + { + return RepetitionType::Complex; + } + + /** + * @return string[] + */ + private function weekdays(): array { return ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; } diff --git a/src/Support/PendingRepeats/PendingRepeat.php b/src/Support/PendingRepeats/PendingRepeat.php index 6b7cc35..c864c68 100644 --- a/src/Support/PendingRepeats/PendingRepeat.php +++ b/src/Support/PendingRepeats/PendingRepeat.php @@ -4,6 +4,7 @@ use Carbon\CarbonInterface; use MohammedManssour\LaravelRecurringModels\Contracts\Repeatable; +use MohammedManssour\LaravelRecurringModels\Enums\RepetitionType; use MohammedManssour\LaravelRecurringModels\Support\RepeatCollection; abstract class PendingRepeat @@ -19,6 +20,7 @@ abstract class PendingRepeat public function __construct(Repeatable $model) { $this->model = $model; + $this->start_at = $this->model->repetitionBaseDate($this->type())->toImmutable(); } public function endsAt(CarbonInterface $end_at): static @@ -35,19 +37,22 @@ public function startsAt(CarbonInterface $start_at): static return $this; } - public function repeatitions(): RepeatCollection + public function repetitions(): RepeatCollection { - return $this->model->repetitions()->makeMany($this->rules()); + /** @var RepeatCollection $collection */ + $collection = $this->model->repetitions()->makeMany($this->rules()); + + return $collection; } - public function save() + public function save(): void { - return $this->repeatitions()->save(); + $this->repetitions()->save(); } public function __destruct() { - return $this->save(); + $this->save(); } /** @@ -59,4 +64,6 @@ abstract public function endsAfter(int $times): static; * translates repeat requirements to repeat settings array */ abstract public function rules(): array; + + abstract public function type(): RepetitionType; } diff --git a/src/Support/RepeatCollection.php b/src/Support/RepeatCollection.php index 44a4967..fd99547 100644 --- a/src/Support/RepeatCollection.php +++ b/src/Support/RepeatCollection.php @@ -2,14 +2,32 @@ namespace MohammedManssour\LaravelRecurringModels\Support; +use Carbon\CarbonInterface as Carbon; use Illuminate\Database\Eloquent\Collection; use MohammedManssour\LaravelRecurringModels\Models\Repetition; +/** + * @extends Collection + */ class RepeatCollection extends Collection { - public function save() + public function save(): void { - $this->transform(fn ($item) => $item->getAttributes()); - Repetition::insert($this->items); + Repetition::insert($this->map(function (Repetition $item) { + $item->updateTimestamps(); + + return $item->getAttributes(); + })->toArray()); + } + + public function nextOccurrence(Carbon $after): ?Carbon + { + $occurrences = $this->map(fn (Repetition $item) => $item->nextOccurrence($after))->filter()->toArray(); + + if (empty($occurrences)) { + return null; + } + + return min($occurrences); } } diff --git a/tests/RepeatTest.php b/tests/RepeatTest.php index 33d2e55..610e574 100644 --- a/tests/RepeatTest.php +++ b/tests/RepeatTest.php @@ -18,7 +18,7 @@ public function it_creates_daily_repetition_for_task_with_no_end() ->everyNDays(5); $this->assertDatabaseHas('repetitions', [ - 'start_at' => $this->task()->repetitionBaseDate()->addDays(5), + 'start_at' => $this->task()->repetitionBaseDate(), 'interval' => 5 * 86400, 'end_at' => null, ]); @@ -33,7 +33,7 @@ public function it_creates_daily_repetition_for_task_that_ends_in_a_specific_dat ->endsAt(Carbon::make('2023-04-25')); $this->assertDatabaseHas('repetitions', [ - 'start_at' => $this->task()->repetitionBaseDate()->addDays(2), + 'start_at' => $this->task()->repetitionBaseDate(), 'interval' => 2 * 86400, 'end_at' => '2023-04-25 00:00:00', ]); @@ -48,9 +48,9 @@ public function it_can_create_daily_repetition_for_task_that_ends_after_n_times( ->endsAfter(5); $this->assertDatabaseHas('repetitions', [ - 'start_at' => $this->task()->repetitionBaseDate()->addDays(3), + 'start_at' => $this->task()->repetitionBaseDate(), 'interval' => 3 * 86400, - 'end_at' => $this->task()->repetitionBaseDate()->addDays(18), // because five times means 5 repetitions + 'end_at' => $this->task()->repetitionBaseDate()->addDays(15), // because five times means 5 repetitions ]); } @@ -62,7 +62,7 @@ public function it_create_daily_repetition() ->daily(); $this->assertDatabaseHas('repetitions', [ - 'start_at' => $this->task()->repetitionBaseDate()->addDays(1), + 'start_at' => $this->task()->repetitionBaseDate(), 'interval' => 86400, 'end_at' => null, ]); @@ -81,7 +81,7 @@ public function it_creates_daily_repetition_with_timezone() ->daily(); $this->assertDatabaseHas('repetitions', [ - 'start_at' => $this->task()->repetitionBaseDate()->utc()->addDays(1), + 'start_at' => $this->task()->repetitionBaseDate()->utc(), 'tz_offset' => 4 * 3600, 'interval' => 86400, 'end_at' => null, diff --git a/tests/RepetitionTest.php b/tests/RepetitionTest.php index 5ee27b3..d2fe0c1 100644 --- a/tests/RepetitionTest.php +++ b/tests/RepetitionTest.php @@ -1,5 +1,6 @@ assertNull(Repetition::whereOccurresOn(Carbon::make('2023-05-05'))->first()); $this->assertNull(Repetition::whereOccurresOn(Carbon::make('2023-05-19'))->first()); } + + /** @test */ + public function it_returns_correct_period() + { + // repeat start at 2023-04-15 00:00:00 + $repetition = $this->repetition($this->task(), '2023-04-30'); + $start = CarbonImmutable::make('2023-04-15'); + + $period = $repetition->toPeriod(); + + foreach ($period as $i => $p) { + $this->assertEquals($start->addDays($i * 5), $p); + } + } + + /** @test */ + public function it_returns_correct_next_simple_repetition() + { + // repeat start at 2023-04-15 00:00:00 + $repetition = $this->repetition($this->task(), '2023-04-30'); + + $this->assertEquals(Carbon::make('2023-04-20'), $repetition->nextOccurrence(Carbon::make('2023-04-15'))); + $this->assertEquals(Carbon::make('2023-04-25'), $repetition->nextOccurrence(Carbon::make('2023-04-22'))); + $this->assertEquals(null, $repetition->nextOccurrence(Carbon::make('2023-04-30'))); + } + + /** @test */ + public function it_returns_correct_next_complex_repetition() + { + Carbon::setTestNow( + Carbon::make('2023-04-20') + ); + + // repeats on second Friday of the month + $repetition = Repetition::factory() + ->morphs($this->task()) + ->complex(weekOfMonth: 2, weekday: Carbon::FRIDAY) + ->starts($this->task()->repetitionBaseDate()) + ->create(); + + $this->assertEquals(Carbon::make('2023-05-12'), $repetition->nextOccurrence(Carbon::make('2023-04-20'))); + $this->assertEquals(Carbon::make('2023-06-09'), $repetition->nextOccurrence(Carbon::make('2023-05-12'))); + } } diff --git a/tests/Stubs/Models/Task.php b/tests/Stubs/Models/Task.php index a3df5fd..80a5f44 100644 --- a/tests/Stubs/Models/Task.php +++ b/tests/Stubs/Models/Task.php @@ -19,7 +19,7 @@ class Task extends Model implements RepeatableContract /** * define the base date that we would use to calculate repetition start_at */ - public function repetitionBaseDate(RepetitionType $type = null): Carbon + public function repetitionBaseDate(?RepetitionType $type = null): Carbon { return now(); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 9370013..9931883 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -40,6 +40,18 @@ public function getEnvironmentSetUp($app) $app['config']->set('database.default', 'testing'); + $migrations = [ + __DIR__.'/../database/migrations/1682348400_create_recurring_models_table.php', + __DIR__.'/../database/migrations/1692297663_add_tz_offset_to_repetitions_table.php', + __DIR__.'/../database/migrations/1692434186_adds_week_of_month_to_repetitions_table.php', + __DIR__.'/Stubs/Migrations/2023_04_18_000000_create_tasks_table.php', + ]; + + foreach ($migrations as $migrationPath) { + $migration = include $migrationPath; + $migration->up(); + } + // $app['config']->set('database.connections.mysql', [ // 'driver' => 'mysql', // 'host' => env('MYSQL_DB_HOST', '127.0.0.1'),