mirror of
https://github.com/aaronpo97/the-biergarten-app.git
synced 2026-10-14 17:54:42 +00:00
Compare commits
2 Commits
81717eddbf
...
dab9931bd9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dab9931bd9 | ||
|
|
132f93cc87 |
@@ -0,0 +1,65 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Repository;
|
||||
using FluentAssertions;
|
||||
using Moq;
|
||||
|
||||
namespace Features.Users.Tests.Queries;
|
||||
|
||||
public class GetPublicUserProfileByIdHandlerTests
|
||||
{
|
||||
private readonly GetPublicUserProfileByIdHandler _handler;
|
||||
private readonly Mock<IUserListRepository> _repoMock = new();
|
||||
|
||||
public GetPublicUserProfileByIdHandlerTests()
|
||||
{
|
||||
_handler = new GetPublicUserProfileByIdHandler(_repoMock.Object);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsPublicProfile_WhenFound()
|
||||
{
|
||||
UserAccount user = new()
|
||||
{
|
||||
UserAccountId = Guid.NewGuid(),
|
||||
Username = "test",
|
||||
FirstName = "Jane",
|
||||
LastName = "Doe",
|
||||
Email = "jane@example.com",
|
||||
DateOfBirth = new DateTime(1990, 1, 1),
|
||||
CreatedAt = new DateTime(2024, 1, 1),
|
||||
};
|
||||
_repoMock.Setup(r => r.GetByIdAsync(user.UserAccountId)).ReturnsAsync(user);
|
||||
|
||||
PublicUserProfileDto result = await _handler.Handle(
|
||||
new GetPublicUserProfileByIdQuery(user.UserAccountId),
|
||||
CancellationToken.None
|
||||
);
|
||||
|
||||
result
|
||||
.Should()
|
||||
.Be(
|
||||
new PublicUserProfileDto(
|
||||
user.UserAccountId,
|
||||
user.Username,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.CreatedAt
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_Throws_WhenNotFound()
|
||||
{
|
||||
Guid id = Guid.NewGuid();
|
||||
_repoMock.Setup(r => r.GetByIdAsync(id)).ReturnsAsync((UserAccount?)null);
|
||||
|
||||
Func<Task<PublicUserProfileDto>> act = async () =>
|
||||
await _handler.Handle(new GetPublicUserProfileByIdQuery(id), CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<NotFoundException>();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Queries.GetAllUsers;
|
||||
using Features.Users.Queries.GetPublicUserProfileById;
|
||||
using Features.Users.Queries.GetUserById;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -37,4 +39,19 @@ public class UserController(IMediator mediator) : ControllerBase
|
||||
UserAccount user = await mediator.Send(new GetUserByIdQuery(id));
|
||||
return Ok(user);
|
||||
}
|
||||
|
||||
/// <summary>Gets the public profile for the user account with the given ID.</summary>
|
||||
/// <remarks>
|
||||
/// Exposes only the fields that are safe to show on an unauthenticated, public profile page;
|
||||
/// unlike <see cref="GetById"/>, this never returns <c>Email</c> or <c>DateOfBirth</c>.
|
||||
/// </remarks>
|
||||
/// <exception cref="NotFoundException">
|
||||
/// Thrown when no user account exists with the given ID, resulting in a 404 response.
|
||||
/// </exception>
|
||||
[HttpGet("{id:guid}/profile")]
|
||||
public async Task<ActionResult<PublicUserProfileDto>> GetPublicProfile(Guid id)
|
||||
{
|
||||
PublicUserProfileDto profile = await mediator.Send(new GetPublicUserProfileByIdQuery(id));
|
||||
return Ok(profile);
|
||||
}
|
||||
}
|
||||
|
||||
11
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
11
web/backend/Features/Features.Users/Dtos/UserDtoMapper.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
using Domain.Entities;
|
||||
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Converts user domain entities into API response models.</summary>
|
||||
public static class UserDtoMapper
|
||||
{
|
||||
/// <summary>Converts a user account into its public-profile representation.</summary>
|
||||
public static PublicUserProfileDto ToPublicProfileDto(this UserAccount user) =>
|
||||
new(user.UserAccountId, user.Username, user.FirstName, user.LastName, user.CreatedAt);
|
||||
}
|
||||
10
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
10
web/backend/Features/Features.Users/Dtos/UserDtos.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace Features.Users.Dtos;
|
||||
|
||||
/// <summary>Represents the subset of a user account that is safe to expose on a public profile.</summary>
|
||||
public record PublicUserProfileDto(
|
||||
Guid UserAccountId,
|
||||
string Username,
|
||||
string FirstName,
|
||||
string LastName,
|
||||
DateTime CreatedAt
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
using Domain.Entities;
|
||||
using Domain.Exceptions;
|
||||
using Features.Users.Dtos;
|
||||
using Features.Users.Repository;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public class GetPublicUserProfileByIdHandler(IUserListRepository repository)
|
||||
: IRequestHandler<GetPublicUserProfileByIdQuery, PublicUserProfileDto>
|
||||
{
|
||||
/// <exception cref="NotFoundException">Thrown when no user account exists with the given ID.</exception>
|
||||
public async Task<PublicUserProfileDto> Handle(
|
||||
GetPublicUserProfileByIdQuery request,
|
||||
CancellationToken cancellationToken
|
||||
)
|
||||
{
|
||||
UserAccount? user = await repository.GetByIdAsync(request.UserAccountId);
|
||||
if (user is null)
|
||||
throw new NotFoundException($"User with ID {request.UserAccountId} not found");
|
||||
return user.ToPublicProfileDto();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
using Features.Users.Dtos;
|
||||
using MediatR;
|
||||
|
||||
namespace Features.Users.Queries.GetPublicUserProfileById;
|
||||
|
||||
public record GetPublicUserProfileByIdQuery(Guid UserAccountId) : IRequest<PublicUserProfileDto>;
|
||||
@@ -8,7 +8,6 @@ const commentItemDescription = `A single comment row on the brewery detail page:
|
||||
const comment: FillerComment = {
|
||||
id: '1',
|
||||
user: 'malt_kettle',
|
||||
userAccountId: 'malt_kettle',
|
||||
initials: 'MK',
|
||||
rating: 5,
|
||||
time: '2 days ago',
|
||||
@@ -55,3 +54,14 @@ export const Liked: Story = {
|
||||
await expect(canvas.getByRole('button', { name: /13 likes/i })).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLinkedAccount: Story = {
|
||||
args: { comment: { ...comment, userAccountId: '11111111-1111-1111-1111-111111111111' } },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByRole('link', { name: 'malt_kettle' })).toHaveAttribute(
|
||||
'href',
|
||||
'/users/11111111-1111-1111-1111-111111111111',
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,9 +20,16 @@ const CommentItem = ({ comment, onToggleLike }: CommentItemProps) => {
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col gap-1 min-w-0">
|
||||
<div className="flex items-baseline gap-2 flex-wrap">
|
||||
<Link to={`/users/${comment.userAccountId}`} className="font-bold text-sm link link-hover">
|
||||
{comment.user}
|
||||
</Link>
|
||||
{comment.userAccountId ? (
|
||||
<Link
|
||||
to={`/users/${comment.userAccountId}`}
|
||||
className="font-bold text-sm link link-hover"
|
||||
>
|
||||
{comment.user}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-bold text-sm">{comment.user}</span>
|
||||
)}
|
||||
<StarRating value={comment.rating} size="xs" />
|
||||
<span className="text-xs text-base-content/60">{comment.time}</span>
|
||||
</div>
|
||||
|
||||
@@ -88,7 +88,7 @@ export const FILLER_BEERS: FillerBeer[] = [
|
||||
export interface FillerComment {
|
||||
id: string;
|
||||
user: string;
|
||||
userAccountId: string;
|
||||
userAccountId?: string;
|
||||
initials: string;
|
||||
rating: number;
|
||||
time: string;
|
||||
@@ -97,11 +97,12 @@ export interface FillerComment {
|
||||
liked: boolean;
|
||||
}
|
||||
|
||||
// `userAccountId` is left unset below since these commenters aren't backed by real accounts
|
||||
// yet; CommentItem only links a commenter's name once a real account GUID is available.
|
||||
export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '1',
|
||||
user: 'malt_kettle',
|
||||
userAccountId: 'malt_kettle',
|
||||
initials: 'MK',
|
||||
rating: 5,
|
||||
time: '2 days ago',
|
||||
@@ -112,7 +113,6 @@ export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '2',
|
||||
user: 'stoutfan_88',
|
||||
userAccountId: 'stoutfan_88',
|
||||
initials: 'SB',
|
||||
rating: 4,
|
||||
time: '1 week ago',
|
||||
@@ -123,7 +123,6 @@ export const FILLER_COMMENTS: FillerComment[] = [
|
||||
{
|
||||
id: '3',
|
||||
user: 'ale_annie',
|
||||
userAccountId: 'ale_annie',
|
||||
initials: 'AL',
|
||||
rating: 4,
|
||||
time: '3 weeks ago',
|
||||
|
||||
@@ -13,7 +13,7 @@ Covers `/users/:id` (`routes/user-profile.tsx`). Mirrors the format of
|
||||
|
||||
| Field | Status | Source | Gap |
|
||||
| ---------------------------------------- | ------ | --------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Username, name, joined date | ✅ | `GET /api/user/{id}` (`UserAccount`) | |
|
||||
| Username, name, joined date | ✅ | `GET /api/user/{id}/profile` (`PublicUserProfileDto`) | |
|
||||
| `isOwnProfile` | ✅ | Compares the session's `userAccountId` to the route param | |
|
||||
| Bio | ⚠️ | `FILLER_PROFILE_META.bio` | `Social.UserProfile.Biography` exists (`IUserProfileRepository`, `UpdateBiographyHandler`) but there's no query/controller route to read another user's profile — only a write path for the authenticated user's own. |
|
||||
| Location | ❌ | `FILLER_PROFILE_META.location` | No location field on `UserAccount` or `UserProfile`. |
|
||||
|
||||
@@ -22,13 +22,19 @@ const meta = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
const panels = {
|
||||
activity: <p>Activity panel</p>,
|
||||
following: <p>Following panel</p>,
|
||||
liked: <p>Liked panel</p>,
|
||||
};
|
||||
|
||||
const ControlledTabs = () => {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
|
||||
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} />;
|
||||
return <ProfileTabs activeTab={activeTab} onChange={setActiveTab} panels={panels} />;
|
||||
};
|
||||
|
||||
export const Default: Story = {
|
||||
args: { activeTab: 'activity', onChange: fn() },
|
||||
args: { activeTab: 'activity', onChange: fn(), panels },
|
||||
render: () => <ControlledTabs />,
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
@@ -36,5 +42,6 @@ export const Default: Story = {
|
||||
await expect(followingTab).toHaveAttribute('aria-selected', 'false');
|
||||
await userEvent.click(followingTab);
|
||||
await expect(followingTab).toHaveAttribute('aria-selected', 'true');
|
||||
await expect(canvas.getByText('Following panel')).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Tab, TabGroup, TabList } from '@headlessui/react';
|
||||
import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export type ProfileTab = 'activity' | 'following' | 'liked';
|
||||
|
||||
@@ -11,9 +12,10 @@ const TABS: { id: ProfileTab; label: string }[] = [
|
||||
interface ProfileTabsProps {
|
||||
activeTab: ProfileTab;
|
||||
onChange: (tab: ProfileTab) => void;
|
||||
panels: Record<ProfileTab, ReactNode>;
|
||||
}
|
||||
|
||||
const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => {
|
||||
const ProfileTabs = ({ activeTab, onChange, panels }: ProfileTabsProps) => {
|
||||
const selectedIndex = TABS.findIndex((tab) => tab.id === activeTab);
|
||||
|
||||
return (
|
||||
@@ -28,6 +30,11 @@ const ProfileTabs = ({ activeTab, onChange }: ProfileTabsProps) => {
|
||||
</Tab>
|
||||
))}
|
||||
</TabList>
|
||||
<TabPanels className="mt-5">
|
||||
{TABS.map((tab) => (
|
||||
<TabPanel key={tab.id}>{panels[tab.id]}</TabPanel>
|
||||
))}
|
||||
</TabPanels>
|
||||
</TabGroup>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { data } from 'react-router';
|
||||
import RouteErrorState from '../../../components/ui/error/RouteErrorState';
|
||||
import { getOptionalAuth } from '../../auth/auth.server';
|
||||
import { getPublicUserProfile } from '../users.server';
|
||||
import { getPublicUserProfile, type PublicUserProfile } from '../users.server';
|
||||
import ProfileSidebarCard from '../components/profile/ProfileSidebarCard';
|
||||
import ProfileTabs, { type ProfileTab } from '../components/profile/ProfileTabs';
|
||||
import ActivityTab from '../components/profile/tabs/ActivityTab';
|
||||
@@ -37,9 +37,17 @@ export const loader = async ({ request, params }: Route.LoaderArgs) => {
|
||||
const joinedLabel = (isoDate: string) =>
|
||||
`Joined ${new Intl.DateTimeFormat('en-US', { month: 'long', year: 'numeric' }).format(new Date(isoDate))}`;
|
||||
|
||||
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { profile, isOwnProfile } = loaderData;
|
||||
interface ProfileContentProps {
|
||||
profile: PublicUserProfile;
|
||||
isOwnProfile: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the profile-scoped interactive state. Keyed by `profile.userAccountId` in the
|
||||
* parent so React remounts it (resetting follow/tab state) when the route's `:id` changes,
|
||||
* since React Router reuses the route component itself across param-only navigations.
|
||||
*/
|
||||
const ProfileContent = ({ profile, isOwnProfile }: ProfileContentProps) => {
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('activity');
|
||||
const [isFollowingProfile, setIsFollowingProfile] = useState(false);
|
||||
const [following, setFollowing] = useState(() =>
|
||||
@@ -91,17 +99,21 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-5 mt-12">
|
||||
<ProfileTabs activeTab={activeTab} onChange={setActiveTab} />
|
||||
|
||||
{activeTab === 'activity' && <ActivityTab activity={FILLER_ACTIVITY} />}
|
||||
{activeTab === 'following' && (
|
||||
<FollowingTab
|
||||
following={following}
|
||||
onToggleFollow={handleToggleFollowRow}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'liked' && <LikedTab liked={FILLER_LIKED} />}
|
||||
<div className="mt-12">
|
||||
<ProfileTabs
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
panels={{
|
||||
activity: <ActivityTab activity={FILLER_ACTIVITY} />,
|
||||
following: (
|
||||
<FollowingTab
|
||||
following={following}
|
||||
onToggleFollow={handleToggleFollowRow}
|
||||
/>
|
||||
),
|
||||
liked: <LikedTab liked={FILLER_LIKED} />,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,6 +121,18 @@ const UserProfile = ({ loaderData }: Route.ComponentProps) => {
|
||||
);
|
||||
};
|
||||
|
||||
const UserProfile = ({ loaderData }: Route.ComponentProps) => {
|
||||
const { profile, isOwnProfile } = loaderData;
|
||||
|
||||
return (
|
||||
<ProfileContent
|
||||
key={profile.userAccountId}
|
||||
profile={profile}
|
||||
isOwnProfile={isOwnProfile}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserProfile;
|
||||
|
||||
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => (
|
||||
|
||||
@@ -11,15 +11,17 @@ export interface PublicUserProfile {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the public-facing slice of a user account. `GET /api/user/{id}` also returns
|
||||
* `email` and `dateOfBirth`, but those stay out of anything rendered on a public profile.
|
||||
* Fetches the public-facing slice of a user account from the dedicated public-profile
|
||||
* endpoint, which never includes `email` or `dateOfBirth`.
|
||||
*/
|
||||
export const getPublicUserProfile = async (
|
||||
userAccountId: string,
|
||||
): Promise<PublicUserProfile | null> => {
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}`);
|
||||
res = await fetch(
|
||||
`${API_BASE_URL}/api/user/${encodeURIComponent(userAccountId)}/profile`,
|
||||
);
|
||||
} catch {
|
||||
throw data('The user service is unreachable right now. Please try again in a moment.', {
|
||||
status: 503,
|
||||
|
||||
@@ -77,11 +77,13 @@ export interface FillerFollowing {
|
||||
userAccountId?: string;
|
||||
}
|
||||
|
||||
// `userAccountId` is left unset for rows below since no backend account backs them yet;
|
||||
// FollowingTab only links a row once a real account GUID is available (see USER_PROFILE_HANDOFF.md).
|
||||
export const FILLER_FOLLOWING: FillerFollowing[] = [
|
||||
{ id: '1', name: 'Breakside Brewery', meta: 'Portland, OR · Brewery' },
|
||||
{ id: '2', name: 'Jonah Weckstein', meta: '@jweck · 142 ratings', userAccountId: 'jweck' },
|
||||
{ id: '2', name: 'Jonah Weckstein', meta: '@jweck · 142 ratings' },
|
||||
{ id: '3', name: 'Wayfinder Beer', meta: 'Portland, OR · Brewery' },
|
||||
{ id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings', userAccountId: 'caseyb' },
|
||||
{ id: '4', name: 'Casey Blume', meta: '@caseyb · 88 ratings' },
|
||||
];
|
||||
|
||||
export interface FillerLiked {
|
||||
|
||||
Reference in New Issue
Block a user