feat(automations): generate node supports the 3 content styles
This commit is contained in:
parent
8d608554cd
commit
7fd84507b3
4 changed files with 306 additions and 64 deletions
|
|
@ -7,7 +7,10 @@
|
|||
use App\Actions\Post\CreatePost;
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Ai\Templates\AiTemplateRegistry;
|
||||
use App\Ai\Templates\TemplateContext;
|
||||
use App\DataTransferObjects\Automation\NodeRunResult;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Models\AutomationRun;
|
||||
use App\Models\SocialAccount;
|
||||
|
|
@ -16,7 +19,6 @@
|
|||
use App\Services\Ai\RecordAiUsage;
|
||||
use App\Services\Automation\ExpressionResolver;
|
||||
use App\Services\Automation\GenerateNodeValidator;
|
||||
use App\Services\Image\PostImagePipeline;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
|
|
@ -48,41 +50,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
// Per-automation toggle: when off, the brand persona/voice is NOT
|
||||
// injected, so the post stays faithful to a third-party source (news /
|
||||
// RSS curation) instead of being rewritten in the brand's voice.
|
||||
$applyBrandVoice = (bool) data_get($config, 'use_brand_voice', true);
|
||||
|
||||
$platformContext = $this->resolvePlatformContext($accountsConfig);
|
||||
|
||||
$agent = new PostContentGenerator(
|
||||
workspace: $workspace,
|
||||
format: $format,
|
||||
slideCount: $slideCount,
|
||||
platformContext: $platformContext,
|
||||
applyBrandVoice: $applyBrandVoice,
|
||||
);
|
||||
|
||||
$generatorResponse = $agent->prompt($prompt);
|
||||
|
||||
RecordAiUsage::recordText(
|
||||
workspace: $workspace,
|
||||
promptTokens: $generatorResponse->usage->promptTokens,
|
||||
completionTokens: $generatorResponse->usage->completionTokens,
|
||||
provider: (string) config('ai.default'),
|
||||
model: (string) config('ai.default_text_model'),
|
||||
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
|
||||
);
|
||||
|
||||
$structured = $generatorResponse->structured ?? [];
|
||||
|
||||
$structured = $this->humanize($workspace, $structured, $format, $applyBrandVoice, $platformContext);
|
||||
|
||||
$content = $format === 'carousel'
|
||||
? (string) data_get($structured, 'caption', '')
|
||||
: (string) data_get($structured, 'content', '');
|
||||
|
||||
$user = $this->resolveUser($run);
|
||||
$style = ContentStyle::tryFrom((string) data_get($config, 'style', ContentStyle::default()->value)) ?? ContentStyle::default();
|
||||
$styleTemplate = app(AiTemplateRegistry::class)->find($style);
|
||||
|
||||
$platforms = [];
|
||||
foreach ($accountsConfig as $entry) {
|
||||
|
|
@ -105,50 +78,71 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
];
|
||||
}
|
||||
|
||||
// A single source of truth for image count: 0 = text-only (no image),
|
||||
// 1 = single image, 2+ = carousel. For the single-image branch we only
|
||||
// care whether at least one image was requested.
|
||||
$wantsImage = (int) data_get($config, 'target_slide_count', 1) >= 1;
|
||||
|
||||
$brandAccount = $platforms !== []
|
||||
? $activeAccounts->get(data_get($platforms[0], 'social_account_id'))
|
||||
: null;
|
||||
|
||||
$contentType = $platforms !== []
|
||||
? ContentType::tryFrom((string) data_get($platforms[0], 'content_type'))
|
||||
: null;
|
||||
$isCarousel = $format === 'carousel';
|
||||
$imageCount = $isCarousel ? $slideCount : ($wantsImage ? 1 : 0);
|
||||
|
||||
$imageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount);
|
||||
$templateContext = new TemplateContext(
|
||||
workspace: $workspace,
|
||||
socialAccount: $brandAccount,
|
||||
format: $platformContext ?? $format,
|
||||
imageCount: $imageCount,
|
||||
isCarousel: $isCarousel,
|
||||
applyBrandVisuals: (bool) data_get($config, 'use_brand_visuals', true),
|
||||
);
|
||||
|
||||
$agent = new PostContentGenerator(
|
||||
workspace: $workspace,
|
||||
format: $format,
|
||||
slideCount: $slideCount,
|
||||
platformContext: $platformContext,
|
||||
applyBrandVoice: $applyBrandVoice,
|
||||
template: $styleTemplate,
|
||||
templateContext: $templateContext,
|
||||
);
|
||||
|
||||
$generatorResponse = $agent->prompt($prompt);
|
||||
|
||||
RecordAiUsage::recordText(
|
||||
workspace: $workspace,
|
||||
promptTokens: $generatorResponse->usage->promptTokens,
|
||||
completionTokens: $generatorResponse->usage->completionTokens,
|
||||
provider: (string) config('ai.default'),
|
||||
model: (string) config('ai.default_text_model'),
|
||||
metadata: ['agent' => 'post_generator', 'format' => $format, 'source' => 'automation'],
|
||||
);
|
||||
|
||||
$structured = $generatorResponse->structured ?? [];
|
||||
|
||||
$structured = $this->humanize($workspace, $structured, $format, $style->value, $applyBrandVoice, $platformContext);
|
||||
|
||||
$intendedImageCount = $this->intendedImageCount($format, $slideCount, $wantsImage, $structured, $brandAccount, $style);
|
||||
|
||||
// Dry runs do the AI work (so the user sees a real generation) but
|
||||
// never persist a Post. Downstream nodes (Publish) read `is_dry_run`
|
||||
// and skip their persistence too.
|
||||
if ($run->is_dry_run) {
|
||||
$dryContent = $this->extractContent($structured, $format, $style);
|
||||
|
||||
return NodeRunResult::completed(output: [
|
||||
'generated' => [
|
||||
'post_id' => null,
|
||||
'content' => $content,
|
||||
'content' => $dryContent,
|
||||
'dry_run' => true,
|
||||
'image_count' => $imageCount,
|
||||
'image_count' => $intendedImageCount,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$media = [];
|
||||
$generated = $styleTemplate->assemble($structured, $templateContext);
|
||||
|
||||
$applyBrandVisuals = (bool) data_get($config, 'use_brand_visuals', true);
|
||||
|
||||
if ($brandAccount) {
|
||||
if ($format === 'carousel') {
|
||||
$media = app(PostImagePipeline::class)->forCarousel($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
|
||||
} elseif ($wantsImage) {
|
||||
$media = app(PostImagePipeline::class)->forSingle($workspace, $brandAccount, $structured, $contentType, $applyBrandVisuals);
|
||||
}
|
||||
}
|
||||
$user = $this->resolveUser($run);
|
||||
|
||||
$post = CreatePost::execute($workspace, $user, [
|
||||
'content' => $content,
|
||||
'media' => $media,
|
||||
'content' => $generated->content,
|
||||
'media' => $generated->media,
|
||||
'platforms' => $platforms,
|
||||
]);
|
||||
|
||||
|
|
@ -157,7 +151,7 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
return NodeRunResult::completed(output: [
|
||||
'generated' => [
|
||||
'post_id' => $post->id,
|
||||
'content' => $content,
|
||||
'content' => $generated->content,
|
||||
'post_url' => route('app.posts.show', $post->id),
|
||||
],
|
||||
]);
|
||||
|
|
@ -167,8 +161,12 @@ public function __invoke(AutomationRun $run, array $config): NodeRunResult
|
|||
* @param array<string, mixed> $structured
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function humanize(Workspace $workspace, array $structured, string $format, bool $applyBrandVoice = true, ?string $platformContext = null): array
|
||||
private function humanize(Workspace $workspace, array $structured, string $format, string $styleKey = 'image_card', bool $applyBrandVoice = true, ?string $platformContext = null): array
|
||||
{
|
||||
if ($styleKey === 'tweet_card' || $styleKey === 'tweet_card_image') {
|
||||
return $structured;
|
||||
}
|
||||
|
||||
try {
|
||||
$input = $format === 'carousel'
|
||||
? [
|
||||
|
|
@ -227,6 +225,26 @@ private function humanize(Workspace $workspace, array $structured, string $forma
|
|||
return $structured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the post caption from the raw structured output without calling
|
||||
* assemble() (which triggers image generation). Used for dry-run responses
|
||||
* so no pipeline work happens during test runs.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
private function extractContent(array $structured, string $format, ContentStyle $style): string
|
||||
{
|
||||
if ($style === ContentStyle::TweetCard || $style === ContentStyle::TweetCardImage) {
|
||||
return $format === 'carousel'
|
||||
? (string) data_get($structured, 'caption', '')
|
||||
: (string) data_get($structured, 'tweet_text', '');
|
||||
}
|
||||
|
||||
return $format === 'carousel'
|
||||
? (string) data_get($structured, 'caption', '')
|
||||
: (string) data_get($structured, 'content', '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the generator format and slide count from per-account content types.
|
||||
*
|
||||
|
|
@ -247,11 +265,6 @@ private function humanize(Workspace $workspace, array $structured, string $forma
|
|||
*/
|
||||
public function deriveFormat(array $accountsConfig, array $config): array
|
||||
{
|
||||
// Multi-image capability comes from the same ContentType rules the
|
||||
// publish flow uses — never a hardcoded list — so facebook_post,
|
||||
// tiktok_photo, linkedin_carousel etc. are all recognised. We cap the
|
||||
// slide count at MAX_GENERATED_IMAGES and at the tightest selected
|
||||
// account's limit.
|
||||
$maxImagesAcross = 0;
|
||||
foreach ($accountsConfig as $entry) {
|
||||
$contentType = ContentType::tryFrom((string) data_get($entry, 'content_type'));
|
||||
|
|
@ -294,15 +307,20 @@ private function resolvePlatformContext(array $accountsConfig): ?string
|
|||
* Number of images that would be attached for the resolved format. Used as
|
||||
* the dry-run indicator and mirrors the non-dry image generation branches:
|
||||
* one per slide for carousels, one for single posts when images are enabled.
|
||||
* Tweet styles always produce one image per slide/post when an account is set.
|
||||
*
|
||||
* @param array<string, mixed> $structured
|
||||
*/
|
||||
private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount): int
|
||||
private function intendedImageCount(string $format, int $slideCount, bool $wantsImage, array $structured, ?SocialAccount $brandAccount, ContentStyle $style): int
|
||||
{
|
||||
if (! $brandAccount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($style === ContentStyle::TweetCard || $style === ContentStyle::TweetCardImage) {
|
||||
return $format === 'carousel' ? $slideCount : 1;
|
||||
}
|
||||
|
||||
if ($format === 'carousel') {
|
||||
$slides = data_get($structured, 'slides', []);
|
||||
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ private function assembleCarousel(array $structured, TemplateContext $context):
|
|||
account: $context->socialAccount,
|
||||
structured: $structured,
|
||||
contentType: ContentType::InstagramFeed,
|
||||
applyBrandVisuals: $context->applyBrandVisuals,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -142,6 +143,7 @@ private function assembleSingle(array $structured, TemplateContext $context): Ge
|
|||
account: $context->socialAccount,
|
||||
structured: $structured,
|
||||
contentType: $contentType,
|
||||
applyBrandVisuals: $context->applyBrandVisuals,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,5 +18,6 @@ public function __construct(
|
|||
public string $format,
|
||||
public int $imageCount,
|
||||
public bool $isCarousel = false,
|
||||
public bool $applyBrandVisuals = true,
|
||||
) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
use App\Actions\Automation\Node\RunGenerateNode;
|
||||
use App\Ai\Agents\PostContentGenerator;
|
||||
use App\Ai\Agents\PostContentHumanizer;
|
||||
use App\Enums\Ai\ContentStyle;
|
||||
use App\Enums\Post\Status as PostStatus;
|
||||
use App\Enums\PostPlatform\ContentType;
|
||||
use App\Models\Automation;
|
||||
|
|
@ -457,3 +458,223 @@
|
|||
$run->refresh();
|
||||
expect($run->generated_post_id)->toBeNull();
|
||||
});
|
||||
|
||||
it('defaults to image_card style when no style is set, behaving identically to before', function () {
|
||||
PostContentGenerator::fake([
|
||||
['content' => 'Legacy post', 'image_title' => 'Title', 'image_body' => 'Body', 'image_keywords' => ['kw']],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([
|
||||
['content' => 'Legacy post', 'image_title' => 'Title', 'image_body' => 'Body'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$mediaItem = ['id' => 1, 'path' => 'ai-images/x.webp', 'url' => 'http://x', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forSingle')->once()->andReturn([$mediaItem]);
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write about {{ trigger.title }}',
|
||||
'target_slide_count' => 1,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
|
||||
$post = Post::find($result->output['generated']['post_id']);
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->content)->toBe('Legacy post');
|
||||
expect($post->media)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('generates a tweet card post when style is tweet_card', function () {
|
||||
PostContentGenerator::fake([
|
||||
['tweet_text' => 'Hot take on AI agents today.'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$mediaItem = ['id' => 2, 'path' => 'ai-images/tweet.webp', 'url' => 'http://tweet', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([$mediaItem]);
|
||||
$pipeline->shouldNotReceive('forSingle');
|
||||
$pipeline->shouldNotReceive('forCarousel');
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'style' => ContentStyle::TweetCard->value,
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
|
||||
'target_slide_count' => 1,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
|
||||
$post = Post::find($result->output['generated']['post_id']);
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->content)->toBe('Hot take on AI agents today.');
|
||||
expect($post->media)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('skips the humanizer for tweet_card style', function () {
|
||||
PostContentGenerator::fake([
|
||||
['tweet_text' => 'Raw tweet, no humanizer.'],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([]);
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
app(RunGenerateNode::class)($run, [
|
||||
'style' => ContentStyle::TweetCard->value,
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
|
||||
'target_slide_count' => 1,
|
||||
]);
|
||||
|
||||
PostContentHumanizer::assertNeverPrompted();
|
||||
});
|
||||
|
||||
it('generates a tweet card with AI image background when style is tweet_card_image', function () {
|
||||
PostContentGenerator::fake([
|
||||
['tweet_text' => 'AI is transforming everything.', 'image_keywords' => ['artificial intelligence', 'technology']],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$mediaItem = ['id' => 3, 'path' => 'ai-images/tweet-img.webp', 'url' => 'http://tweet-img', 'type' => 'image', 'mime_type' => 'image/webp', 'source' => 'ai', 'source_meta' => []];
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forTweetCard')
|
||||
->once()
|
||||
->withArgs(function ($ws, $acc, $text, $keywords) {
|
||||
return $text === 'AI is transforming everything.'
|
||||
&& is_array($keywords)
|
||||
&& count($keywords) > 0;
|
||||
})
|
||||
->andReturn([$mediaItem]);
|
||||
$pipeline->shouldNotReceive('forSingle');
|
||||
$pipeline->shouldNotReceive('forCarousel');
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'style' => ContentStyle::TweetCardImage->value,
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
|
||||
'target_slide_count' => 1,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
|
||||
$post = Post::find($result->output['generated']['post_id']);
|
||||
expect($post)->not->toBeNull();
|
||||
expect($post->content)->toBe('AI is transforming everything.');
|
||||
expect($post->media)->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('skips the humanizer for tweet_card_image style', function () {
|
||||
PostContentGenerator::fake([
|
||||
['tweet_text' => 'Raw tweet with image.', 'image_keywords' => ['tech']],
|
||||
]);
|
||||
|
||||
PostContentHumanizer::fake([]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldReceive('forTweetCard')->once()->andReturn([]);
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
app(RunGenerateNode::class)($run, [
|
||||
'style' => ContentStyle::TweetCardImage->value,
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write a tweet about {{ trigger.title }}',
|
||||
'target_slide_count' => 1,
|
||||
]);
|
||||
|
||||
PostContentHumanizer::assertNeverPrompted();
|
||||
});
|
||||
|
||||
it('dry run returns sensible image_count for tweet_card style', function () {
|
||||
PostContentGenerator::fake([
|
||||
['tweet_text' => 'A tweet for dry run.'],
|
||||
]);
|
||||
|
||||
$workspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->for($workspace)->create(['platform' => 'x']);
|
||||
|
||||
$pipeline = Mockery::mock(PostImagePipeline::class);
|
||||
$pipeline->shouldNotReceive('forTweetCard');
|
||||
$pipeline->shouldNotReceive('forSingle');
|
||||
$pipeline->shouldNotReceive('forCarousel');
|
||||
app()->instance(PostImagePipeline::class, $pipeline);
|
||||
|
||||
$automation = Automation::factory()->for($workspace)->create();
|
||||
$run = AutomationRun::factory()->for($automation)->create([
|
||||
'is_dry_run' => true,
|
||||
'context' => ['trigger' => ['title' => 'test']],
|
||||
]);
|
||||
|
||||
$result = app(RunGenerateNode::class)($run, [
|
||||
'style' => ContentStyle::TweetCard->value,
|
||||
'accounts' => [
|
||||
['social_account_id' => (string) $account->id, 'content_type' => ContentType::XPost->value, 'meta' => []],
|
||||
],
|
||||
'prompt_template' => 'Write about {{ trigger.title }}',
|
||||
'target_slide_count' => 1,
|
||||
]);
|
||||
|
||||
expect($result->status->value)->toBe('completed');
|
||||
expect($result->output['generated']['dry_run'])->toBeTrue();
|
||||
expect($result->output['generated']['image_count'])->toBe(1);
|
||||
expect($result->output['generated']['post_id'])->toBeNull();
|
||||
expect(Post::count())->toBe(0);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue