diff --git a/README.md b/README.md index 2d5a769..f740964 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ Why use our backup addon? - Choose exactly what you want to backup by configuring the backup [pipeline](docs/pipeline.md). - Easy to extend and customize, [just create a new pipes](docs/pipeline.md#creating-a-new-backup-pipe)! - Uses laravels [storage system](https://laravel.com/docs/11.x/filesystem) and thus supports external storage out of the box. -- Tested, the addon have over 85% test coverage. +- Tested, the addon have over 95% test coverage. ## Installation diff --git a/client/src/components/Actions.vue b/client/src/components/Actions.vue index a89623c..fef3e38 100644 --- a/client/src/components/Actions.vue +++ b/client/src/components/Actions.vue @@ -23,7 +23,6 @@ :status="file.status" :percent="file.progress * 100" :file="file" - @restore="restore(file)" /> @@ -38,7 +37,11 @@ export default { upload: UploadButton, "upload-status": UploadStatus, }, - + mounted(){ + this.$root.$on("uploaded", (file) => { + this.files = this.files.filter((item) => item.file.uniqueIdentifier !== file.uniqueIdentifier) + }); + }, data() { return { files: [], @@ -80,33 +83,6 @@ export default { this.loading = false; }); }, - restore(file) { - this.loading = true; - this.confirming = false; - file.status = "restoring"; - - this.$store.dispatch('backup-provider/setStatus','restore_in_progress'); - - this.$axios - .post(cp_url("api/backups/restore-from-path"), { - path: file.path, - }) - .then(({ data }) => { - this.$toast.info(__(data.message)); - }) - .catch((error) => { - let message = __("statamic-backup::backup.restore.failed"); - - if (error.response.data.message) { - message = error.response.data.message; - } - this.$toast.error(__(message)); - }) - .finally(() => { - this.loading = false; - file.status = "restored"; - }); - }, }, }; diff --git a/client/src/components/Listing.vue b/client/src/components/Listing.vue index 84b0644..6eff728 100644 --- a/client/src/components/Listing.vue +++ b/client/src/components/Listing.vue @@ -38,14 +38,14 @@ v-if="canDownload.isPermitted" :disabled="!canDownload.isPossible" :text="__('statamic-backup::backup.download.label')" - :redirect="download_url(backup.timestamp)" + :redirect="download_url(backup.id)" />
@@ -53,7 +53,7 @@ @@ -92,7 +92,8 @@ export default { mixins: [Listing], mounted() { - this.$on("onDestroyed", this.request); + this.$on("onDestroyed", this.request); // refetch backup list after destroy is completed + this.$root.$on("uploaded", this.request); // refetch backup list after upload is completed }, watch: { status(newStatus, oldStatus) { @@ -117,7 +118,7 @@ export default { columns: this.initialColumns, confirmingRestore: false, confirmingDestroy: false, - activeTimestamp: null, + activeId: null, activeName: null, }; }, @@ -139,22 +140,22 @@ export default { }, }, methods: { - download_url(timestamp) { - return cp_url("api/backups/download/" + timestamp); + download_url(id) { + return cp_url("api/backups/download/" + id); }, - restore_url(timestamp) { - return cp_url("api/backups/restore/" + timestamp); + restore_url(id) { + return cp_url("api/backups/restore/" + id); }, - destroy_url(timestamp) { - return cp_url("api/backups/" + timestamp); + destroy_url(id) { + return cp_url("api/backups/" + id); }, - initiateDestroy(timestamp, name) { - this.activeTimestamp = timestamp; + initiateDestroy(id, name) { + this.activeId = id; this.activeName = name; this.confirmingDestroy = true; }, - initiateRestore(timestamp, name) { - this.activeTimestamp = timestamp; + initiateRestore(id, name) { + this.activeId = id; this.activeName = name; this.confirmingRestore = true; }, @@ -165,7 +166,7 @@ export default { this.$store.dispatch('backup-provider/setStatus', 'restore_in_progress'); this.$axios - .post(this.restore_url(this.activeTimestamp)) + .post(this.restore_url(this.activeId)) .then(({ data }) => { this.$toast.info(__(data.message)); this.$emit("onRestored"); @@ -180,7 +181,7 @@ export default { }) .finally(() => { this.activeName = null; - this.activeTimestamp = null; + this.activeId = null; }); }, destroy() { @@ -188,7 +189,7 @@ export default { this.confirmingDestroy = false; this.$axios - .delete(this.destroy_url(this.activeTimestamp)) + .delete(this.destroy_url(this.activeId)) .then(({ data }) => { this.$toast.success(__(data.message)); this.$emit("onDestroyed"); @@ -203,7 +204,7 @@ export default { }) .finally(() => { this.activeName = null; - this.activeTimestamp = null; + this.activeId = null; }); }, }, diff --git a/client/src/components/Upload.vue b/client/src/components/Upload.vue index fa85065..2d839f8 100644 --- a/client/src/components/Upload.vue +++ b/client/src/components/Upload.vue @@ -87,11 +87,9 @@ export default { }); this.resumable.on("fileSuccess", (file, event) => { - const data = JSON.parse(event); - this.findFile(file).status = "success"; - this.findFile(file).path = data.file; + const data = JSON.parse(event);; this.$toast.success(data.message); - this.$emit("uploaded", data.file); + this.$root.$emit("uploaded", file); }); this.resumable.on("fileError", (file, event) => { diff --git a/client/src/components/UploadStatus.vue b/client/src/components/UploadStatus.vue index 9db568a..f8ef2b2 100644 --- a/client/src/components/UploadStatus.vue +++ b/client/src/components/UploadStatus.vue @@ -52,26 +52,6 @@ > - - - - - - - {{ __("statamic-backup::backup.restore.success") }} - - @@ -92,9 +72,6 @@ export default { pause() { this.$emit("pause", this.file); }, - restore() { - this.$emit("restore", this.file); - }, }, }; diff --git a/config/backup.php b/config/backup.php index 9a688a8..f89cc33 100644 --- a/config/backup.php +++ b/config/backup.php @@ -48,6 +48,13 @@ // 'time' => '03:00', ], + /** + * The backup name resolver + * + * the resolver handles generating and parsing backup names + */ + 'name_resolver' => \Itiden\Backup\GenericBackupNameResolver::class, + /** * The backup repository * diff --git a/docs/pages/.vitepress/config.js b/docs/pages/.vitepress/config.js index 1944b88..d5d3e56 100644 --- a/docs/pages/.vitepress/config.js +++ b/docs/pages/.vitepress/config.js @@ -32,6 +32,10 @@ export default defineConfig({ { text: "Metadata", link: "/metadata.md" }, ], }, + { + text: "Advanced", + items: [{ text: "Naming backups", link: "/naming-backups.md" }], + }, ], search: { diff --git a/docs/pages/naming-backups.md b/docs/pages/naming-backups.md new file mode 100644 index 0000000..0382821 --- /dev/null +++ b/docs/pages/naming-backups.md @@ -0,0 +1,63 @@ +# Naming backups + +If you want to customize how backups are named and "discovered", you can! + +The default naming scheme will be: + +``` +{app.name}-{timestamp}-{id}.zip +``` + +## Customizing + +You can customize the naming by providing your own `BackupNameResolver` implementation. + +This class is responsible for generating filenames and parsing files into identifiable information and required metadata in the form of `ResolvedBackupData`. +So when making your own implementation, you need to make sure that your generate and parseFilename methods work togheter or it will not work. + +Here is an example of a custom `BackupNameResolver` implementation: + +```php +use Carbon\CarbonImmutable; +use Itiden\Backup\Contracts\BackupNameResolver; +use Itiden\Backup\DataTransferObjects\ResolvedBackupData; + +final readonly class MyAppSpecificBackupNameResolver implements BackupNameResolver +{ + private const string Separator = '---'; + + // return a custom filename, the ".zip" extension will be added automatically if it is missing + public function generateFilename(CarbonImmutable $createdAt, string $id): string + { + $parts = [ + "some-testest-that-implies-something", + $createdAt->format('Y-m-d'), + $id, + ]; + + return implode(self::Separator, $parts); + } + + public function parseFilename(string $path): ?ResolvedBackupData + { + $filename = pathinfo($path, PATHINFO_FILENAME); + + $parts = explode(self::Separator, $filename); + + // if the filename cannot be parsed, return null + if (count($parts) !== 3) { + return null; + } + + [$name, $date, $identifier] = $parts; + + $createdAt = CarbonImmutable::createFromFormat('Y-m-d', $date); + + return new ResolvedBackupData( + createdAt: $createdAt, + id: $identifier, + name: $name + ); + } +} +``` diff --git a/routes/cp.php b/routes/cp.php index 12f786f..2f3bf53 100644 --- a/routes/cp.php +++ b/routes/cp.php @@ -4,7 +4,6 @@ use Itiden\Backup\Http\Controllers\Api\BackupController; use Itiden\Backup\Http\Controllers\Api\DestroyBackupController; use Itiden\Backup\Http\Controllers\Api\RestoreController; -use Itiden\Backup\Http\Controllers\Api\RestoreFromPathController; use Itiden\Backup\Http\Controllers\Api\StateController; use Itiden\Backup\Http\Controllers\Api\StoreBackupController; use Itiden\Backup\Http\Controllers\DownloadBackupController; @@ -43,19 +42,15 @@ ->middleware('can:create backups') ->name('store'); - Route::delete('/{timestamp}', DestroyBackupController::class) + Route::delete('/{id}', DestroyBackupController::class) ->middleware('can:delete backups') ->name('destroy'); - Route::get('/download/{timestamp}', DownloadBackupController::class) + Route::get('/download/{id}', DownloadBackupController::class) ->middleware('can:download backups') ->name('download'); - Route::post('/restore/{timestamp}', RestoreController::class) + Route::post('/restore/{id}', RestoreController::class) ->middleware('can:restore backups') ->name('restore'); - - Route::post('/restore-from-path', RestoreFromPathController::class) - ->middleware('can:restore backups') - ->name('restore-from-path'); }); diff --git a/src/Backuper.php b/src/Backuper.php index 31b786f..7cc2ed5 100644 --- a/src/Backuper.php +++ b/src/Backuper.php @@ -121,7 +121,7 @@ private function resolveMetaFromZip(Zipper $zip): Collection /** * Remove oldest backups when max backups is exceeded if it's present. */ - private function enforceMaxBackups(): void + public function enforceMaxBackups(): void { $maxBackups = config('backup.max_backups', false); if (!$maxBackups) { @@ -133,7 +133,7 @@ private function enforceMaxBackups(): void if ($backups->count() > $maxBackups) { $backups ->slice($maxBackups) - ->each(fn(BackupDto $backup): ?BackupDto => $this->repository->remove($backup->timestamp)); + ->each(fn(BackupDto $backup): ?BackupDto => $this->repository->remove($backup->id)); } } } diff --git a/src/Console/Commands/RestoreCommand.php b/src/Console/Commands/RestoreCommand.php index 25ea252..10834e3 100644 --- a/src/Console/Commands/RestoreCommand.php +++ b/src/Console/Commands/RestoreCommand.php @@ -22,9 +22,15 @@ final class RestoreCommand extends Command public function handle(BackupRepository $repo): void { - /* @var BackupDto $backup */ + /** @var BackupDto $backup */ $backup = match (true) { - (bool) $this->option('path') => BackupDto::fromAbsolutePath($this->option('path')), + (bool) $this->option('path') => new BackupDto( + 'not-that-important', + basename($this->option('path')), + now()->toImmutable(), + (string) filesize($this->option('path')), + $this->option('path'), + ), default => BackupDto::fromFile(select( label: 'Which backup do you want to restore to?', diff --git a/src/Contracts/BackupNameResolver.php b/src/Contracts/BackupNameResolver.php new file mode 100644 index 0000000..6f73d8e --- /dev/null +++ b/src/Contracts/BackupNameResolver.php @@ -0,0 +1,22 @@ +afterLast('-') - ->before('.zip') - ->toString(); - $bytes = Storage::disk(config('backup.destination.disk'))->size($path); + $values = app(BackupNameResolver::class)->parseFilename($path); - return new self( - name: File::name($path), - created_at: Carbon::createFromTimestamp($timestamp), - size: StatamicStr::fileSizeForHumans($bytes, 2), - path: $path, - timestamp: $timestamp, - ); - } + if (!$values) { + return null; + } - /** - * Create a new BackupDto from a absolute path - */ - public static function fromAbsolutePath(string $path): self - { - $timestamp = str(basename($path)) - ->afterLast('-') - ->before('.zip') - ->toString(); - $bytes = File::size($path); + $bytes = Storage::disk(config('backup.destination.disk'))->size($path); - return new self( - name: File::name($path), - created_at: Carbon::createFromTimestamp($timestamp), + return new static( + id: $values->id, + name: $values->name, + created_at: $values->createdAt, size: StatamicStr::fileSizeForHumans($bytes, 2), path: $path, - timestamp: $timestamp, ); } } diff --git a/src/DataTransferObjects/ResolvedBackupData.php b/src/DataTransferObjects/ResolvedBackupData.php new file mode 100644 index 0000000..ab88f9f --- /dev/null +++ b/src/DataTransferObjects/ResolvedBackupData.php @@ -0,0 +1,17 @@ +slug(), + $createdAt->timestamp, + $id, + ]); + } + + public function parseFilename(string $path): ?ResolvedBackupData + { + /** @var string */ + $filename = pathinfo($path, PATHINFO_FILENAME); + + $parts = explode(static::Separator, $filename); + + if (count($parts) !== 3) + return null; + + [$name, $createdAt, $id] = $parts; + + return new ResolvedBackupData( + createdAt: CarbonImmutable::createFromTimestamp($createdAt), + id: $id, + name: $name, + ); + } +} diff --git a/src/Http/Controllers/Api/DestroyBackupController.php b/src/Http/Controllers/Api/DestroyBackupController.php index e89046c..e12a503 100644 --- a/src/Http/Controllers/Api/DestroyBackupController.php +++ b/src/Http/Controllers/Api/DestroyBackupController.php @@ -10,9 +10,9 @@ final readonly class DestroyBackupController { - public function __invoke(string $timestamp, BackupRepository $repo): JsonResponse|RedirectResponse + public function __invoke(string $id, BackupRepository $repo): JsonResponse|RedirectResponse { - $backup = $repo->remove($timestamp); + $backup = $repo->remove($id); return response()->json(['message' => __('statamic-backup::backup.destroy.success', [ 'name' => $backup->name, diff --git a/src/Http/Controllers/Api/RestoreController.php b/src/Http/Controllers/Api/RestoreController.php index 2d45e1f..ce11bdc 100644 --- a/src/Http/Controllers/Api/RestoreController.php +++ b/src/Http/Controllers/Api/RestoreController.php @@ -8,15 +8,15 @@ use Illuminate\Contracts\Cache\Repository; use Illuminate\Http\JsonResponse; use Itiden\Backup\Exceptions\ActionAlreadyInProgress; -use Itiden\Backup\Jobs\RestoreFromTimestampJob; +use Itiden\Backup\Jobs\RestoreJob; use Itiden\Backup\StateManager; use Statamic\Contracts\Auth\User; final readonly class RestoreController { - public function __invoke(string $timestamp, StateManager $stateManager, #[Authenticated] User $user): JsonResponse + public function __invoke(string $id, StateManager $stateManager, #[Authenticated] User $user): JsonResponse { - $stateManager->dispatch(new RestoreFromTimestampJob($timestamp, $user)); + $stateManager->dispatch(new RestoreJob($id, $user)); return response()->json(['message' => __('statamic-backup::backup.restore.started')]); } diff --git a/src/Http/Controllers/Api/RestoreFromPathController.php b/src/Http/Controllers/Api/RestoreFromPathController.php deleted file mode 100644 index ee13b26..0000000 --- a/src/Http/Controllers/Api/RestoreFromPathController.php +++ /dev/null @@ -1,22 +0,0 @@ -dispatch(new RestoreFromPathJob(path: $request->validated('path'))); - - return response()->json(['message' => __('statamic-backup::backup.restore.started')]); - } -} diff --git a/src/Http/Controllers/DownloadBackupController.php b/src/Http/Controllers/DownloadBackupController.php index 833625c..7f24a70 100644 --- a/src/Http/Controllers/DownloadBackupController.php +++ b/src/Http/Controllers/DownloadBackupController.php @@ -13,9 +13,9 @@ /** * Handle the incoming request. */ - public function __invoke(string $timestamp, BackupRepository $repo): StreamedResponse + public function __invoke(string $id, BackupRepository $repo): StreamedResponse { - $backup = $repo->find($timestamp); + $backup = $repo->find($id); $backup ->getMetadata() diff --git a/src/Http/Controllers/UploadController.php b/src/Http/Controllers/UploadController.php index 4dd1ba9..4af4b53 100644 --- a/src/Http/Controllers/UploadController.php +++ b/src/Http/Controllers/UploadController.php @@ -6,6 +6,8 @@ use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Itiden\Backup\Backuper; +use Itiden\Backup\Contracts\Repositories\BackupRepository; use Itiden\Backup\DataTransferObjects\ChunkyTestDto; use Itiden\Backup\DataTransferObjects\ChunkyUploadDto; use Itiden\Backup\Http\Requests\ChunkyUploadRequest; @@ -13,9 +15,15 @@ final readonly class UploadController { - public function __invoke(ChunkyUploadRequest $request): JsonResponse + public function __invoke(ChunkyUploadRequest $request, BackupRepository $repo, Backuper $backuper): JsonResponse { - return Chunky::put(ChunkyUploadDto::fromRequest($request)); + return Chunky::put(ChunkyUploadDto::fromRequest($request), onCompleted: function (string $completeFile) use ( + $repo, + $backuper, + ): void { + $repo->add($completeFile); + $backuper->enforceMaxBackups(); + }); } public function test(Request $request): JsonResponse diff --git a/src/Http/Requests/RestoreFromPathRequest.php b/src/Http/Requests/RestoreFromPathRequest.php deleted file mode 100644 index 7d1400e..0000000 --- a/src/Http/Requests/RestoreFromPathRequest.php +++ /dev/null @@ -1,17 +0,0 @@ - 'required|string', - ]; - } -} diff --git a/src/Http/Resources/BackupResource.php b/src/Http/Resources/BackupResource.php index 46a8293..cd159f1 100644 --- a/src/Http/Resources/BackupResource.php +++ b/src/Http/Resources/BackupResource.php @@ -17,7 +17,7 @@ public function toArray($request): array 'name' => $this->name, 'created_at' => $this->created_at->format('Y-m-d H:i:s'), 'path' => $this->path, - 'timestamp' => $this->timestamp, + 'id' => $this->id, 'size' => $this->size, 'metadata' => new MetadataResource(resource: $this->getMetadata()), ]; diff --git a/src/Jobs/RestoreFromPathJob.php b/src/Jobs/RestoreFromPathJob.php deleted file mode 100644 index 9f90d16..0000000 --- a/src/Jobs/RestoreFromPathJob.php +++ /dev/null @@ -1,43 +0,0 @@ -restore(BackupDto::fromAbsolutePath($this->path)); - - $cache->forget(StateManager::JOB_QUEUED_KEY); - - File::delete($this->path); - } - - public function failed(): void - { - app(Repository::class)->forget(StateManager::JOB_QUEUED_KEY); - } -} diff --git a/src/Jobs/RestoreFromTimestampJob.php b/src/Jobs/RestoreJob.php similarity index 77% rename from src/Jobs/RestoreFromTimestampJob.php rename to src/Jobs/RestoreJob.php index 587b836..72885bc 100644 --- a/src/Jobs/RestoreFromTimestampJob.php +++ b/src/Jobs/RestoreJob.php @@ -9,7 +9,7 @@ use Itiden\Backup\Restorer; use Statamic\Contracts\Auth\User; -final class RestoreFromTimestampJob implements ShouldQueue +final class RestoreJob implements ShouldQueue { use Queueable; @@ -17,7 +17,7 @@ final class RestoreFromTimestampJob implements ShouldQueue * Create a new job instance. */ public function __construct( - private string $timestamp, + private string $id, private User $user, ) { } @@ -28,6 +28,6 @@ public function __construct( public function handle(Restorer $backuper): void { auth()->login($this->user); // ugly but it works; - $backuper->restoreFromTimestamp($this->timestamp); + $backuper->restoreFromId($this->id); } } diff --git a/src/Models/Metadata.php b/src/Models/Metadata.php index ccaf161..582b81f 100644 --- a/src/Models/Metadata.php +++ b/src/Models/Metadata.php @@ -39,7 +39,7 @@ public function __construct( 'root' => config('backup.metadata_path') . '/.meta', ]); - $yaml = YAML::parse($this->filesystem->get($this->backup->timestamp) ?? ''); + $yaml = YAML::parse($this->filesystem->get($this->backup->id) ?? ''); $this->createdBy = $yaml['created_by'] ?? null; $this->downloads = array_map(UserActionDto::fromArray(...), $yaml['downloads'] ?? []); @@ -107,13 +107,13 @@ public function addSkippedPipe(string $pipe, string $reason): void public function delete(): void { - $this->filesystem->delete($this->backup->timestamp); + $this->filesystem->delete($this->backup->id); } private function save(): void { $this->filesystem->put( - $this->backup->timestamp, + $this->backup->id, YAML::dump([ 'created_by' => $this->createdBy, 'downloads' => array_map(fn(UserActionDto $action): array => $action->toArray(), $this->downloads), diff --git a/src/Repositories/FileBackupRepository.php b/src/Repositories/FileBackupRepository.php index 1ed0c27..dcb1280 100644 --- a/src/Repositories/FileBackupRepository.php +++ b/src/Repositories/FileBackupRepository.php @@ -4,13 +4,14 @@ namespace Itiden\Backup\Repositories; -use Carbon\Carbon; +use Carbon\CarbonImmutable; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Filesystem\FilesystemAdapter; use Illuminate\Http\File as StreamableFile; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Str; +use Itiden\Backup\Contracts\BackupNameResolver; use Itiden\Backup\Contracts\Repositories\BackupRepository; use Itiden\Backup\DataTransferObjects\BackupDto; use Itiden\Backup\Events\BackupDeleted; @@ -22,53 +23,46 @@ final class FileBackupRepository implements BackupRepository /** @var FilesystemAdapter */ private Filesystem $filesystem; - public function __construct() - { + public function __construct( + private BackupNameResolver $nameResolver, + ) { $this->path = config('backup.destination.path'); $this->filesystem = Storage::disk(config('backup.destination.disk')); } - private function makeFilename(string $timestamp): string - { - return Str::slug(config('app.name')) . '-' . $timestamp . '.zip'; - } - public function all(): Collection { - return collect($this->filesystem->files($this->path)) + return collect($this->filesystem->allFiles($this->path)) ->map(BackupDto::fromFile(...)) - ->sortByDesc('timestamp'); + ->whereInstanceOf(BackupDto::class) + ->sortByDesc(fn(BackupDto $backup) => $backup->created_at); } public function add(string $path): BackupDto { $this->filesystem->makeDirectory(path: $this->path); - $timestamp = (string) Carbon::now()->unix(); + $id = (string) Str::ulid(); $this->filesystem->putFileAs( path: $this->path, file: new StreamableFile($path), - name: $this->makeFilename($timestamp), + name: (string) str($this->nameResolver->generateFilename(CarbonImmutable::now(), $id))->finish('.zip'), ); - return $this->find($timestamp); + return $this->find($id); } - public function find(string $timestamp): ?BackupDto + public function find(string $id): ?BackupDto { - $path = "{$this->path}/{$this->makeFilename($timestamp)}"; - - if (!$this->filesystem->exists($path)) { - return null; - } - - return BackupDto::fromFile($path); + return $this + ->all() + ->first(fn(BackupDto $backup): bool => $backup->id === $id); } - public function remove(string $timestamp): ?BackupDto + public function remove(string $id): ?BackupDto { - $backup = $this->find($timestamp); + $backup = $this->find($id); if (!$backup) { return null; @@ -85,7 +79,7 @@ public function empty(): bool { $this ->all() - ->each(fn(BackupDto $backup): ?BackupDto => $this->remove($backup->timestamp)); + ->each(fn(BackupDto $backup): ?BackupDto => $this->remove($backup->id)); return Storage::disk(config('backup.destination.disk'))->deleteDirectory(config('backup.destination.path')); } } diff --git a/src/Restorer.php b/src/Restorer.php index de29bd7..0547ed3 100644 --- a/src/Restorer.php +++ b/src/Restorer.php @@ -31,12 +31,12 @@ public function __construct( * * @throws Exception */ - public function restoreFromTimestamp(string $timestamp): void + public function restoreFromId(string $id): void { - $backup = $this->repository->find($timestamp); + $backup = $this->repository->find($id); if (!$backup) { - throw new RuntimeException("Backup with timestamp {$timestamp} not found."); + throw new RuntimeException("Backup with id {$id} not found."); } $this->restore($backup); diff --git a/src/ServiceProvider.php b/src/ServiceProvider.php index 5fb0caf..bbbb123 100644 --- a/src/ServiceProvider.php +++ b/src/ServiceProvider.php @@ -8,6 +8,7 @@ use Itiden\Backup\Console\Commands\BackupCommand; use Itiden\Backup\Console\Commands\ClearFilesCommand; use Itiden\Backup\Console\Commands\RestoreCommand; +use Itiden\Backup\Contracts\BackupNameResolver; use Itiden\Backup\Contracts\Repositories\BackupRepository; use Itiden\Backup\Events\BackupDeleted; use Statamic\Auth\Permissions as PermissionContract; @@ -69,6 +70,7 @@ public function register(): void $this->mergeConfigFrom(__DIR__ . '/../config/backup.php', 'backup'); $this->app->bind(BackupRepository::class, config('backup.repository')); + $this->app->bind(BackupNameResolver::class, config('backup.name_resolver')); } private function configureCommands(): void diff --git a/src/Support/Chunky.php b/src/Support/Chunky.php index 937a1fc..dc0fabb 100644 --- a/src/Support/Chunky.php +++ b/src/Support/Chunky.php @@ -4,6 +4,7 @@ namespace Itiden\Backup\Support; +use Closure; use Illuminate\Contracts\Filesystem\Filesystem; use Illuminate\Http\JsonResponse; use Illuminate\Http\Response; @@ -12,6 +13,8 @@ use Itiden\Backup\DataTransferObjects\ChunkyUploadDto; use SplFileInfo; +use function Illuminate\Filesystem\join_paths; + final class Chunky { private Filesystem $disk; @@ -33,9 +36,10 @@ public function path(?string $path = ''): string } /** - * put a from≤ chunks. + * Store a chunk of a file. If all chunks are uploaded, merge them into a single file. + * @param ?Closure $onCompleted Callback to run when the file is fully uploaded. */ - public function put(ChunkyUploadDto $dto): JsonResponse + public function put(ChunkyUploadDto $dto, ?Closure $onCompleted = null): JsonResponse { if (!$this->disk->putFileAs($dto->path, $dto->file, $dto->filename . '.part' . $dto->currentChunk)) { return response()->json(['message' => 'Error saving chunk'], Response::HTTP_INTERNAL_SERVER_ERROR); @@ -54,7 +58,12 @@ public function put(ChunkyUploadDto $dto): JsonResponse } $completeFile = $this->mergeChunksIntoFile($dto->path, $dto->filename, $dto->totalChunks); + if ($completeFile) { + if ($onCompleted) { + $onCompleted($completeFile); + } + return response()->json( ['message' => 'File successfully uploaded', 'file' => $completeFile], Response::HTTP_CREATED, @@ -83,13 +92,15 @@ public function mergeChunksIntoFile(string $path, string $filename, int $totalCh } fclose($file); + $targetPath = join_paths('backups', $filename); + // move the file to the backups folder - $this->disk->move($path . '/' . $filename, 'backups/' . $filename); + $this->disk->move(join_paths($path, $filename), $targetPath); // delete the chunks $this->disk->deleteDirectory($path); - return $this->path('backups/' . $filename); + return $this->path($targetPath); } /** diff --git a/src/Support/Facades/Chunky.php b/src/Support/Facades/Chunky.php index 6af5c07..0fc42d9 100644 --- a/src/Support/Facades/Chunky.php +++ b/src/Support/Facades/Chunky.php @@ -10,9 +10,9 @@ use Itiden\Backup\Support\Chunky as ChunkySupport; /** - * @method static \Illuminate\Http\JsonResponse put(ChunkyUploadDto $dto) + * @method static \Illuminate\Http\JsonResponse put(ChunkyUploadDto $dto, ?Closure $onCompleted = null) * @method static \Illuminate\Http\JsonResponse exists(ChunkyTestDto $dto) - * @method static string path() + * @method static string path(string $path = '') * * @see \Itiden\Backup\Support\Chunky */ diff --git a/tests/Feature/BackupMetadataTest.php b/tests/Feature/BackupMetadataTest.php index 58c1382..780ec2e 100644 --- a/tests/Feature/BackupMetadataTest.php +++ b/tests/Feature/BackupMetadataTest.php @@ -131,7 +131,7 @@ $metadata = $backup->getMetadata(); $metadata->addSkippedPipe(pipe: UserPipe::class, reason: 'Some reason'); - $file = File::get(config('backup.metadata_path') . '/.meta/' . $backup->timestamp); + $file = File::get(config('backup.metadata_path') . '/.meta/' . $backup->id); $yaml = app(Yaml::class)->parse($file); expect($file)->not->toBeEmpty(); diff --git a/tests/Feature/DeleteBackupTest.php b/tests/Feature/DeleteBackupTest.php index b7a5c07..4108591 100644 --- a/tests/Feature/DeleteBackupTest.php +++ b/tests/Feature/DeleteBackupTest.php @@ -13,7 +13,7 @@ it('cant be deleted by a guest', function (): void { $backup = Backuper::backup(); - $res = deleteJson(cp_route('api.itiden.backup.destroy', $backup->timestamp)); + $res = deleteJson(cp_route('api.itiden.backup.destroy', $backup->id)); expect($res->status())->toBe(401); expect(app(BackupRepository::class)->all())->toHaveCount(1); @@ -24,7 +24,7 @@ actingAs(user()); - $res = deleteJson(cp_route('api.itiden.backup.destroy', $backup->timestamp)); + $res = deleteJson(cp_route('api.itiden.backup.destroy', $backup->id)); expect($res->status())->toBe(403); expect(app(BackupRepository::class)->all())->toHaveCount(1); @@ -41,7 +41,7 @@ actingAs($user); - $response = deleteJson(cp_route('api.itiden.backup.destroy', $backup->timestamp)); + $response = deleteJson(cp_route('api.itiden.backup.destroy', $backup->id)); expect($response->status())->toBe(200); expect($response->json('message'))->toBe('Deleted ' . $backup->name); diff --git a/tests/Feature/DownloadBackupTest.php b/tests/Feature/DownloadBackupTest.php index 3a22dd6..dc02d3a 100644 --- a/tests/Feature/DownloadBackupTest.php +++ b/tests/Feature/DownloadBackupTest.php @@ -14,7 +14,7 @@ it('cant be downloaded by a guest', function (): void { $backup = Backuper::backup(); - $responseJson = getJson(cp_route('api.itiden.backup.download', $backup->timestamp)); + $responseJson = getJson(cp_route('api.itiden.backup.download', $backup->id)); expect($responseJson->status())->toBe(401); }); @@ -24,7 +24,7 @@ actingAs(user()); - $responseJson = getJson(cp_route('api.itiden.backup.download', $backup->timestamp)); + $responseJson = getJson(cp_route('api.itiden.backup.download', $backup->id)); expect($responseJson->status())->toBe(403); }); @@ -43,7 +43,7 @@ actingAs($user); - $response = get(cp_route('api.itiden.backup.download', $backup->timestamp)); + $response = get(cp_route('api.itiden.backup.download', $backup->id)); expect($response)->assertDownload(); })->with(['s3', 'local']); @@ -59,7 +59,7 @@ actingAs($user); - get(cp_route('api.itiden.backup.download', $backup->timestamp)); + get(cp_route('api.itiden.backup.download', $backup->id)); $metadata = $backup->getMetadata(); diff --git a/tests/Feature/PreventsSimultaneousActionsTest.php b/tests/Feature/PreventsSimultaneousActionsTest.php index b6a6e7e..1e9133a 100644 --- a/tests/Feature/PreventsSimultaneousActionsTest.php +++ b/tests/Feature/PreventsSimultaneousActionsTest.php @@ -5,7 +5,7 @@ use Illuminate\Support\Facades\Bus; use Itiden\Backup\Jobs\BackupJob; use Itiden\Backup\Jobs\RestoreFromPathJob; -use Itiden\Backup\Jobs\RestoreFromTimestampJob; +use Itiden\Backup\Jobs\RestoreJob; use Statamic\Contracts\Auth\User; use function Itiden\Backup\Tests\user; @@ -39,34 +39,14 @@ actingAs($user); postJson(cp_route('api.itiden.backup.restore', [ - 'timestamp' => now()->timestamp, + 'id' => now()->timestamp, ])); postJson(cp_route('api.itiden.backup.restore', [ - 'timestamp' => now()->timestamp, + 'id' => now()->timestamp, ]))->assertServerError(); - Bus::assertDispatchedTimes(RestoreFromTimestampJob::class, 1); - }); - - it('prevents simultaneous restore from path jobs', function (): void { - Bus::fake(); - - $user = tap(user()) - ->assignRole('super admin') - ->save(); - - actingAs($user); - - postJson(cp_route('api.itiden.backup.restore-from-path'), [ - 'path' => 'test', - ]); - - postJson(cp_route('api.itiden.backup.restore-from-path'), [ - 'path' => 'test', - ])->assertServerError(); - - Bus::assertDispatchedTimes(RestoreFromPathJob::class, 1); + Bus::assertDispatchedTimes(RestoreJob::class, 1); }); it('prevents other actions when something is queued', function (): void { @@ -79,21 +59,16 @@ actingAs($user); postJson(cp_route('api.itiden.backup.restore', [ - 'timestamp' => now()->timestamp, + 'id' => now()->timestamp, ])); postJson(cp_route('api.itiden.backup.store'))->assertServerError(); - postJson(cp_route('api.itiden.backup.restore-from-path'), [ - 'path' => 'test', - ])->assertServerError(); - postJson(cp_route('api.itiden.backup.restore', [ - 'timestamp' => now()->timestamp, + 'id' => now()->timestamp, ]))->assertServerError(); Bus::assertNotDispatched(BackupJob::class); - Bus::assertNotDispatched(RestoreFromPathJob::class); - Bus::assertDispatchedTimes(RestoreFromTimestampJob::class, 1); + Bus::assertDispatchedTimes(RestoreJob::class, 1); }); }); diff --git a/tests/Feature/RestoreBackupTest.php b/tests/Feature/RestoreBackupTest.php index d8bcdfc..2399794 100644 --- a/tests/Feature/RestoreBackupTest.php +++ b/tests/Feature/RestoreBackupTest.php @@ -15,16 +15,16 @@ use function Pest\Laravel\postJson; describe('api:restore', function (): void { - it('cant restore by timestamp by a guest', function (): void { - $response = postJson(cp_route('api.itiden.backup.restore', 'timestamp')); + it('cant restore by id by a guest', function (): void { + $response = postJson(cp_route('api.itiden.backup.restore', 'id')); expect($response->status())->toBe(Response::HTTP_UNAUTHORIZED); }); - it('cant restore by timestamp by a user without permissons a backup', function (): void { + it('cant restore by id by a user without permissons a backup', function (): void { actingAs(user()); - $response = postJson(cp_route('api.itiden.backup.restore', 'timestamp')); + $response = postJson(cp_route('api.itiden.backup.restore', 'id')); expect($response->status())->toBe(Response::HTTP_FORBIDDEN); }); @@ -38,12 +38,12 @@ actingAs($user); - $response = postJson(cp_route('api.itiden.backup.restore', 'timestamp')); + $response = postJson(cp_route('api.itiden.backup.restore', 'id')); expect($response->status())->toBe(Response::HTTP_INTERNAL_SERVER_ERROR); }); - it('can restore by timestamp', function (): void { + it('can restore by id', function (): void { $backup = Backuper::backup(); $user = user(); @@ -54,7 +54,7 @@ actingAs($user); - $response = postJson(cp_route('api.itiden.backup.restore', $backup->timestamp)); + $response = postJson(cp_route('api.itiden.backup.restore', $backup->id)); expect($response->status())->toBe(Response::HTTP_OK); }); @@ -71,10 +71,10 @@ actingAs($user); - $response = postJson(cp_route('api.itiden.backup.restore', $backup->timestamp)); + $response = postJson(cp_route('api.itiden.backup.restore', $backup->id)); Event::assertDispatched(BackupRestored::class, function (BackupRestored $event) use ($backup): bool { - return $event->backup->timestamp === $backup->timestamp; + return $event->backup->id === $backup->id; }); expect($response->status())->toBe(Response::HTTP_OK); }); @@ -116,7 +116,7 @@ actingAs($user); - $response = postJson(cp_route('api.itiden.backup.restore', $backup->timestamp)); + $response = postJson(cp_route('api.itiden.backup.restore', $backup->id)); expect($response->status())->toBe(Response::HTTP_OK); expect($backup diff --git a/tests/Feature/RestoreFromPathTest.php b/tests/Feature/RestoreFromPathTest.php deleted file mode 100644 index 5b46632..0000000 --- a/tests/Feature/RestoreFromPathTest.php +++ /dev/null @@ -1,89 +0,0 @@ -assignRole('super admin') - ->save(); - - actingAs($user); - - $path = Storage::disk(config('backup.destination.disk'))->path($backup->path); - - $response = postJson(cp_route('api.itiden.backup.restore-from-path'), [ - 'path' => $path, - ]); - - expect($response->status())->toBe(Response::HTTP_OK); - }); - - it('can restore from path and delete after', function (): void { - $backup = Backuper::backup(); - - $user = user(); - - $user - ->assignRole('super admin') - ->save(); - - actingAs($user); - - $path = Storage::disk(config('backup.destination.disk'))->path($backup->path); - - $response = postJson(cp_route('api.itiden.backup.restore-from-path'), [ - 'path' => $path, - ]); - - expect($response->status())->toBe(Response::HTTP_OK); - expect(File::exists($path))->toBeFalse(); - }); - - it('will not restore empty archives and dispatches failed event', function (): void { - Event::fake(); - $user = user(); - - $emptyArchive = storage_path(config('backup.temp_path') . '/empty.zip'); - - // The zip file cant be empty, but when extracting it can if the password is wrong. - Zipper::write($emptyArchive) - ->addFromString('empty.txt', 'empty') - ->encrypt('not-the-password-we-decrypt-with') - ->close(); - - $user - ->assignRole('super admin') - ->save(); - - actingAs($user); - - $response = postJson(cp_route('api.itiden.backup.restore-from-path'), [ - 'path' => $emptyArchive, - ]); - - Event::assertDispatched(RestoreFailed::class); - - expect($response->status())->toBe(Response::HTTP_INTERNAL_SERVER_ERROR); - }); -}) - ->group('restore-from-path') - ->afterEach(function (): void { - File::cleanDirectory(config('backup.temp_path')); - }); diff --git a/tests/Feature/UploadControllerTest.php b/tests/Feature/UploadControllerTest.php new file mode 100644 index 0000000..c132361 --- /dev/null +++ b/tests/Feature/UploadControllerTest.php @@ -0,0 +1,116 @@ +deleteDirectory(config('backup.destination.path')); + + $backup = Backuper::backup(); + + $chunks = chunk_file( + file: Storage::disk(config('backup.destination.disk'))->path(join_paths($backup->path)), + path: config('backup.temp_path') . '/test-chunks/', + buffer: 512, + ); + + $totalSize = Storage::disk(config('backup.destination.disk'))->size(join_paths($backup->path)); + + $bodies = $chunks->map(fn(string $chunk, int $index): array => [ + 'resumableIdentifier' => 'test-chunk-identifier', + 'resumableFilename' => $backup->name, + 'resumableTotalChunks' => $chunks->count(), + 'resumableChunkNumber' => $index + 1, + 'resumableTotalSize' => $totalSize, + 'file' => UploadedFile::fake()->createWithContent(basename($chunk), file_get_contents($chunk)), + ]); + + $user = user(); + $user->makeSuper(); + $user->save(); + + actingAs($user); + + $bodies + ->take($chunks->count() - 1) + ->each(function (array $values): void { + $res = postJson(cp_route('itiden.backup.chunky.upload'), $values); + $res->assertStatus(201); + $res->assertJsonStructure(['message']); + }); + + expect(app(BackupRepository::class)->all())->toHaveCount(1); + + $res = postJson(cp_route('itiden.backup.chunky.upload'), $bodies->last()); + + $res->assertSuccessful(); + expect(app(BackupRepository::class)->all())->toHaveCount(2); + + File::cleanDirectory(Chunky::path()); + File::cleanDirectory(config('backup.temp_path')); + app(BackupRepository::class)->empty(); + }); + + it('can test if a chunk exists', function (): void { + File::cleanDirectory(config('backup.temp_path')); + Storage::disk(config('backup.destination.disk'))->deleteDirectory(config('backup.destination.path')); + + $backup = Backuper::backup(); + + $chunks = chunk_file( + file: Storage::disk(config('backup.destination.disk'))->path(join_paths($backup->path)), + path: config('backup.temp_path') . '/test-chunks/', + buffer: 512, + ); + + $totalSize = Storage::disk(config('backup.destination.disk'))->size(join_paths($backup->path)); + + $bodies = $chunks->map(fn(string $chunk, int $index): array => [ + 'resumableIdentifier' => 'test-chunk-identifier', + 'resumableFilename' => $backup->name, + 'resumableTotalChunks' => $chunks->count(), + 'resumableChunkNumber' => $index + 1, + 'resumableTotalSize' => $totalSize, + 'file' => UploadedFile::fake()->createWithContent(basename($chunk), file_get_contents($chunk)), + ]); + + $user = user(); + $user->makeSuper(); + $user->save(); + + actingAs($user); + + $chunksToTest = $bodies->take($chunks->count() - 1); + + $chunksToTest->each(function (array $values): void { + $res = postJson(cp_route('itiden.backup.chunky.upload'), $values); + $res->assertStatus(201); + }); + + $chunksToTest->each(function (array $values): void { + $res = getJson(cp_route('itiden.backup.chunky.test', $values)); + $res->assertStatus(200); + }); + + File::cleanDirectory(Chunky::path()); + File::cleanDirectory(config('backup.temp_path')); + app(BackupRepository::class)->empty(); + }); +}); diff --git a/tests/Feature/ViewBackupsTest.php b/tests/Feature/ViewBackupsTest.php index 5448aa6..8461250 100644 --- a/tests/Feature/ViewBackupsTest.php +++ b/tests/Feature/ViewBackupsTest.php @@ -84,7 +84,7 @@ 'size', 'path', 'created_at', - 'timestamp', + 'id', 'metadata' => [ 'created_by', 'downloads', diff --git a/tests/Unit/BackupDtoTest.php b/tests/Unit/BackupDtoTest.php index 8156f81..2623978 100644 --- a/tests/Unit/BackupDtoTest.php +++ b/tests/Unit/BackupDtoTest.php @@ -15,7 +15,7 @@ $backup = Backuper::backup(); - expect($backup->timestamp)->toBeString(); - expect($backup->timestamp)->toBe((string) $fakeTime->timestamp); + expect($backup->id)->toBeString(); + expect($backup->created_at->timestamp)->toBe($fakeTime->timestamp); }); })->group('backupdto'); diff --git a/tests/Unit/BackupRepositoryTest.php b/tests/Unit/BackupRepositoryTest.php index b1f9ad1..571af5e 100644 --- a/tests/Unit/BackupRepositoryTest.php +++ b/tests/Unit/BackupRepositoryTest.php @@ -20,16 +20,16 @@ expect($backups->first())->toBeInstanceOf(BackupDto::class); }); - it('can get backup by timestamp', function (): void { + it('can get backup by id', function (): void { $backup = Backuper::backup(); - $backupByTimestamp = app(BackupRepository::class)->find($backup->timestamp); + $foundBackup = app(BackupRepository::class)->find($backup->id); - expect($backupByTimestamp)->toBeInstanceOf(BackupDto::class); - expect($backupByTimestamp->timestamp)->toBe($backup->timestamp); - expect($backupByTimestamp)->toEqual($backup); + expect($foundBackup)->toBeInstanceOf(BackupDto::class); + expect($foundBackup->id)->toBe($backup->id); + expect($foundBackup)->toEqual($backup); }); - it('returns null when timestamp doesnt exist', function (): void { + it('returns null when id doesnt exist', function (): void { $backup = app(BackupRepository::class)->find('1234567890'); expect($backup)->toBeNull(); }); @@ -48,7 +48,7 @@ Event::fake(); $backup = Backuper::backup(); - app(BackupRepository::class)->remove($backup->timestamp); + app(BackupRepository::class)->remove($backup->id); Event::assertDispatched(BackupDeleted::class); }); @@ -61,14 +61,23 @@ expect(Storage::disk('local')->files(storage_path('statamic-backup/.metadata')))->toBeEmpty(); }); - it('can delete backup by timestamp', function (): void { + it('can delete backup by id', function (): void { $backup = Backuper::backup(); - $backup = app(BackupRepository::class)->remove($backup->timestamp); + $backup = app(BackupRepository::class)->remove($backup->id); expect($backup)->toBeInstanceOf(BackupDto::class); expect(Storage::disk(config('backup.destination.disk'))->exists( config('backup.destination.path') . "/{$backup->name}.zip", ))->toBeFalse(); }); + + it('returns null and doesnt dispatch event when backup doesnt exist', function (): void { + Event::fake(); + + $backup = app(BackupRepository::class)->remove('1234567890'); + + expect($backup)->toBeNull(); + Event::assertNotDispatched(BackupDeleted::class); + }); })->group('backuprepository'); diff --git a/tests/Unit/BackuperTest.php b/tests/Unit/BackuperTest.php index c71cf18..ddbf2af 100644 --- a/tests/Unit/BackuperTest.php +++ b/tests/Unit/BackuperTest.php @@ -5,6 +5,7 @@ use Carbon\Carbon; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Storage; +use Itiden\Backup\Contracts\BackupNameResolver; use Itiden\Backup\Contracts\Repositories\BackupRepository; use Itiden\Backup\DataTransferObjects\BackupDto; use Itiden\Backup\Facades\Backuper; @@ -18,14 +19,22 @@ describe('backuper', function (): void { it('can backup', function (): void { - $this->withoutExceptionHandling(); + Carbon::setTestNow(Carbon::now()); + $backup = Backuper::backup(); + $filename = app(BackupNameResolver::class)->generateFilename(Carbon::now()->toImmutable(), $backup->id); + expect($backup)->toBeInstanceOf(BackupDto::class); - expect(Storage::disk(config('backup.destination.disk'))->exists( - config('backup.destination.path') . "/{$backup->name}.zip", - ))->toBeTrue(); + expect(Storage::disk(config('backup.destination.disk'))->exists(str( + config('backup.destination.path') . "/{$filename}", + )->finish('.zip')))->toBeTrue(); + + expect(pathinfo( + Storage::disk(config('backup.destination.disk'))->path($backup->path), + PATHINFO_EXTENSION, + ))->toBe('zip'); }); it('backups correct files', function (): void { diff --git a/tests/Unit/ChunkyTest.php b/tests/Unit/ChunkyTest.php index 39365df..8d6f556 100644 --- a/tests/Unit/ChunkyTest.php +++ b/tests/Unit/ChunkyTest.php @@ -8,6 +8,7 @@ use Itiden\Backup\DataTransferObjects\ChunkyUploadDto; use Itiden\Backup\Support\Facades\Chunky; +use function Illuminate\Filesystem\join_paths; use function Itiden\Backup\Tests\chunk_file; describe('chunky', function (): void { @@ -44,18 +45,27 @@ ), ); - $responses = $dtos->map(Chunky::put(...)); + $uploadedFile = null; + + $responses = $dtos->map(function (ChunkyUploadDto $r) use (&$uploadedFile): JsonResponse { + return Chunky::put($r, onCompleted: function (string $file) use (&$uploadedFile): void { + $uploadedFile = $file; + }); + }); expect($responses->every(fn(JsonResponse $res): bool => $res->getStatusCode() === 201))->toBeTrue(); expect($responses ->last() ->getData(true))->toHaveKey('file'); - expect(Chunky::path() . '/backups/homepage.md')->toBeFile(); - expect(File::get(Chunky::path() . '/backups/homepage.md'))->toBe(File::get( + expect(Chunky::path('backups/homepage.md'))->toBeFile(); + + expect(File::get(Chunky::path('/backups/homepage.md')))->toBe(File::get( __DIR__ . '/../__fixtures__/content/collections/pages/homepage.md', )); + expect($uploadedFile)->toEqual(Chunky::path('/backups/homepage.md')); + File::deleteDirectory(Chunky::path()); File::deleteDirectory(config('backup.temp_path') . '/chunks'); }); diff --git a/tests/Unit/RestorerTest.php b/tests/Unit/RestorerTest.php index 41c3b58..bc6d6f9 100644 --- a/tests/Unit/RestorerTest.php +++ b/tests/Unit/RestorerTest.php @@ -15,14 +15,14 @@ use function Itiden\Backup\Tests\user; describe('restorer', function (): void { - it('can restore from timestamp', function (): void { + it('can restore from id', function (): void { $backup = Backuper::backup(); File::cleanDirectory(fixtures_path('content/collections')); expect(File::isEmptyDirectory(fixtures_path('content/collections')))->toBeTrue(); - Restorer::restoreFromTimestamp($backup->timestamp); + Restorer::restoreFromId($backup->id); expect(File::isEmptyDirectory(fixtures_path('content/collections')))->toBeFalse(); }); @@ -57,10 +57,10 @@ Restorer::restore( new BackupDto( name: 'test', - created_at: now(), + created_at: now()->toImmutable(), size: '0', path: 'test/path', - timestamp: (string) now()->timestamp, + id: (string) random_bytes(10), ), ); })->throws(RestoreFailed::class);