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
42 changes: 42 additions & 0 deletions .github/instructions/sonarqube_mcp.instructions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
---
applyTo: "**/*"
---

These are some guidelines when using the SonarQube MCP server.

# Important Tool Guidelines

## Basic usage
- **IMPORTANT**: After you finish generating or modifying any code files at the very end of the task, you MUST call the `analyze_file_list` tool (if it exists) to analyze the files you created or modified.
- **IMPORTANT**: When starting a new task, you MUST disable automatic analysis with the `toggle_automatic_analysis` tool if it exists.
- **IMPORTANT**: When you are done generating code at the very end of the task, you MUST re-enable automatic analysis with the `toggle_automatic_analysis` tool if it exists.

## Project Keys
- When a user mentions a project key, use `search_my_sonarqube_projects` first to find the exact project key
- Don't guess project keys - always look them up

## Code Language Detection
- When analyzing code snippets, try to detect the programming language from the code syntax
- If unclear, ask the user or make an educated guess based on syntax

## Branch and Pull Request Context
- Many operations support branch-specific analysis
- If user mentions working on a feature branch, include the branch parameter

## Code Issues and Violations
- After fixing issues, do not attempt to verify them using `search_sonar_issues_in_projects`, as the server will not yet reflect the updates

# Common Troubleshooting

## Authentication Issues
- SonarQube requires USER tokens (not project tokens)
- When the error `SonarQube answered with Not authorized` occurs, verify the token type

## Project Not Found
- Use `search_my_sonarqube_projects` to find available projects
- Verify project key spelling and format

## Code Analysis Issues
- Ensure programming language is correctly specified
- Remind users that snippet analysis doesn't replace full project scans
- Provide full file content for better analysis results
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/025-normalize-movie-genres"
"feature_directory": "specs/017-home-movie-cards"
}
6 changes: 5 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"files.exclude": {
"**/.vs":true,
"**/.codex": true,
"**/.ai": true,
"**/.ai": false,
"**/node_modules": true,
"**/bin": true,
"**/obj": true,
Expand All @@ -17,6 +17,10 @@
"search.exclude": {
"**/dist": true,
"**/*.js.map": true
},
"sonarlint.connectedMode.project": {
"connectionId": "marciomyst",
"projectKey": "marciomyst_SmartMovieCatalog"
}

}
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ Do not read, modify, or base analysis on generated/vendor output unless explicit
- `test:`

<!-- SPECKIT START -->
Current Spec Kit plan: `specs/013-view-movie-details/plan.md`.
Current Spec Kit plan: `specs/017-home-movie-cards/plan.md`.

Before using Spec Kit skills, read `.specify/memory/constitution.md`.
If the spec, plan, or implementation touches backend, API, contracts, domain,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ namespace SmartMovieCatalog.Api.Features.Movies.GetMovieById;

public static class GetMovieByIdEndpoint
{
private const string ProblemJsonContentType = "application/problem+json";
public static void MapGetMovieById(this IEndpointRouteBuilder app)
{
app.MapGet("/api/movies/{id}", HandleAsync)
.WithName("GetMovieById")
.WithTags("Movies")
.AllowAnonymous()
.Produces<MovieDetailsResponse>(StatusCodes.Status200OK)
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest, "application/problem+json")
.Produces<ProblemDetails>(StatusCodes.Status404NotFound, "application/problem+json");
.Produces<ProblemDetails>(StatusCodes.Status400BadRequest, ProblemJsonContentType)
.Produces<ProblemDetails>(StatusCodes.Status404NotFound, ProblemJsonContentType);
}

private static async Task<IResult> HandleAsync(
Expand All @@ -30,7 +31,7 @@ private static async Task<IResult> HandleAsync(
return Results.Json(
CreateInvalidMovieIdProblem(httpContext),
statusCode: StatusCodes.Status400BadRequest,
contentType: "application/problem+json");
contentType: ProblemJsonContentType);
}

Result<MovieDetails, GetMovieByIdFailure> result =
Expand All @@ -57,7 +58,7 @@ await messageBus.InvokeAsync<Result<MovieDetails, GetMovieByIdFailure>>(
_ => Results.Json(
CreateMovieNotFoundProblem(httpContext, id),
statusCode: StatusCodes.Status404NotFound,
contentType: "application/problem+json"));
contentType: ProblemJsonContentType));
}

