diff --git a/app/Enums/ContextItemSummaryStatus.php b/app/Enums/ContextItemSummaryStatus.php new file mode 100644 index 0000000..9a8b3c3 --- /dev/null +++ b/app/Enums/ContextItemSummaryStatus.php @@ -0,0 +1,18 @@ + */ + use HasFactory, SoftDeletes; + + /** + * Soft cap for raw text returned by `bodyForContext()` when no summary + * is available. Roughly 4 KB of UTF-8 — enough to keep plans usable + * without dominating the prompt budget. + */ + public const BODY_FALLBACK_CHARS = 4000; + + protected function casts(): array + { + return [ + 'type' => ContextItemType::class, + 'summary_status' => ContextItemSummaryStatus::class, + 'metadata' => 'array', + ]; + } + + public function project(): BelongsTo + { + return $this->belongsTo(Project::class); + } + + public function story(): BelongsTo + { + return $this->belongsTo(Story::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by_id'); + } + + public function includedInStories(): BelongsToMany + { + return $this->belongsToMany(Story::class, 'context_item_story') + ->withPivot(['included_at', 'included_by_id']); + } + + public function isProjectScoped(): bool + { + return $this->story_id === null; + } + + public function isStoryScoped(): bool + { + return $this->story_id !== null; + } + + /** + * The text rendered into the AI prompt. Prefers a ready summary; falls + * back to the truncated raw text body so plans still generate when + * summarisation was skipped or the item never went through compression. + */ + public function bodyForContext(): string + { + if ($this->summary_status === ContextItemSummaryStatus::Ready && filled($this->summary)) { + return (string) $this->summary; + } + + $raw = $this->rawBody(); + if ($raw === '') { + return ''; + } + + return Str::limit($raw, self::BODY_FALLBACK_CHARS, '…'); + } + + private function rawBody(): string + { + $type = $this->type instanceof ContextItemType ? $this->type : ContextItemType::tryFrom((string) $this->type); + + return match ($type) { + ContextItemType::Text => (string) ($this->metadata['body'] ?? $this->description ?? ''), + ContextItemType::Link => (string) ($this->metadata['url'] ?? ''), + ContextItemType::File => (string) ($this->metadata['original_name'] ?? $this->metadata['path'] ?? ''), + null => (string) ($this->description ?? ''), + }; + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 07d989a..cfc44ff 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -56,6 +56,11 @@ public function features(): HasMany return $this->hasMany(Feature::class); } + public function contextItems(): HasMany + { + return $this->hasMany(ContextItem::class); + } + public function stories(): HasManyThrough { return $this->hasManyThrough(Story::class, Feature::class); diff --git a/app/Models/Story.php b/app/Models/Story.php index 7e29e29..b851f2d 100644 --- a/app/Models/Story.php +++ b/app/Models/Story.php @@ -13,6 +13,7 @@ use App\Services\Stories\StoryRunProjection; use Database\Factories\StoryFactory; use Illuminate\Database\Eloquent\Attributes\Fillable; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -217,6 +218,47 @@ public function scenarios(): HasMany return $this->hasMany(Scenario::class)->orderBy('position'); } + /** + * Story-scoped ContextItems (those whose `story_id` matches this story). + * `ContextItemWriter::createStoryItem` auto-attaches them to this + * Story's selection, and `ContextItemSelector` refuses to detach them + * — toggling story-scoped items off is not a picker concern. They + * leave the selection only when deleted via `ContextItemWriter::delete`. + */ + public function ownedContextItems(): HasMany + { + return $this->hasMany(ContextItem::class); + } + + /** + * Items currently selected as AI context for this Story via the pivot. + * The set is the union of explicit selections from project-scoped items + * and the auto-included story-scoped items. + */ + public function includedContextItems(): BelongsToMany + { + return $this->belongsToMany(ContextItem::class, 'context_item_story') + ->withPivot(['included_at', 'included_by_id']); + } + + /** + * The picker's source set: every ContextItem that *could* be selected + * for this Story — i.e. project-scoped items in the same Project plus + * this Story's own story-scoped items. + * + * @return Builder + */ + public function availableContextItems(): Builder + { + $projectId = $this->feature?->project_id; + + return ContextItem::query() + ->where('project_id', $projectId) + ->where(function ($q): void { + $q->whereNull('story_id')->orWhere('story_id', $this->getKey()); + }); + } + public function dependencies(): BelongsToMany { return $this->belongsToMany(self::class, 'story_dependencies', 'story_id', 'depends_on_story_id'); diff --git a/app/Services/Context/AssetUploader.php b/app/Services/Context/AssetUploader.php new file mode 100644 index 0000000..16663e7 --- /dev/null +++ b/app/Services/Context/AssetUploader.php @@ -0,0 +1,109 @@ +ensureStoryBelongsToProject($project, $story); + $this->validate($file); + + $disk = $this->disk(); + $directory = 'context/'.Str::lower((string) Str::ulid()); + $storedPath = $file->storeAs($directory, $this->safeFilename($file), ['disk' => $disk]); + + if ($storedPath === false) { + throw new \RuntimeException('Failed to store uploaded asset.'); + } + + return ContextItem::create([ + 'project_id' => $project->getKey(), + 'story_id' => $story?->getKey(), + 'type' => ContextItemType::File, + 'title' => $file->getClientOriginalName() ?: basename($storedPath), + 'description' => null, + 'metadata' => [ + 'disk' => $disk, + 'path' => $storedPath, + 'original_name' => $file->getClientOriginalName(), + // Server-detected MIME — getClientMimeType() is user-controlled and + // trivial to spoof. The detected value is what we record and what + // later checks should rely on. + 'mime' => $file->getMimeType() ?: 'application/octet-stream', + 'size' => $file->getSize(), + ], + 'summary_status' => ContextItemSummaryStatus::Pending, + 'created_by_id' => $actor->getKey(), + ]); + } + + private function ensureStoryBelongsToProject(Project $project, ?Story $story): void + { + if ($story === null) { + return; + } + + $storyProjectId = $story->feature?->project_id; + if ((int) $storyProjectId !== (int) $project->getKey()) { + throw new InvalidArgumentException('Story does not belong to the given project.'); + } + } + + private function validate(UploadedFile $file): void + { + $maxKb = (int) config('specify.context.assets.max_file_kb', 10240); + $sizeKb = (int) ceil($file->getSize() / 1024); + if ($maxKb > 0 && $sizeKb > $maxKb) { + throw new InvalidArgumentException("Uploaded file exceeds the {$maxKb} KB limit."); + } + + $allowed = (array) config('specify.context.assets.allowed_mimes', []); + $detected = $file->getMimeType() ?: 'application/octet-stream'; + if ($allowed !== [] && ! in_array($detected, $allowed, true)) { + throw new InvalidArgumentException("MIME type {$detected} is not allowed."); + } + } + + private function safeFilename(UploadedFile $file): string + { + $original = $file->getClientOriginalName() ?: 'upload'; + $base = Str::of(pathinfo($original, PATHINFO_FILENAME))->slug()->limit(80, ''); + $ext = strtolower((string) $file->getClientOriginalExtension()); + + $name = $base->isEmpty() ? 'upload' : (string) $base; + + return $ext === '' ? $name : "{$name}.{$ext}"; + } + + private function disk(): string + { + $disk = (string) config('specify.context.assets.disk', 'private'); + + if (! array_key_exists($disk, config('filesystems.disks', []))) { + throw new \RuntimeException("Configured assets disk '{$disk}' is not defined."); + } + + // Touch the disk so misconfiguration surfaces here (not in storeAs). + Storage::disk($disk); + + return $disk; + } +} diff --git a/app/Services/Context/ContextItemSelector.php b/app/Services/Context/ContextItemSelector.php new file mode 100644 index 0000000..8a359fa --- /dev/null +++ b/app/Services/Context/ContextItemSelector.php @@ -0,0 +1,134 @@ +ensureSameProject($story, $item); + + if ($item->isStoryScoped()) { + throw new InvalidArgumentException('Story-scoped items are auto-included and cannot be toggled.'); + } + + DB::transaction(function () use ($story, $item, $included, $actor): void { + $isAttached = $story->includedContextItems()->whereKey($item->getKey())->exists(); + + if ($included === $isAttached) { + return; + } + + if ($included) { + $story->includedContextItems()->attach($item->getKey(), [ + 'included_at' => now(), + 'included_by_id' => $actor->getKey(), + ]); + } else { + $story->includedContextItems()->detach($item->getKey()); + } + + $this->revisions->recordContentArtifactChanged($story); + }); + } + + /** + * Replace the project-scoped selection for a Story with the given set + * of ContextItem IDs. Story-scoped items remain attached either way — + * they're managed by `ContextItemWriter::createStoryItem` / `delete`, + * never by the picker. + * + * @param array $itemIds Desired included **project-scoped** item IDs. + * Story-scoped IDs are rejected; they cannot be + * detached this way and would conflict with the + * pivot's composite primary key on re-attach. + */ + public function bulkSet(Story $story, array $itemIds, User $actor): void + { + DB::transaction(function () use ($story, $itemIds, $actor): void { + $desired = array_values(array_unique(array_map('intval', $itemIds))); + + if ($desired !== []) { + $items = ContextItem::query()->whereIn('id', $desired)->get(); + if ($items->count() !== count($desired)) { + throw new InvalidArgumentException('One or more context items do not exist.'); + } + + foreach ($items as $item) { + $this->ensureSameProject($story, $item); + if ($item->isStoryScoped()) { + throw new InvalidArgumentException( + "Context item {$item->getKey()} is story-scoped; bulkSet only manages project-scoped selections." + ); + } + } + } + + $currentProjectScoped = $story->includedContextItems() + ->whereNull('context_items.story_id') + ->pluck('context_items.id') + ->map(fn ($id) => (int) $id) + ->all(); + + sort($currentProjectScoped); + $sortedDesired = $desired; + sort($sortedDesired); + + if ($currentProjectScoped === $sortedDesired) { + return; + } + + $toAttach = array_diff($desired, $currentProjectScoped); + $toDetach = array_diff($currentProjectScoped, $desired); + + if ($toDetach !== []) { + $story->includedContextItems()->detach($toDetach); + } + + if ($toAttach !== []) { + $now = now(); + $payload = []; + foreach ($toAttach as $id) { + $payload[$id] = [ + 'included_at' => $now, + 'included_by_id' => $actor->getKey(), + ]; + } + $story->includedContextItems()->attach($payload); + } + + $this->revisions->recordContentArtifactChanged($story); + }); + } + + private function ensureSameProject(Story $story, ContextItem $item): void + { + $storyProjectId = $story->feature?->project_id; + if ($storyProjectId === null) { + throw new InvalidArgumentException('Story is not attached to a Feature with a Project.'); + } + + if ((int) $item->project_id !== (int) $storyProjectId) { + throw new InvalidArgumentException( + "Context item {$item->getKey()} belongs to a different Project than story {$story->getKey()}." + ); + } + } +} diff --git a/app/Services/Context/ContextItemWriter.php b/app/Services/Context/ContextItemWriter.php new file mode 100644 index 0000000..4b946f7 --- /dev/null +++ b/app/Services/Context/ContextItemWriter.php @@ -0,0 +1,202 @@ +|null, + * } $attributes + */ + public function createProjectItem(Project $project, array $attributes, User $actor): ContextItem + { + $this->ensureNonFileType($attributes['type'] ?? null); + + return ContextItem::create([ + 'project_id' => $project->getKey(), + 'story_id' => null, + 'type' => $attributes['type'], + 'title' => $attributes['title'], + 'description' => $attributes['description'] ?? null, + 'metadata' => $attributes['metadata'] ?? null, + 'summary_status' => $this->initialSummaryStatus($attributes['type']), + 'created_by_id' => $actor->getKey(), + ]); + } + + /** + * @param array{ + * type: ContextItemType|string, + * title: string, + * description?: string|null, + * metadata?: array|null, + * } $attributes + */ + public function createStoryItem(Story $story, array $attributes, User $actor): ContextItem + { + $this->ensureNonFileType($attributes['type'] ?? null); + + return DB::transaction(function () use ($story, $attributes, $actor): ContextItem { + $projectId = $this->projectIdFor($story); + + $item = ContextItem::create([ + 'project_id' => $projectId, + 'story_id' => $story->getKey(), + 'type' => $attributes['type'], + 'title' => $attributes['title'], + 'description' => $attributes['description'] ?? null, + 'metadata' => $attributes['metadata'] ?? null, + 'summary_status' => $this->initialSummaryStatus($attributes['type']), + 'created_by_id' => $actor->getKey(), + ]); + + $story->includedContextItems()->syncWithoutDetaching([ + $item->getKey() => [ + 'included_at' => now(), + 'included_by_id' => $actor->getKey(), + ], + ]); + + $this->revisions->recordContentArtifactChanged($story); + + return $item; + }); + } + + /** + * @param array $changes + */ + public function update(ContextItem $item, array $changes, User $actor): ContextItem + { + return DB::transaction(function () use ($item, $changes): ContextItem { + $allowed = ['title', 'description', 'metadata']; + $payload = array_intersect_key($changes, array_flip($allowed)); + + if ($payload === []) { + return $item; + } + + // File-typed items have storage-bearing metadata (disk, path, mime, + // size). Letting callers rewrite those keys would let a tampered + // request retarget `deleteUnderlyingFile()` at someone else's bytes. + // The uploader owns file metadata; updates here are name-only. + if (array_key_exists('metadata', $payload) && $item->type === ContextItemType::File) { + throw new InvalidArgumentException( + 'File metadata is immutable through ContextItemWriter::update — re-upload via AssetUploader.' + ); + } + + $item->forceFill($payload); + + if (! $item->isDirty()) { + return $item; + } + + $item->save(); + + if ($item->isStoryScoped() && $item->story) { + $this->revisions->recordContentArtifactChanged($item->story); + } + + return $item->refresh(); + }); + } + + public function delete(ContextItem $item, User $actor): void + { + DB::transaction(function () use ($item): void { + $story = $item->isStoryScoped() ? $item->story : null; + + $this->deleteUnderlyingFile($item); + + $item->delete(); + + if ($story) { + $this->revisions->recordContentArtifactChanged($story); + } + }); + } + + private function projectIdFor(Story $story): int + { + $projectId = $story->feature?->project_id; + if ($projectId === null) { + throw new InvalidArgumentException('Story is not attached to a Feature with a Project.'); + } + + return (int) $projectId; + } + + private function ensureNonFileType(mixed $type): void + { + $resolved = $type instanceof ContextItemType + ? $type + : ContextItemType::tryFrom((string) $type); + + if ($resolved === ContextItemType::File) { + throw new InvalidArgumentException('Use AssetUploader to create file-typed ContextItems.'); + } + + if ($resolved === null) { + throw new InvalidArgumentException('Unknown ContextItem type.'); + } + } + + private function initialSummaryStatus(mixed $type): ContextItemSummaryStatus + { + $resolved = $type instanceof ContextItemType + ? $type + : ContextItemType::tryFrom((string) $type); + + return $resolved === ContextItemType::Link + ? ContextItemSummaryStatus::Skipped + : ContextItemSummaryStatus::Pending; + } + + private function deleteUnderlyingFile(ContextItem $item): void + { + if ($item->type !== ContextItemType::File) { + return; + } + + $disk = (string) ($item->metadata['disk'] ?? ''); + $path = (string) ($item->metadata['path'] ?? ''); + if ($disk === '' || $path === '') { + return; + } + + // Defense-in-depth against tampered metadata: only delete on a disk + // that's both registered in `filesystems.php` AND the configured + // assets disk. Refuse silently otherwise — better an orphaned blob + // than an arbitrary cross-disk delete. + $configured = (string) config('specify.context.assets.disk', 'private'); + $known = array_key_exists($disk, (array) config('filesystems.disks', [])); + if (! $known || $disk !== $configured) { + return; + } + + Storage::disk($disk)->delete($path); + } +} diff --git a/config/filesystems.php b/config/filesystems.php index 37d8fca..eaa19e5 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -38,6 +38,14 @@ 'report' => false, ], + 'private' => [ + 'driver' => 'local', + 'root' => storage_path('app/private'), + 'visibility' => 'private', + 'throw' => false, + 'report' => false, + ], + 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), diff --git a/config/specify.php b/config/specify.php index 9fb21e2..ff5be2f 100644 --- a/config/specify.php +++ b/config/specify.php @@ -195,5 +195,17 @@ 'window' => env('SPECIFY_CONTEXT_WINDOW', '30.days'), 'max_files' => (int) env('SPECIFY_CONTEXT_MAX_FILES', 10), ], + 'assets' => [ + 'disk' => env('SPECIFY_ASSETS_DISK', 'private'), + 'max_file_kb' => (int) env('SPECIFY_ASSETS_MAX_FILE_KB', 10240), + 'allowed_mimes' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) env( + 'SPECIFY_ASSETS_ALLOWED_MIMES', + 'image/png,image/jpeg,image/webp,image/gif,application/pdf,text/plain,text/markdown' + )) + ))), + 'summary_threshold_chars' => (int) env('SPECIFY_ASSETS_SUMMARY_THRESHOLD', 2000), + ], ], ]; diff --git a/database/factories/ContextItemFactory.php b/database/factories/ContextItemFactory.php new file mode 100644 index 0000000..713a6cf --- /dev/null +++ b/database/factories/ContextItemFactory.php @@ -0,0 +1,64 @@ + + */ +class ContextItemFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'project_id' => Project::factory(), + 'story_id' => null, + 'type' => ContextItemType::Text, + 'title' => fake()->sentence(4), + 'description' => fake()->sentence(), + 'metadata' => ['body' => fake()->paragraph()], + 'summary' => null, + 'summary_status' => ContextItemSummaryStatus::Pending, + 'summary_error' => null, + 'created_by_id' => null, + ]; + } + + public function forText(?string $body = null): self + { + return $this->state(fn () => [ + 'type' => ContextItemType::Text, + 'metadata' => ['body' => $body ?? fake()->paragraph()], + ]); + } + + public function forLink(?string $url = null): self + { + return $this->state(fn () => [ + 'type' => ContextItemType::Link, + 'metadata' => ['url' => $url ?? fake()->url()], + ]); + } + + public function forFile(?string $path = null, ?string $originalName = null, ?string $mime = null): self + { + return $this->state(fn () => [ + 'type' => ContextItemType::File, + 'metadata' => [ + 'disk' => 'private', + 'path' => $path ?? 'context/01HXXX/example.pdf', + 'original_name' => $originalName ?? 'example.pdf', + 'mime' => $mime ?? 'application/pdf', + 'size' => 1024, + ], + ]); + } +} diff --git a/database/migrations/2026_05_10_000001_create_context_items_table.php b/database/migrations/2026_05_10_000001_create_context_items_table.php new file mode 100644 index 0000000..56b7083 --- /dev/null +++ b/database/migrations/2026_05_10_000001_create_context_items_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignId('project_id')->constrained()->cascadeOnDelete(); + $table->foreignId('story_id')->nullable()->constrained()->cascadeOnDelete(); + $table->string('type'); + $table->string('title'); + $table->text('description')->nullable(); + $table->json('metadata')->nullable(); + $table->text('summary')->nullable(); + $table->string('summary_status')->default('pending'); + $table->text('summary_error')->nullable(); + $table->foreignId('created_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->softDeletes(); + $table->timestamps(); + + $table->index(['project_id', 'type']); + $table->index(['project_id', 'story_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('context_items'); + } +}; diff --git a/database/migrations/2026_05_10_000002_create_context_item_story_table.php b/database/migrations/2026_05_10_000002_create_context_item_story_table.php new file mode 100644 index 0000000..3e8416a --- /dev/null +++ b/database/migrations/2026_05_10_000002_create_context_item_story_table.php @@ -0,0 +1,26 @@ +foreignId('story_id')->constrained()->cascadeOnDelete(); + $table->foreignId('context_item_id')->constrained()->cascadeOnDelete(); + $table->timestamp('included_at')->useCurrent(); + $table->foreignId('included_by_id')->nullable()->constrained('users')->nullOnDelete(); + + $table->primary(['story_id', 'context_item_id']); + $table->index('context_item_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('context_item_story'); + } +}; diff --git a/tests/Feature/ContextItem/AssetUploaderTest.php b/tests/Feature/ContextItem/AssetUploaderTest.php new file mode 100644 index 0000000..49305e7 --- /dev/null +++ b/tests/Feature/ContextItem/AssetUploaderTest.php @@ -0,0 +1,94 @@ +create(); + $actor = User::factory()->create(); + $file = UploadedFile::fake()->create('Spec Notes.pdf', 12, 'application/pdf'); + + $item = app(AssetUploader::class)->store($file, $project, null, $actor); + + expect($item->type)->toBe(ContextItemType::File); + expect($item->project_id)->toBe($project->id); + expect($item->story_id)->toBeNull(); + expect($item->summary_status)->toBe(ContextItemSummaryStatus::Pending); + expect($item->created_by_id)->toBe($actor->id); + expect($item->title)->toBe('Spec Notes.pdf'); + expect($item->metadata['original_name'])->toBe('Spec Notes.pdf'); + expect($item->metadata['disk'])->toBe('private'); + + Storage::disk('private')->assertExists($item->metadata['path']); +}); + +test('store accepts a Story when it belongs to the same Project', function () { + $project = Project::factory()->create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create(); + $actor = User::factory()->create(); + + $item = app(AssetUploader::class)->store( + UploadedFile::fake()->create('a.pdf', 4, 'application/pdf'), + $project, + $story, + $actor, + ); + + expect($item->story_id)->toBe($story->id); + expect($item->project_id)->toBe($project->id); +}); + +test('store rejects a Story from a different Project', function () { + $projectA = Project::factory()->create(); + $projectB = Project::factory()->create(); + $featureB = Feature::factory()->for($projectB)->create(); + $story = Story::factory()->for($featureB)->create(); + $actor = User::factory()->create(); + + expect(fn () => app(AssetUploader::class)->store( + UploadedFile::fake()->create('a.pdf', 4, 'application/pdf'), + $projectA, + $story, + $actor, + ))->toThrow(InvalidArgumentException::class); +}); + +test('store rejects files exceeding the size cap', function () { + config(['specify.context.assets.max_file_kb' => 1]); + + $project = Project::factory()->create(); + $actor = User::factory()->create(); + + expect(fn () => app(AssetUploader::class)->store( + UploadedFile::fake()->create('big.pdf', 100, 'application/pdf'), + $project, + null, + $actor, + ))->toThrow(InvalidArgumentException::class); +}); + +test('store rejects MIME types not in the allow-list', function () { + config(['specify.context.assets.allowed_mimes' => ['application/pdf']]); + + $project = Project::factory()->create(); + $actor = User::factory()->create(); + + expect(fn () => app(AssetUploader::class)->store( + UploadedFile::fake()->create('a.exe', 4, 'application/x-msdownload'), + $project, + null, + $actor, + ))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/ContextItem/ContextItemModelTest.php b/tests/Feature/ContextItem/ContextItemModelTest.php new file mode 100644 index 0000000..5beec6d --- /dev/null +++ b/tests/Feature/ContextItem/ContextItemModelTest.php @@ -0,0 +1,90 @@ +create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create(); + + $projectItem = ContextItem::factory()->for($project)->forText('hello')->create(); + $storyItem = ContextItem::factory()->for($project)->for($story)->forText('world')->create(); + + expect($projectItem->isProjectScoped())->toBeTrue(); + expect($projectItem->isStoryScoped())->toBeFalse(); + expect($storyItem->isStoryScoped())->toBeTrue(); + expect($storyItem->isProjectScoped())->toBeFalse(); +}); + +test('bodyForContext returns summary when ready', function () { + $item = ContextItem::factory()->forText('the long body')->create([ + 'summary' => 'short summary', + 'summary_status' => ContextItemSummaryStatus::Ready, + ]); + + expect($item->bodyForContext())->toBe('short summary'); +}); + +test('bodyForContext falls back to truncated raw body when no summary', function () { + $body = str_repeat('a', ContextItem::BODY_FALLBACK_CHARS + 200); + $item = ContextItem::factory()->forText($body)->create([ + 'summary_status' => ContextItemSummaryStatus::Skipped, + ]); + + $rendered = $item->bodyForContext(); + + expect(mb_strlen($rendered))->toBeLessThanOrEqual(ContextItem::BODY_FALLBACK_CHARS + 1); + expect($rendered)->toEndWith('…'); +}); + +test('bodyForContext for link returns the url', function () { + $item = ContextItem::factory()->forLink('https://example.test/spec')->create(); + + expect($item->bodyForContext())->toBe('https://example.test/spec'); +}); + +test('Project hasMany contextItems, Story owned and includedContextItems work', function () { + $project = Project::factory()->create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create(); + + $owned = ContextItem::factory()->for($project)->for($story)->forText()->create(); + $other = ContextItem::factory()->for($project)->forText()->create(); + $story->includedContextItems()->attach($other); + + expect($project->contextItems()->count())->toBe(2); + expect($story->ownedContextItems()->pluck('id')->all())->toBe([$owned->id]); + expect($story->includedContextItems()->pluck('context_items.id')->sort()->values()->all()) + ->toBe([$other->id]); +}); + +test('availableContextItems returns project-scoped + own story items', function () { + $project = Project::factory()->create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create(); + $otherStory = Story::factory()->for($feature)->create(); + + $projectItem = ContextItem::factory()->for($project)->forText()->create(); + $ownItem = ContextItem::factory()->for($project)->for($story)->forText()->create(); + $otherStoryItem = ContextItem::factory()->for($project)->for($otherStory)->forText()->create(); + + $ids = $story->availableContextItems()->pluck('id')->sort()->values()->all(); + $expected = collect([$projectItem->id, $ownItem->id])->sort()->values()->all(); + + expect($ids)->toBe($expected); + expect($ids)->not->toContain($otherStoryItem->id); +}); + +test('cast for type and summary_status returns enums', function () { + $item = ContextItem::factory()->forLink()->create([ + 'summary_status' => ContextItemSummaryStatus::Skipped, + ]); + + expect($item->type)->toBe(ContextItemType::Link); + expect($item->summary_status)->toBe(ContextItemSummaryStatus::Skipped); +}); diff --git a/tests/Feature/ContextItem/ContextItemSelectorTest.php b/tests/Feature/ContextItem/ContextItemSelectorTest.php new file mode 100644 index 0000000..cf5d3ba --- /dev/null +++ b/tests/Feature/ContextItem/ContextItemSelectorTest.php @@ -0,0 +1,118 @@ +create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create([ + 'status' => StoryStatus::Approved, + 'revision' => 1, + ]); + $actor = User::factory()->create(); + + return compact('project', 'feature', 'story', 'actor'); +} + +test('setIncluded toggles attachment and reopens approval once', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = selectorScene(); + $item = ContextItem::factory()->for($project)->forText()->create(); + $beforeRev = $story->fresh()->revision; + + app(ContextItemSelector::class)->setIncluded($story, $item, true, $actor); + + expect($story->includedContextItems()->whereKey($item->id)->exists())->toBeTrue(); + expect($story->fresh()->revision)->toBe($beforeRev + 1); + + // Calling again with same state must be a no-op (no second reopen). + app(ContextItemSelector::class)->setIncluded($story, $item, true, $actor); + expect($story->fresh()->revision)->toBe($beforeRev + 1); + + app(ContextItemSelector::class)->setIncluded($story, $item, false, $actor); + expect($story->includedContextItems()->whereKey($item->id)->exists())->toBeFalse(); + expect($story->fresh()->revision)->toBe($beforeRev + 2); +}); + +test('setIncluded refuses to toggle a story-scoped item', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = selectorScene(); + $item = ContextItem::factory()->for($project)->for($story)->forText()->create(); + + expect(fn () => app(ContextItemSelector::class)->setIncluded($story, $item, false, $actor)) + ->toThrow(InvalidArgumentException::class); +}); + +test('setIncluded refuses cross-project items', function () { + ['story' => $story, 'actor' => $actor] = selectorScene(); + $otherProject = Project::factory()->create(); + $item = ContextItem::factory()->for($otherProject)->forText()->create(); + + expect(fn () => app(ContextItemSelector::class)->setIncluded($story, $item, true, $actor)) + ->toThrow(InvalidArgumentException::class); +}); + +test('bulkSet replaces project-scoped selection in one reopen', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = selectorScene(); + $a = ContextItem::factory()->for($project)->forText()->create(); + $b = ContextItem::factory()->for($project)->forText()->create(); + $c = ContextItem::factory()->for($project)->forText()->create(); + $story->includedContextItems()->attach([$a->id, $b->id]); + $beforeRev = $story->fresh()->revision; + + app(ContextItemSelector::class)->bulkSet($story, [$b->id, $c->id], $actor); + + $included = $story->includedContextItems()->pluck('context_items.id')->sort()->values()->all(); + expect($included)->toBe(collect([$b->id, $c->id])->sort()->values()->all()); + expect($story->fresh()->revision)->toBe($beforeRev + 1); +}); + +test('bulkSet is a no-op when desired set matches current', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = selectorScene(); + $a = ContextItem::factory()->for($project)->forText()->create(); + $story->includedContextItems()->attach([$a->id]); + $beforeRev = $story->fresh()->revision; + + app(ContextItemSelector::class)->bulkSet($story, [$a->id], $actor); + + expect($story->fresh()->revision)->toBe($beforeRev); +}); + +test('bulkSet preserves story-scoped attachments and only manages project-scoped IDs', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = selectorScene(); + $owned = ContextItem::factory()->for($project)->for($story)->forText()->create(); + $story->includedContextItems()->attach([$owned->id]); + + $extra = ContextItem::factory()->for($project)->forText()->create(); + + app(ContextItemSelector::class)->bulkSet($story, [$extra->id], $actor); + + $included = $story->includedContextItems()->pluck('context_items.id')->sort()->values()->all(); + expect($included)->toBe(collect([$owned->id, $extra->id])->sort()->values()->all()); +}); + +test('bulkSet rejects cross-project items', function () { + ['story' => $story, 'actor' => $actor] = selectorScene(); + $otherProject = Project::factory()->create(); + $foreign = ContextItem::factory()->for($otherProject)->forText()->create(); + + expect(fn () => app(ContextItemSelector::class)->bulkSet($story, [$foreign->id], $actor)) + ->toThrow(InvalidArgumentException::class); +}); + +test('bulkSet rejects any story-scoped item (including own)', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = selectorScene(); + $own = ContextItem::factory()->for($project)->for($story)->forText()->create(); + $otherStory = Story::factory()->for($story->feature)->create(); + $alien = ContextItem::factory()->for($project)->for($otherStory)->forText()->create(); + + expect(fn () => app(ContextItemSelector::class)->bulkSet($story, [$alien->id], $actor)) + ->toThrow(InvalidArgumentException::class); + expect(fn () => app(ContextItemSelector::class)->bulkSet($story, [$own->id], $actor)) + ->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Feature/ContextItem/ContextItemWriterTest.php b/tests/Feature/ContextItem/ContextItemWriterTest.php new file mode 100644 index 0000000..d1de841 --- /dev/null +++ b/tests/Feature/ContextItem/ContextItemWriterTest.php @@ -0,0 +1,184 @@ +create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create([ + 'status' => StoryStatus::Approved, + 'revision' => 1, + ]); + $actor = User::factory()->create(); + + return compact('project', 'feature', 'story', 'actor'); +} + +test('createProjectItem creates project-scoped item without reopening any story', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = ciScene(); + + $beforeRev = $story->fresh()->revision; + + $item = app(ContextItemWriter::class)->createProjectItem($project, [ + 'type' => ContextItemType::Text, + 'title' => 'House style', + 'metadata' => ['body' => 'Use Oxford commas.'], + ], $actor); + + expect($item->project_id)->toBe($project->id); + expect($item->story_id)->toBeNull(); + expect($item->summary_status)->toBe(ContextItemSummaryStatus::Pending); + expect($story->fresh()->revision)->toBe($beforeRev); + expect($story->fresh()->status)->toBe(StoryStatus::Approved); +}); + +test('createStoryItem auto-includes and reopens approval', function () { + ['story' => $story, 'actor' => $actor] = ciScene(); + + $item = app(ContextItemWriter::class)->createStoryItem($story, [ + 'type' => ContextItemType::Text, + 'title' => 'Note', + 'metadata' => ['body' => 'Hot edit'], + ], $actor); + + $fresh = $story->fresh(); + expect($item->story_id)->toBe($story->id); + expect($fresh->revision)->toBe(2); + expect($story->includedContextItems()->whereKey($item->id)->exists())->toBeTrue(); +}); + +test('createProjectItem rejects file type', function () { + ['project' => $project, 'actor' => $actor] = ciScene(); + + expect(fn () => app(ContextItemWriter::class)->createProjectItem($project, [ + 'type' => ContextItemType::File, + 'title' => 'x', + ], $actor))->toThrow(InvalidArgumentException::class); +}); + +test('createProjectItem stores link with summary_status=skipped', function () { + ['project' => $project, 'actor' => $actor] = ciScene(); + + $item = app(ContextItemWriter::class)->createProjectItem($project, [ + 'type' => ContextItemType::Link, + 'title' => 'Figma', + 'metadata' => ['url' => 'https://figma.com/x'], + ], $actor); + + expect($item->summary_status)->toBe(ContextItemSummaryStatus::Skipped); +}); + +test('update on project-scoped item does not reopen approval', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = ciScene(); + $item = ContextItem::factory()->for($project)->forText('old')->create(); + $beforeRev = $story->fresh()->revision; + + app(ContextItemWriter::class)->update($item, ['title' => 'New title'], $actor); + + expect($item->fresh()->title)->toBe('New title'); + expect($story->fresh()->revision)->toBe($beforeRev); +}); + +test('update on story-scoped item reopens approval once', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = ciScene(); + $item = ContextItem::factory()->for($project)->for($story)->forText('old')->create(); + $beforeRev = $story->fresh()->revision; + + app(ContextItemWriter::class)->update($item, ['title' => 'Renamed'], $actor); + + expect($story->fresh()->revision)->toBe($beforeRev + 1); +}); + +test('update with no real changes does not save or reopen', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = ciScene(); + $item = ContextItem::factory()->for($project)->for($story)->forText('body')->create([ + 'title' => 'Same', + ]); + $beforeRev = $story->fresh()->revision; + + app(ContextItemWriter::class)->update($item, ['title' => 'Same'], $actor); + + expect($story->fresh()->revision)->toBe($beforeRev); +}); + +test('delete on story-scoped item soft-deletes row, removes file, reopens approval', function () { + Storage::fake('private'); + ['story' => $story, 'project' => $project, 'actor' => $actor] = ciScene(); + + $file = UploadedFile::fake()->create('doc.pdf', 4, 'application/pdf'); + $stored = $file->storeAs('context/'.Str::ulid(), 'doc.pdf', ['disk' => 'private']); + $item = ContextItem::factory()->for($project)->for($story)->forFile($stored, 'doc.pdf', 'application/pdf')->create(); + + Storage::disk('private')->assertExists($stored); + $beforeRev = $story->fresh()->revision; + + app(ContextItemWriter::class)->delete($item, $actor); + + expect(ContextItem::query()->whereKey($item->id)->exists())->toBeFalse(); + expect(ContextItem::withTrashed()->whereKey($item->id)->exists())->toBeTrue(); + Storage::disk('private')->assertMissing($stored); + expect($story->fresh()->revision)->toBe($beforeRev + 1); +}); + +test('update refuses to mutate metadata on file-typed items', function () { + Storage::fake('private'); + ['story' => $story, 'project' => $project, 'actor' => $actor] = ciScene(); + $stored = UploadedFile::fake()->create('doc.pdf', 4, 'application/pdf')->storeAs( + 'context/'.Str::ulid(), 'doc.pdf', ['disk' => 'private'] + ); + $item = ContextItem::factory()->for($project)->for($story)->forFile($stored, 'doc.pdf', 'application/pdf')->create(); + + expect(fn () => app(ContextItemWriter::class)->update($item, [ + 'metadata' => ['disk' => 'public', 'path' => 'someone/elses/file.txt'], + ], $actor))->toThrow(InvalidArgumentException::class); + + // Title-only updates on file items remain allowed. + app(ContextItemWriter::class)->update($item, ['title' => 'Renamed.pdf'], $actor); + expect($item->fresh()->title)->toBe('Renamed.pdf'); +}); + +test('delete refuses to delete from a non-configured disk', function () { + Storage::fake('private'); + Storage::fake('public'); + ['story' => $story, 'project' => $project, 'actor' => $actor] = ciScene(); + + $tampered = UploadedFile::fake()->create('canary.pdf', 4, 'application/pdf')->storeAs( + 'shared/canary', 'canary.pdf', ['disk' => 'public'] + ); + Storage::disk('public')->assertExists($tampered); + + // Item claims `public` disk in metadata but assets are configured to `private`. + $item = ContextItem::factory()->for($project)->for($story)->create([ + 'type' => ContextItemType::File, + 'metadata' => ['disk' => 'public', 'path' => $tampered, 'mime' => 'application/pdf'], + ]); + + app(ContextItemWriter::class)->delete($item, $actor); + + // Row gone, but the canary on the wrong disk survives. + expect(ContextItem::query()->whereKey($item->id)->exists())->toBeFalse(); + Storage::disk('public')->assertExists($tampered); +}); + +test('delete on project-scoped item does not reopen any story', function () { + ['project' => $project, 'story' => $story, 'actor' => $actor] = ciScene(); + $item = ContextItem::factory()->for($project)->forText()->create(); + $beforeRev = $story->fresh()->revision; + + app(ContextItemWriter::class)->delete($item, $actor); + + expect(ContextItem::query()->whereKey($item->id)->exists())->toBeFalse(); + expect($story->fresh()->revision)->toBe($beforeRev); +}); diff --git a/tests/Feature/ContextItem/SchemaTest.php b/tests/Feature/ContextItem/SchemaTest.php new file mode 100644 index 0000000..fc37d30 --- /dev/null +++ b/tests/Feature/ContextItem/SchemaTest.php @@ -0,0 +1,25 @@ +toBeTrue(); + + foreach ([ + 'id', 'project_id', 'story_id', 'type', 'title', 'description', + 'metadata', 'summary', 'summary_status', 'summary_error', + 'created_by_id', 'deleted_at', 'created_at', 'updated_at', + ] as $column) { + expect(Schema::hasColumn('context_items', $column)) + ->toBeTrue("missing column: {$column}"); + } +}); + +test('context_item_story pivot has expected columns', function () { + expect(Schema::hasTable('context_item_story'))->toBeTrue(); + + foreach (['story_id', 'context_item_id', 'included_at', 'included_by_id'] as $column) { + expect(Schema::hasColumn('context_item_story', $column)) + ->toBeTrue("missing column: {$column}"); + } +});