Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 157 additions & 0 deletions .specify/inputs/github-issues/16-basic-title-search.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
# GitHub Issue Context

## Source

- Repository: marciomyst/SmartMovieCatalog
- Issue: #16
- URL: https://github.com/marciomyst/SmartMovieCatalog/issues/16
- State: OPEN
- Created: 05/02/2026 23:08:24
- Updated: 05/02/2026 23:08:25
- Milestone: M1 — Core Movie Catalog

## Title

Basic title search

## Labels

- type:feature
- priority:high
- area:api
- area:backend
- area:frontend
- area:catalog
- area:search
- v1

## Assignees

_No assignees_



## Issue Body

## Summary

Implement basic movie search by title.

This is a simple catalog search feature for V1 and must not be confused with semantic search, RAG, vector search, or AI-based discovery.

## Goal

Allow users to find movies by title using a basic text query.

## Scope

- Add title query support to the movie listing endpoint.
- Search against movie title and optionally original title.
- Add frontend search input where movie lists are displayed.
- Show loading, empty result, and error states.
- Keep search behavior simple and predictable.

## Suggested API

```http
GET /api/movies?query=central&page=1&pageSize=12
```

Response media type:

```http
application/json
```

The response should use the same paginated response shape as `GET /api/movies`:

```json
{
"items": [],
"page": 1,
"pageSize": 12,
"totalCount": 0,
"totalPages": 0,
"hasPreviousPage": false,
"hasNextPage": false
}
```

Example invalid request response:

```http
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
```

```json
{
"type": "https://smartmoviecatalog.dev/problems/invalid-request",
"title": "Invalid request.",
"status": 400,
"detail": "The request could not be processed because it is malformed or contains invalid parameters.",
"instance": "/api/movies",
"errors": {
"page": [
"Page must be greater than or equal to 1."
]
}
}
```

## Acceptance Criteria

- User can search movies by title.
- Search is case-insensitive if supported by the chosen implementation.
- Empty query returns the default movie list.
- No results displays a clear empty state.
- Search works with pagination.
- Search response uses the same paginated response shape as the movie listing endpoint.
- Invalid pagination or query parameters return `400 Bad Request` using `application/problem+json` with `invalid-request`.
- Frontend uses a typed API service.
- Search does not use AI, embeddings, Qdrant, RAG, CLIP, or semantic ranking.
- API response shape remains stable.

## Technical Notes

- Keep this as basic title search only.
- Avoid introducing search infrastructure prematurely.
- Do not add a dedicated search endpoint for V1.
- If persistence is not implemented yet, search should use the current storage mechanism consistently.
- Debounce on the frontend only if it fits the existing Angular patterns.

## Out of Scope

- Semantic search.
- AI tags search.
- Full-text search engine.
- Ranking algorithms.
- Genre/year filters.
- Poster visual search.


## Comments

_No comments_

## Instructions for Spec Kit

Use this GitHub issue as the primary source of truth.

Convert the issue into a Spec Kit feature specification before creating the implementation plan.

Preserve:

- business goal;
- user stories;
- acceptance criteria;
- technical constraints;
- non-goals;
- dependencies;
- open questions.

If information is missing, add it under a clearly marked **Clarifications Needed** section instead of inventing requirements.

If the issue conflicts with existing project documentation, explicitly call out the conflict.

Prefer a small, incremental implementation plan aligned with the repository's existing architecture, folder structure, language, framework, and conventions.
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,49 @@ await client.PostAsJsonAsync(
Assert.Equal("/p/central.jpg", item.PosterUrl);
}

[Fact]
public async Task ListMovies_WithQueryAndPaging_ReturnsPagedMetadata()
{
HttpClient client = _factory.CreateClient();
await client.PostAsJsonAsync(
"/api/movies",
CreateMovieEndpointTests.ValidRequest() with
{
Title = "Central do Brasil",
OriginalTitle = "Central Station",
Image = "/p/central.jpg"
});
await client.PostAsJsonAsync(
"/api/movies",
CreateMovieEndpointTests.ValidRequest() with
{
Title = "Central Park",
OriginalTitle = null,
Image = "/p/central-park.jpg"
});
await client.PostAsJsonAsync(
"/api/movies",
CreateMovieEndpointTests.ValidRequest() with
{
Title = "Cidade de Deus",
OriginalTitle = null,
Image = "/p/cidade.jpg"
});

HttpResponseMessage response = await client.GetAsync("/api/movies?query=central&page=1&pageSize=1");

Assert.Equal(HttpStatusCode.OK, response.StatusCode);
PagedMovieSummaryResponse? body = await response.Content.ReadFromJsonAsync<PagedMovieSummaryResponse>();
Assert.NotNull(body);
Assert.Equal(1, body.Page);
Assert.Equal(1, body.PageSize);
Assert.Equal(2, body.TotalCount);
Assert.Equal(2, body.TotalPages);
Assert.False(body.HasPreviousPage);
Assert.True(body.HasNextPage);
Assert.Single(body.Items);
}