private static ProblemDetails CreateInvalidMovieIdProblem(HttpContext httpContext)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
using SmartMovieCatalog.Domain.Common;

namespace SmartMovieCatalog.Domain.Tests.Common;

public sealed class ValueObjectTests
{
[Fact]
public void Equals_WithSameTypeAndSameComponents_ReturnsTrue()
{
SampleValueObject left = new("same", 7);
SampleValueObject right = new("same", 7);

Assert.True(left.Equals(right));
Assert.Equal(left.GetHashCode(), right.GetHashCode());
}

[Fact]
public void Equals_WithDifferentComponents_ReturnsFalse()
{
SampleValueObject left = new("left", 7);
SampleValueObject right = new("right", 8);

Assert.False(left.Equals(right));
}

[Fact]
public void Equals_WithDifferentRuntimeType_ReturnsFalse()
{
SampleValueObject sample = new("same", 7);
OtherValueObject other = new("same", 7);

Assert.False(sample.Equals(other));
}

private sealed class SampleValueObject : ValueObject
{
private readonly string _name;
private readonly int _version;

public SampleValueObject(string name, int version)
{
_name = name;
_version = version;
}

protected override IEnumerable<object?> GetEqualityComponents()
{
yield return _name;
yield return _version;
}
}

private sealed class OtherValueObject : ValueObject
{
private readonly string _name;
private readonly int _version;

public OtherValueObject(string name, int version)
{
_name = name;
_version = version;
}

protected override IEnumerable<object?> GetEqualityComponents()
{
yield return _name;
yield return _version;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -109,11 +109,28 @@ public void Create_WithoutExternalIdAndImage_KeepsValuesNull()
[InlineData("https://image.tmdb.org/t/p/w500/example.jpg")]
[InlineData("//image.tmdb.org/t/p/w500/example.jpg")]
[InlineData("p/example-card.jpg")]
[InlineData("/p\\example-card.jpg")]
public void Create_WithNonRelativeImage_Throws(string image)
{
Assert.Throws<ArgumentException>(() => CreateMovie(image: image));
}

[Fact]
public void Create_WithRelativeImagePath_KeepsNormalizedPath()
{
Movie movie = CreateMovie(image: "/p/example-card.jpg");

Assert.Equal("/p/example-card.jpg", movie.Image);
}

[Fact]
public void Create_WithLowercaseCountryCode_NormalizesToUppercase()
{
Movie movie = CreateMovie(countryCode: "br");

Assert.Equal("BR", movie.CountryCode);
}

[Fact]
public void Genre_WithBlankName_Throws()
{
Expand Down
18 changes: 18 additions & 0 deletions backend/tests/SmartMovieCatalog.Domain.Tests/Users/UserIdTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,22 @@ public void From_WithSameGuid_CreatesEqualIds()

Assert.Equal(UserId.From(value), UserId.From(value));
}

[Fact]
public void From_WithDifferentGuid_CreatesDifferentIds()
{
UserId first = UserId.From(Guid.NewGuid());
UserId second = UserId.From(Guid.NewGuid());

Assert.NotEqual(first, second);
}

[Fact]
public void Equals_WithNullOrDifferentType_ReturnsFalse()
{
UserId userId = UserId.New();

Assert.False(userId.Equals(null));
Assert.False(userId.Equals("not-a-user-id"));
}
}
5 changes: 3 additions & 2 deletions docs/FRONTEND.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Frontend

## Current State
The frontend is an Angular 21 SPA in `frontend`. The root route renders the authentication login page, and `/catalog` renders the public movie catalog browsing page.
The frontend is an Angular 21 SPA in `frontend`. The root route renders the public home page movie section, `/login` renders the authentication login page, and `/catalog` renders the public movie catalog browsing page.

Primary references:

Expand All @@ -11,7 +11,8 @@ Primary references:
- `frontend/DESIGN.md`

## Routing
- `/` displays the authentication login page.
- `/` displays the public home page with movie cards.
- `/login` displays the authentication login page.
- `/catalog` displays the public V1 catalog page.
- `/movies/:id` displays movie details for a single movie.
- `/catalog` reads `query`, `page`, and `pageSize` from URL query parameters.
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/app/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@
<header class="top-nav">
<a class="brand-link" routerLink="/">Smart Movie Catalog</a>
<nav aria-label="Primary navigation">
<a routerLink="/" [routerLinkActiveOptions]="{ exact: true }" routerLinkActive="active-link">Home</a>
<a routerLink="/catalog" routerLinkActive="active-link">Catalog</a>
<a routerLink="/login" routerLinkActive="active-link">Login</a>
</nav>
</header>

Expand Down
34 changes: 32 additions & 2 deletions frontend/src/app/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { of } from 'rxjs';
import { App } from './app';
import { LoginPage } from './auth/login-page/login-page';
import { CatalogPage } from './catalog/catalog-page/catalog-page';
import { HomePage } from './home/home-page/home-page';
import { MovieDetails, PagedMovieSummaryResponse } from './movies/movie.models';
import { MovieDetailsPage } from './movies/movie-details-page/movie-details-page';
import { MoviesApi } from './movies/movies-api';
Expand Down Expand Up @@ -45,7 +46,8 @@ describe('App', () => {
imports: [App],
providers: [
provideRouter([
{ path: '', component: LoginPage },
{ path: '', component: HomePage },
{ path: 'login', component: LoginPage },
{ path: 'catalog', component: CatalogPage },
{ path: 'movies/:id', component: MovieDetailsPage }
]),
Expand All @@ -70,12 +72,27 @@ describe('App', () => {
expect(component).toBeTruthy();
});

it('should render the app brand and catalog navigation link', () => {
it('should render the app brand and primary navigation links', () => {
fixture.detectChanges();

const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('.brand-link')?.textContent).toContain('Smart Movie Catalog');
expect(compiled.querySelector('nav a[routerLink="/"]')?.textContent).toContain('Home');
expect(compiled.querySelector('a[routerLink="/catalog"]')?.textContent).toContain('Catalog');
expect(compiled.querySelector('a[routerLink="/login"]')?.textContent).toContain('Login');
});

it('should render the public home route', async () => {
fixture.detectChanges();

await router.navigateByUrl('/');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-home-page')).toBeTruthy();
expect(compiled.textContent).toContain('Latest from the catalog');
});

it('should render the public catalog route without an auth guard', async () => {
Expand Down Expand Up @@ -103,4 +120,17 @@ describe('App', () => {
expect(compiled.querySelector('app-movie-details-page')).toBeTruthy();
expect(compiled.textContent).toContain('Central do Brasil');
});

it('should render the login route', async () => {
fixture.detectChanges();

await router.navigateByUrl('/login');
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();

const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('app-login-page')).toBeTruthy();
expect(compiled.textContent).toContain('Smart Movie Catalog');
});
});
57 changes: 57 additions & 0 deletions frontend/src/app/catalog/catalog-page/catalog-page.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,4 +244,61 @@ describe('CatalogPage', () => {
const link = fixture.nativeElement.querySelector('.movie-card') as HTMLAnchorElement;
expect(link.getAttribute('href')).toBe('/movies/movie-1');
});

it('should render singular result summary when only one movie is available', () => {
moviesApi.listMovies.mockReturnValue(of({
...movieResponse,
totalCount: 1
}));
createComponent();

const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.textContent).toContain('1 movie');
});

it('should not navigate to previous/next page when pagination is unavailable', () => {
moviesApi.listMovies.mockReturnValue(of({
...movieResponse,
hasPreviousPage: false,
hasNextPage: false
}));
createComponent();

const component = fixture.componentInstance as unknown as {
previousPage: () => void;
nextPage: () => void;
};

component.previousPage();
component.nextPage();

expect(router.navigate).not.toHaveBeenCalled();
});

it('should keep absolute poster URLs unchanged', () => {
moviesApi.listMovies.mockReturnValue(of({
...movieResponse,
items: [{
...movieResponse.items[0],
posterUrl: 'https://image.tmdb.org/t/p/w342/external.jpg'
}]
}));
createComponent();

const image = fixture.nativeElement.querySelector('.poster-frame img') as HTMLImageElement;
expect(image.src).toBe('https://image.tmdb.org/t/p/w342/external.jpg');
});

it('should avoid duplicate API calls when query state does not change', () => {
createComponent();
moviesApi.listMovies.mockClear();

queryParamMap.next(convertToParamMap({
query: '',
page: '1',
pageSize: '12'
}));

expect(moviesApi.listMovies).not.toHaveBeenCalled();
});
});
Loading
Loading