[Fact]
public async Task ListMovies_WithInvalidPaging_ReturnsValidationProblem()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,45 @@ public async Task ListAsync_WithQueryAndPaging_ReturnsMatchingMovies()
Assert.Contains(movie.Genres, movieGenre => movieGenre.Genre.Name == "Drama");
}

[Fact]
public async Task ListAsync_WithQueryMatchingOriginalTitle_ReturnsMatchingMovies()
{
await using SmartMovieCatalogDbContext dbContext = CreateDbContext();
EfMovieRepository repository = new(dbContext);
Genre genre = Genre.Create(GenreId.New(), "Drama", externalId: null);
Movie movieWithOriginalTitleMatch = CreateMovie("Central do Brasil", genre, originalTitle: "Central Station");
Movie movieWithoutMatch = CreateMovie("Cidade de Deus", genre, originalTitle: "Cidade de Deus");

await repository.AddAsync(movieWithOriginalTitleMatch, CancellationToken.None);
await repository.AddAsync(movieWithoutMatch, CancellationToken.None);
await repository.SaveChangesAsync(CancellationToken.None);

SmartMovieCatalog.Application.Abstractions.Persistence.PagedResult<Movie> result =
await repository.ListAsync("station", 1, 12, CancellationToken.None);

Movie movie = Assert.Single(result.Items);
Assert.Equal(movieWithOriginalTitleMatch.Id, movie.Id);
Assert.Equal(1, result.TotalCount);
}

[Fact]
public async Task ListAsync_WithCaseInsensitiveQuery_ReturnsMatchingMovies()
{
await using SmartMovieCatalogDbContext dbContext = CreateDbContext();
EfMovieRepository repository = new(dbContext);
Genre genre = Genre.Create(GenreId.New(), "Drama", externalId: null);
Movie movie = CreateMovie("Central do Brasil", genre);

await repository.AddAsync(movie, CancellationToken.None);
await repository.SaveChangesAsync(CancellationToken.None);

SmartMovieCatalog.Application.Abstractions.Persistence.PagedResult<Movie> result =
await repository.ListAsync("CeNtRaL", 1, 12, CancellationToken.None);

Assert.Single(result.Items);
Assert.Equal(movie.Id, result.Items.Single().Id);
}

[Fact]
public async Task GetByIdAsync_WithExistingMovie_ReturnsMovieWithGenres()
{
Expand Down Expand Up @@ -117,12 +156,12 @@ public void Model_MapsGenreExternalIdAsUniqueAndMovieGenresAsAssociation()
Assert.NotNull(movieGenreEntity.FindProperty(nameof(MovieGenre.GenreId)));
}

private static Movie CreateMovie(string title, Genre genre)
private static Movie CreateMovie(string title, Genre genre, string? originalTitle = null)
{
return Movie.Create(
MovieId.New(),
title,
originalTitle: null,
originalTitle: originalTitle,
2024,
"BR",
"pt-BR",
Expand Down
97 changes: 97 additions & 0 deletions specs/016-basic-title-search/.spec-context.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
{
"workflow": "speckit",
"specName": "016-basic-title-search",
"branch": "016-basic-title-search",
"selectedAt": "2026-05-10T19:52:28.164Z",
"currentStep": "implement",
"status": "completed",
"stepHistory": {
"tasks": {
"startedAt": "2026-05-10T20:18:03.428Z",
"completedAt": "2026-05-10T20:22:19.816Z"
},
"implement": {
"startedAt": "2026-05-10T20:22:19.819Z",
"completedAt": "2026-05-10T20:25:57.2693472Z",
"substeps": [
{
"name": "run-tests",
"startedAt": "2026-05-10T20:25:57.2693472Z",
"completedAt": "2026-05-10T20:25:57.2693472Z"
}
]
}
},
"transitions": [
{
"step": "tasks",
"substep": null,
"from": {
"step": "tasks",
"substep": null
},
"by": "extension",
"at": "2026-05-10T20:18:03.428Z"
},
{
"step": "implement",
"substep": null,
"from": {
"step": "specify",
"substep": null
},
"by": "extension",
"at": "2026-05-10T20:18:03.432Z"
},
{
"step": "tasks",
"substep": null,
"from": {
"step": "tasks",
"substep": null
},
"by": "extension",
"at": "2026-05-10T20:22:19.816Z"
},
{
"step": "implement",
"substep": null,
"from": {
"step": "implement",
"substep": null
},
"by": "extension",
"at": "2026-05-10T20:22:19.819Z"
},
{
"step": "implement",
"substep": "run-tests",
"from": {
"step": "implement",
"substep": null
},
"by": "ai",
"at": "2026-05-10T20:25:57.2693472Z"
},
{
"step": "implement",
"substep": null,
"from": {
"step": "implement",
"substep": null
},
"by": "extension",
"at": "2026-05-10T20:37:55.583Z"
},
{
"step": "implement",
"substep": null,
"from": {
"step": "implement",
"substep": null
},
"by": "extension",
"at": "2026-05-10T21:36:07.397Z"
}
]
}
Loading
Loading