From 28807541cc4848f20f997006d7842c90fc2520c9 Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Wed, 15 May 2024 15:17:41 +0200 Subject: [PATCH 1/5] Step 5: Move logic from fetch functions to services --- src/api/feed/api.ts | 14 +------------- src/api/feed/index.ts | 2 +- src/api/feed/service.ts | 21 +++++++++++++++++++++ src/api/media/api.ts | 9 ++------- src/api/media/index.ts | 2 +- src/api/media/service.ts | 11 +++++++++++ src/api/shout/api.ts | 7 ++----- src/api/shout/index.ts | 2 +- src/api/shout/service.ts | 15 +++++++++++++++ src/api/user/api.ts | 13 +++---------- src/api/user/index.ts | 2 +- src/api/user/service.ts | 25 +++++++++++++++++++++++++ src/components/shout/reply-dialog.tsx | 2 +- 13 files changed, 85 insertions(+), 40 deletions(-) create mode 100644 src/api/feed/service.ts create mode 100644 src/api/media/service.ts create mode 100644 src/api/shout/service.ts create mode 100644 src/api/user/service.ts diff --git a/src/api/feed/api.ts b/src/api/feed/api.ts index fd95a28..1587b9a 100644 --- a/src/api/feed/api.ts +++ b/src/api/feed/api.ts @@ -1,22 +1,10 @@ import { apiClient } from "../client"; -import { ImageDto } from "../media/dto"; -import { dtoToImage } from "../media/transform"; -import { dtoToShout } from "../shout/transform"; -import { UserDto } from "../user/dto"; -import { dtoToUser } from "../user/transform"; import { FeedResponse } from "./dto"; async function getFeed() { const response = await apiClient.get("/feed"); - const shouts = response.data.data.map(dtoToShout); - const users = response.data.included - .filter((u): u is UserDto => u.type === "user") - .map(dtoToUser); - const images = response.data.included - .filter((i): i is ImageDto => i.type === "image") - .map(dtoToImage); - return { shouts, users, images }; + return response.data; } export default { getFeed }; diff --git a/src/api/feed/index.ts b/src/api/feed/index.ts index 9e19686..4317dae 100644 --- a/src/api/feed/index.ts +++ b/src/api/feed/index.ts @@ -1 +1 @@ -export { default } from "./api"; +export { default } from "./service"; diff --git a/src/api/feed/service.ts b/src/api/feed/service.ts new file mode 100644 index 0000000..e1f41f0 --- /dev/null +++ b/src/api/feed/service.ts @@ -0,0 +1,21 @@ +import { ImageDto } from "../media/dto"; +import { dtoToImage } from "../media/transform"; +import { dtoToShout } from "../shout/transform"; +import { UserDto } from "../user/dto"; +import { dtoToUser } from "../user/transform"; + +import FeedApi from "./api"; + +async function getFeed() { + const response = await FeedApi.getFeed(); + const shouts = response.data.map(dtoToShout); + const users = response.included + .filter((u): u is UserDto => u.type === "user") + .map(dtoToUser); + const images = response.included + .filter((i): i is ImageDto => i.type === "image") + .map(dtoToImage); + return { shouts, users, images }; +} + +export default { getFeed }; diff --git a/src/api/media/api.ts b/src/api/media/api.ts index 9d1e182..219614f 100644 --- a/src/api/media/api.ts +++ b/src/api/media/api.ts @@ -1,15 +1,10 @@ import { apiClient } from "../client"; import { ImageDto } from "./dto"; -import { dtoToImage } from "./transform"; - -async function uploadImage(file: File) { - const formData = new FormData(); - formData.append("image", file); +async function uploadImage(formData: FormData) { const response = await apiClient.post<{ data: ImageDto }>("/image", formData); - const imageDto = response.data.data; - return dtoToImage(imageDto); + return response.data; } export default { uploadImage }; diff --git a/src/api/media/index.ts b/src/api/media/index.ts index 9e19686..4317dae 100644 --- a/src/api/media/index.ts +++ b/src/api/media/index.ts @@ -1 +1 @@ -export { default } from "./api"; +export { default } from "./service"; diff --git a/src/api/media/service.ts b/src/api/media/service.ts new file mode 100644 index 0000000..c7172f5 --- /dev/null +++ b/src/api/media/service.ts @@ -0,0 +1,11 @@ +import MediaApi from "./api"; +import { dtoToImage } from "./transform"; + +async function saveImage(file: File) { + const formData = new FormData(); + formData.append("image", file); + const { data: imageDto } = await MediaApi.uploadImage(formData); + return dtoToImage(imageDto); +} + +export default { saveImage }; diff --git a/src/api/shout/api.ts b/src/api/shout/api.ts index d5ce43f..d381aa0 100644 --- a/src/api/shout/api.ts +++ b/src/api/shout/api.ts @@ -1,12 +1,10 @@ import { apiClient } from "../client"; import { CreateShoutInput, CreateShoutReplyInput, ShoutDto } from "./dto"; -import { dtoToShout } from "./transform"; async function createShout(input: CreateShoutInput) { const response = await apiClient.post<{ data: ShoutDto }>(`/shout`, input); - const shoutDto = response.data.data; - return dtoToShout(shoutDto); + return response.data; } async function createReply({ shoutId, replyId }: CreateShoutReplyInput) { @@ -14,8 +12,7 @@ async function createReply({ shoutId, replyId }: CreateShoutReplyInput) { `/shout/${shoutId}/reply`, { replyId } ); - const replyDto = response.data.data; - return dtoToShout(replyDto); + return response.data; } export default { createShout, createReply }; diff --git a/src/api/shout/index.ts b/src/api/shout/index.ts index 9e19686..4317dae 100644 --- a/src/api/shout/index.ts +++ b/src/api/shout/index.ts @@ -1 +1 @@ -export { default } from "./api"; +export { default } from "./service"; diff --git a/src/api/shout/service.ts b/src/api/shout/service.ts new file mode 100644 index 0000000..2a819da --- /dev/null +++ b/src/api/shout/service.ts @@ -0,0 +1,15 @@ +import ShoutApi from "./api"; +import { CreateShoutInput, CreateShoutReplyInput } from "./dto"; +import { dtoToShout } from "./transform"; + +async function createShout(input: CreateShoutInput) { + const { data: shoutDto } = await ShoutApi.createShout(input); + return dtoToShout(shoutDto); +} + +async function createReply({ shoutId, replyId }: CreateShoutReplyInput) { + const { data: replyDto } = await ShoutApi.createReply({ shoutId, replyId }); + return dtoToShout(replyDto); +} + +export default { createShout, createReply }; diff --git a/src/api/user/api.ts b/src/api/user/api.ts index 0e85dd9..e0c093c 100644 --- a/src/api/user/api.ts +++ b/src/api/user/api.ts @@ -1,29 +1,22 @@ import { apiClient } from "../client"; -import { dtoToImage } from "../media/transform"; -import { dtoToShout } from "../shout/transform"; import { MeDto, UserDto, UserShoutsResponse } from "./dto"; -import { dtoToMe, dtoToUser } from "./transform"; async function getMe() { const response = await apiClient.get<{ data: MeDto }>("/me"); - const meDto = response.data.data; - return dtoToMe(meDto); + return response.data; } async function getUser(handle: string) { const response = await apiClient.get<{ data: UserDto }>(`/user/${handle}`); - const userDto = response.data.data; - return dtoToUser(userDto); + return response.data; } async function getUserShouts(handle: string) { const response = await apiClient.get( `/user/${handle}/shouts` ); - const shouts = response.data.data.map(dtoToShout); - const images = response.data.included.map(dtoToImage); - return { shouts, images }; + return response.data; } export default { getMe, getUser, getUserShouts }; diff --git a/src/api/user/index.ts b/src/api/user/index.ts index 9e19686..4317dae 100644 --- a/src/api/user/index.ts +++ b/src/api/user/index.ts @@ -1 +1 @@ -export { default } from "./api"; +export { default } from "./service"; diff --git a/src/api/user/service.ts b/src/api/user/service.ts new file mode 100644 index 0000000..909bdf7 --- /dev/null +++ b/src/api/user/service.ts @@ -0,0 +1,25 @@ +import { dtoToImage } from "../media/transform"; +import { dtoToShout } from "../shout/transform"; + +import UserApi from "./api"; +import { dtoToMe, dtoToUser } from "./transform"; + +async function getMe() { + const { data: meDto } = await UserApi.getMe(); + return dtoToMe(meDto); +} + +async function getUser(handle: string) { + const { data: userDto } = await UserApi.getUser(handle); + return dtoToUser(userDto); +} + +async function getUserShouts(handle: string) { + const { data: shoutDtos, included: imageDtos } = + await UserApi.getUserShouts(handle); + const shouts = shoutDtos.map(dtoToShout); + const images = imageDtos.map(dtoToImage); + return { shouts, images }; +} + +export default { getMe, getUser, getUserShouts }; diff --git a/src/components/shout/reply-dialog.tsx b/src/components/shout/reply-dialog.tsx index 70e3f35..2374d89 100644 --- a/src/components/shout/reply-dialog.tsx +++ b/src/components/shout/reply-dialog.tsx @@ -58,7 +58,7 @@ export function ReplyDialog({ children, shoutId }: ReplyDialogProps) { let image; if (files?.length) { - image = await MediaApi.uploadImage(files[0]); + image = await MediaApi.saveImage(files[0]); } const newShout = await ShoutApi.createShout({ From 4ca1173d5b923329aa357ced28d3141038295f80 Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Wed, 15 May 2024 15:18:03 +0200 Subject: [PATCH 2/5] Step 5: Use services in components --- src/components/header/header.tsx | 8 ++++---- src/components/login-dialog/login-dialog.tsx | 4 ++-- src/components/shout/reply-dialog.tsx | 14 +++++++------- src/pages/feed/feed.tsx | 4 ++-- src/pages/user-profile/user-profile.tsx | 6 +++--- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx index 24b0fe6..4371161 100644 --- a/src/components/header/header.tsx +++ b/src/components/header/header.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import AuthApi from "@/api/auth"; -import UserApi from "@/api/user"; +import AuthService from "@/api/auth"; +import UserService from "@/api/user"; import { LoginDialog } from "@/components/login-dialog"; import { Button } from "@/components/ui/button"; import { Me } from "@/domain"; @@ -14,7 +14,7 @@ export function Header() { const [hasError, setHasError] = useState(false); useEffect(() => { - UserApi.getMe() + UserService.getMe() .then((me) => setMe(me)) .catch(() => setHasError(true)) .finally(() => setIsLoadingMe(false)); @@ -22,7 +22,7 @@ export function Header() { async function logout() { setIsLoadingLogout(true); - await AuthApi.logout(); + await AuthService.logout(); setIsLoadingLogout(false); window.location.reload(); } diff --git a/src/components/login-dialog/login-dialog.tsx b/src/components/login-dialog/login-dialog.tsx index 57054a4..6595a85 100644 --- a/src/components/login-dialog/login-dialog.tsx +++ b/src/components/login-dialog/login-dialog.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import AuthApi from "@/api/auth"; +import AuthService from "@/api/auth"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -37,7 +37,7 @@ export function LoginDialog({ children }: LoginDialogProps) { const username = event.currentTarget.elements.username.value; const password = event.currentTarget.elements.password.value; - await AuthApi.login({ username, password }); + await AuthService.login({ username, password }); setIsLoading(false); setOpen(false); diff --git a/src/components/shout/reply-dialog.tsx b/src/components/shout/reply-dialog.tsx index 2374d89..51515fa 100644 --- a/src/components/shout/reply-dialog.tsx +++ b/src/components/shout/reply-dialog.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; -import MediaApi from "@/api/media"; -import ShoutApi from "@/api/shout"; -import UserApi from "@/api/user"; +import MediaService from "@/api/media"; +import ShoutService from "@/api/shout"; +import UserService from "@/api/user"; import { LoginDialog } from "@/components/login-dialog"; import { Button } from "@/components/ui/button"; import { @@ -39,7 +39,7 @@ export function ReplyDialog({ children, shoutId }: ReplyDialogProps) { const [hasError, setHasError] = useState(false); useEffect(() => { - UserApi.getMe() + UserService.getMe() .then((me) => setIsAuthenticated(Boolean(me))) .catch(() => setHasError(true)) .finally(() => setIsLoading(false)); @@ -58,15 +58,15 @@ export function ReplyDialog({ children, shoutId }: ReplyDialogProps) { let image; if (files?.length) { - image = await MediaApi.saveImage(files[0]); + image = await MediaService.saveImage(files[0]); } - const newShout = await ShoutApi.createShout({ + const newShout = await ShoutService.createShout({ message, imageId: image?.id, }); - await ShoutApi.createReply({ + await ShoutService.createReply({ shoutId, replyId: newShout.id, }); diff --git a/src/pages/feed/feed.tsx b/src/pages/feed/feed.tsx index 1e03fb6..21decef 100644 --- a/src/pages/feed/feed.tsx +++ b/src/pages/feed/feed.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import FeedApi from "@/api/feed"; +import FeedService from "@/api/feed"; import { LoadingView } from "@/components/loading"; import { ShoutList } from "@/components/shout-list"; import { Image, Shout, User } from "@/domain"; @@ -14,7 +14,7 @@ export function Feed() { const [hasError, setHasError] = useState(false); useEffect(() => { - FeedApi.getFeed() + FeedService.getFeed() .then((feed) => setFeed(feed)) .catch(() => setHasError(true)); }, []); diff --git a/src/pages/user-profile/user-profile.tsx b/src/pages/user-profile/user-profile.tsx index 17a5192..9dda990 100644 --- a/src/pages/user-profile/user-profile.tsx +++ b/src/pages/user-profile/user-profile.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { Navigate, useParams } from "react-router"; -import UserApi from "@/api/user"; +import UserService from "@/api/user"; import { LoadingSpinner } from "@/components/loading"; import { ShoutList } from "@/components/shout-list"; import { Image, Shout, User } from "@/domain"; @@ -21,11 +21,11 @@ export function UserProfile() { return; } - UserApi.getUser(handle) + UserService.getUser(handle) .then((user) => setUser(user)) .catch(() => setHasError(true)); - UserApi.getUserShouts(handle) + UserService.getUserShouts(handle) .then(({ shouts, images }) => { setShouts(shouts); setImages(images); From 27191bade550d684d3e3afff84b35c22052beccf Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Wed, 15 May 2024 15:19:30 +0200 Subject: [PATCH 3/5] Step 5: Rename api layer to infrastructure --- src/components/header/header.tsx | 4 ++-- src/components/login-dialog/login-dialog.tsx | 2 +- src/components/shout/reply-dialog.tsx | 6 +++--- src/{api => infrastructure}/auth/api.ts | 0 src/{api => infrastructure}/auth/dto.ts | 0 src/{api => infrastructure}/auth/index.ts | 0 src/{api => infrastructure}/client.ts | 0 src/{api => infrastructure}/feed/api.ts | 0 src/{api => infrastructure}/feed/dto.ts | 0 src/{api => infrastructure}/feed/index.ts | 0 src/{api => infrastructure}/feed/service.ts | 0 src/{api => infrastructure}/media/api.ts | 0 src/{api => infrastructure}/media/dto.ts | 0 src/{api => infrastructure}/media/index.ts | 0 src/{api => infrastructure}/media/service.ts | 0 src/{api => infrastructure}/media/transform.ts | 0 src/{api => infrastructure}/shout/api.ts | 0 src/{api => infrastructure}/shout/dto.ts | 0 src/{api => infrastructure}/shout/index.ts | 0 src/{api => infrastructure}/shout/service.ts | 0 src/{api => infrastructure}/shout/transform.ts | 0 src/{api => infrastructure}/user/api.ts | 0 src/{api => infrastructure}/user/dto.ts | 0 src/{api => infrastructure}/user/index.ts | 0 src/{api => infrastructure}/user/service.ts | 0 src/{api => infrastructure}/user/transform.ts | 0 src/pages/feed/feed.tsx | 2 +- src/pages/user-profile/user-profile.tsx | 2 +- 28 files changed, 8 insertions(+), 8 deletions(-) rename src/{api => infrastructure}/auth/api.ts (100%) rename src/{api => infrastructure}/auth/dto.ts (100%) rename src/{api => infrastructure}/auth/index.ts (100%) rename src/{api => infrastructure}/client.ts (100%) rename src/{api => infrastructure}/feed/api.ts (100%) rename src/{api => infrastructure}/feed/dto.ts (100%) rename src/{api => infrastructure}/feed/index.ts (100%) rename src/{api => infrastructure}/feed/service.ts (100%) rename src/{api => infrastructure}/media/api.ts (100%) rename src/{api => infrastructure}/media/dto.ts (100%) rename src/{api => infrastructure}/media/index.ts (100%) rename src/{api => infrastructure}/media/service.ts (100%) rename src/{api => infrastructure}/media/transform.ts (100%) rename src/{api => infrastructure}/shout/api.ts (100%) rename src/{api => infrastructure}/shout/dto.ts (100%) rename src/{api => infrastructure}/shout/index.ts (100%) rename src/{api => infrastructure}/shout/service.ts (100%) rename src/{api => infrastructure}/shout/transform.ts (100%) rename src/{api => infrastructure}/user/api.ts (100%) rename src/{api => infrastructure}/user/dto.ts (100%) rename src/{api => infrastructure}/user/index.ts (100%) rename src/{api => infrastructure}/user/service.ts (100%) rename src/{api => infrastructure}/user/transform.ts (100%) diff --git a/src/components/header/header.tsx b/src/components/header/header.tsx index 4371161..679c822 100644 --- a/src/components/header/header.tsx +++ b/src/components/header/header.tsx @@ -1,11 +1,11 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import AuthService from "@/api/auth"; -import UserService from "@/api/user"; import { LoginDialog } from "@/components/login-dialog"; import { Button } from "@/components/ui/button"; import { Me } from "@/domain"; +import AuthService from "@/infrastructure/auth"; +import UserService from "@/infrastructure/user"; export function Header() { const [isLoadingMe, setIsLoadingMe] = useState(true); diff --git a/src/components/login-dialog/login-dialog.tsx b/src/components/login-dialog/login-dialog.tsx index 6595a85..bd750be 100644 --- a/src/components/login-dialog/login-dialog.tsx +++ b/src/components/login-dialog/login-dialog.tsx @@ -1,6 +1,5 @@ import { useState } from "react"; -import AuthService from "@/api/auth"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -13,6 +12,7 @@ import { } from "@/components/ui/dialog"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import AuthService from "@/infrastructure/auth"; interface LoginFormElements extends HTMLFormControlsCollection { username: HTMLInputElement; diff --git a/src/components/shout/reply-dialog.tsx b/src/components/shout/reply-dialog.tsx index 51515fa..4d69ba5 100644 --- a/src/components/shout/reply-dialog.tsx +++ b/src/components/shout/reply-dialog.tsx @@ -1,8 +1,5 @@ import { useEffect, useState } from "react"; -import MediaService from "@/api/media"; -import ShoutService from "@/api/shout"; -import UserService from "@/api/user"; import { LoginDialog } from "@/components/login-dialog"; import { Button } from "@/components/ui/button"; import { @@ -17,6 +14,9 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import MediaService from "@/infrastructure/media"; +import ShoutService from "@/infrastructure/shout"; +import UserService from "@/infrastructure/user"; interface ReplyFormElements extends HTMLFormControlsCollection { message: HTMLTextAreaElement; diff --git a/src/api/auth/api.ts b/src/infrastructure/auth/api.ts similarity index 100% rename from src/api/auth/api.ts rename to src/infrastructure/auth/api.ts diff --git a/src/api/auth/dto.ts b/src/infrastructure/auth/dto.ts similarity index 100% rename from src/api/auth/dto.ts rename to src/infrastructure/auth/dto.ts diff --git a/src/api/auth/index.ts b/src/infrastructure/auth/index.ts similarity index 100% rename from src/api/auth/index.ts rename to src/infrastructure/auth/index.ts diff --git a/src/api/client.ts b/src/infrastructure/client.ts similarity index 100% rename from src/api/client.ts rename to src/infrastructure/client.ts diff --git a/src/api/feed/api.ts b/src/infrastructure/feed/api.ts similarity index 100% rename from src/api/feed/api.ts rename to src/infrastructure/feed/api.ts diff --git a/src/api/feed/dto.ts b/src/infrastructure/feed/dto.ts similarity index 100% rename from src/api/feed/dto.ts rename to src/infrastructure/feed/dto.ts diff --git a/src/api/feed/index.ts b/src/infrastructure/feed/index.ts similarity index 100% rename from src/api/feed/index.ts rename to src/infrastructure/feed/index.ts diff --git a/src/api/feed/service.ts b/src/infrastructure/feed/service.ts similarity index 100% rename from src/api/feed/service.ts rename to src/infrastructure/feed/service.ts diff --git a/src/api/media/api.ts b/src/infrastructure/media/api.ts similarity index 100% rename from src/api/media/api.ts rename to src/infrastructure/media/api.ts diff --git a/src/api/media/dto.ts b/src/infrastructure/media/dto.ts similarity index 100% rename from src/api/media/dto.ts rename to src/infrastructure/media/dto.ts diff --git a/src/api/media/index.ts b/src/infrastructure/media/index.ts similarity index 100% rename from src/api/media/index.ts rename to src/infrastructure/media/index.ts diff --git a/src/api/media/service.ts b/src/infrastructure/media/service.ts similarity index 100% rename from src/api/media/service.ts rename to src/infrastructure/media/service.ts diff --git a/src/api/media/transform.ts b/src/infrastructure/media/transform.ts similarity index 100% rename from src/api/media/transform.ts rename to src/infrastructure/media/transform.ts diff --git a/src/api/shout/api.ts b/src/infrastructure/shout/api.ts similarity index 100% rename from src/api/shout/api.ts rename to src/infrastructure/shout/api.ts diff --git a/src/api/shout/dto.ts b/src/infrastructure/shout/dto.ts similarity index 100% rename from src/api/shout/dto.ts rename to src/infrastructure/shout/dto.ts diff --git a/src/api/shout/index.ts b/src/infrastructure/shout/index.ts similarity index 100% rename from src/api/shout/index.ts rename to src/infrastructure/shout/index.ts diff --git a/src/api/shout/service.ts b/src/infrastructure/shout/service.ts similarity index 100% rename from src/api/shout/service.ts rename to src/infrastructure/shout/service.ts diff --git a/src/api/shout/transform.ts b/src/infrastructure/shout/transform.ts similarity index 100% rename from src/api/shout/transform.ts rename to src/infrastructure/shout/transform.ts diff --git a/src/api/user/api.ts b/src/infrastructure/user/api.ts similarity index 100% rename from src/api/user/api.ts rename to src/infrastructure/user/api.ts diff --git a/src/api/user/dto.ts b/src/infrastructure/user/dto.ts similarity index 100% rename from src/api/user/dto.ts rename to src/infrastructure/user/dto.ts diff --git a/src/api/user/index.ts b/src/infrastructure/user/index.ts similarity index 100% rename from src/api/user/index.ts rename to src/infrastructure/user/index.ts diff --git a/src/api/user/service.ts b/src/infrastructure/user/service.ts similarity index 100% rename from src/api/user/service.ts rename to src/infrastructure/user/service.ts diff --git a/src/api/user/transform.ts b/src/infrastructure/user/transform.ts similarity index 100% rename from src/api/user/transform.ts rename to src/infrastructure/user/transform.ts diff --git a/src/pages/feed/feed.tsx b/src/pages/feed/feed.tsx index 21decef..1eea6d3 100644 --- a/src/pages/feed/feed.tsx +++ b/src/pages/feed/feed.tsx @@ -1,9 +1,9 @@ import { useEffect, useState } from "react"; -import FeedService from "@/api/feed"; import { LoadingView } from "@/components/loading"; import { ShoutList } from "@/components/shout-list"; import { Image, Shout, User } from "@/domain"; +import FeedService from "@/infrastructure/feed"; export function Feed() { const [feed, setFeed] = useState<{ diff --git a/src/pages/user-profile/user-profile.tsx b/src/pages/user-profile/user-profile.tsx index 9dda990..dcbebb7 100644 --- a/src/pages/user-profile/user-profile.tsx +++ b/src/pages/user-profile/user-profile.tsx @@ -1,10 +1,10 @@ import { useEffect, useState } from "react"; import { Navigate, useParams } from "react-router"; -import UserService from "@/api/user"; import { LoadingSpinner } from "@/components/loading"; import { ShoutList } from "@/components/shout-list"; import { Image, Shout, User } from "@/domain"; +import UserService from "@/infrastructure/user"; import { UserInfo } from "./user-info"; From aa9ee8a4003290ca3b72f680cc5b801c6a5f6329 Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Thu, 30 May 2024 07:20:41 +0200 Subject: [PATCH 4/5] Step 5: Use dependency injection --- src/infrastructure/feed/service.ts | 4 ++-- src/infrastructure/media/service.ts | 4 ++-- src/infrastructure/shout/service.ts | 11 +++++++---- src/infrastructure/user/service.ts | 12 ++++++------ 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/src/infrastructure/feed/service.ts b/src/infrastructure/feed/service.ts index e1f41f0..8238526 100644 --- a/src/infrastructure/feed/service.ts +++ b/src/infrastructure/feed/service.ts @@ -6,8 +6,8 @@ import { dtoToUser } from "../user/transform"; import FeedApi from "./api"; -async function getFeed() { - const response = await FeedApi.getFeed(); +async function getFeed(api = FeedApi) { + const response = await api.getFeed(); const shouts = response.data.map(dtoToShout); const users = response.included .filter((u): u is UserDto => u.type === "user") diff --git a/src/infrastructure/media/service.ts b/src/infrastructure/media/service.ts index c7172f5..68578af 100644 --- a/src/infrastructure/media/service.ts +++ b/src/infrastructure/media/service.ts @@ -1,10 +1,10 @@ import MediaApi from "./api"; import { dtoToImage } from "./transform"; -async function saveImage(file: File) { +async function saveImage(file: File, api = MediaApi) { const formData = new FormData(); formData.append("image", file); - const { data: imageDto } = await MediaApi.uploadImage(formData); + const { data: imageDto } = await api.uploadImage(formData); return dtoToImage(imageDto); } diff --git a/src/infrastructure/shout/service.ts b/src/infrastructure/shout/service.ts index 2a819da..5c5c807 100644 --- a/src/infrastructure/shout/service.ts +++ b/src/infrastructure/shout/service.ts @@ -2,13 +2,16 @@ import ShoutApi from "./api"; import { CreateShoutInput, CreateShoutReplyInput } from "./dto"; import { dtoToShout } from "./transform"; -async function createShout(input: CreateShoutInput) { - const { data: shoutDto } = await ShoutApi.createShout(input); +async function createShout(input: CreateShoutInput, api = ShoutApi) { + const { data: shoutDto } = await api.createShout(input); return dtoToShout(shoutDto); } -async function createReply({ shoutId, replyId }: CreateShoutReplyInput) { - const { data: replyDto } = await ShoutApi.createReply({ shoutId, replyId }); +async function createReply( + { shoutId, replyId }: CreateShoutReplyInput, + api = ShoutApi +) { + const { data: replyDto } = await api.createReply({ shoutId, replyId }); return dtoToShout(replyDto); } diff --git a/src/infrastructure/user/service.ts b/src/infrastructure/user/service.ts index 909bdf7..358f0fb 100644 --- a/src/infrastructure/user/service.ts +++ b/src/infrastructure/user/service.ts @@ -4,19 +4,19 @@ import { dtoToShout } from "../shout/transform"; import UserApi from "./api"; import { dtoToMe, dtoToUser } from "./transform"; -async function getMe() { - const { data: meDto } = await UserApi.getMe(); +async function getMe(api = UserApi) { + const { data: meDto } = await api.getMe(); return dtoToMe(meDto); } -async function getUser(handle: string) { - const { data: userDto } = await UserApi.getUser(handle); +async function getUser(handle: string, api = UserApi) { + const { data: userDto } = await api.getUser(handle); return dtoToUser(userDto); } -async function getUserShouts(handle: string) { +async function getUserShouts(handle: string, api = UserApi) { const { data: shoutDtos, included: imageDtos } = - await UserApi.getUserShouts(handle); + await api.getUserShouts(handle); const shouts = shoutDtos.map(dtoToShout); const images = imageDtos.map(dtoToImage); return { shouts, images }; From 8fb48b2ca2e7397d7716ba75e9e4d7474ee8450c Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Thu, 30 May 2024 07:21:11 +0200 Subject: [PATCH 5/5] Step 5: Add tests for infrastructure services --- package-lock.json | 553 ++++++++++++++++++++++- package.json | 7 +- src/infrastructure/feed/service.test.ts | 165 +++++++ src/infrastructure/media/service.test.ts | 42 ++ src/infrastructure/user/service.test.ts | 87 ++++ 5 files changed, 851 insertions(+), 3 deletions(-) create mode 100644 src/infrastructure/feed/service.test.ts create mode 100644 src/infrastructure/media/service.test.ts create mode 100644 src/infrastructure/user/service.test.ts diff --git a/package-lock.json b/package-lock.json index 001a14f..e267893 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "fetch-blob": "^4.0.0", + "formdata-node": "^6.0.3", "husky": "^9.0.11", "lint-staged": "^15.2.2", "npm-run-all": "^4.1.5", @@ -49,7 +51,8 @@ "tailwindcss": "^3.4.3", "tsx": "^4.7.2", "typescript": "^5.4.5", - "vite": "^5.2.8" + "vite": "^5.2.8", + "vitest": "^1.6.0" } }, "node_modules/@aashutoshrathi/word-wrap": { @@ -1118,6 +1121,18 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -1771,6 +1786,12 @@ "win32" ] }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "dev": true + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2085,6 +2106,102 @@ "vite": "^4.2.0 || ^5.0.0" } }, + "node_modules/@vitest/expect": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.0.tgz", + "integrity": "sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "chai": "^4.3.10" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-1.6.0.tgz", + "integrity": "sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==", + "dev": true, + "dependencies": { + "@vitest/utils": "1.6.0", + "p-limit": "^5.0.0", + "pathe": "^1.1.1" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/p-limit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-5.0.0.tgz", + "integrity": "sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==", + "dev": true, + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/runner/node_modules/yocto-queue": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.0.0.tgz", + "integrity": "sha512-9bnSc/HEW2uRy67wc+T8UwauLuPJVn28jb+GtJY16iiKWyvmYJRXVT4UamsAEGQfPohgr2q4Tq0sQbQlxTfi1g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@vitest/snapshot": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-1.6.0.tgz", + "integrity": "sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==", + "dev": true, + "dependencies": { + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-1.6.0.tgz", + "integrity": "sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==", + "dev": true, + "dependencies": { + "tinyspy": "^2.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-1.6.0.tgz", + "integrity": "sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==", + "dev": true, + "dependencies": { + "diff-sequences": "^29.6.3", + "estree-walker": "^3.0.3", + "loupe": "^2.3.7", + "pretty-format": "^29.7.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/abort-controller": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", @@ -2122,6 +2239,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/acorn-walk": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.2.tgz", + "integrity": "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -2373,6 +2499,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/assertion-error": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", + "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -2568,6 +2703,15 @@ "ieee754": "^1.2.1" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", @@ -2624,6 +2768,24 @@ } ] }, + "node_modules/chai": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chai/-/chai-4.4.1.tgz", + "integrity": "sha512-13sOfMv2+DWduEU+/xbun3LScLoqN17nBeTLUsmDfKdoiC1fr0n9PU4guu4AhRcOVFk/sW8LyZWHuhWtQZiF+g==", + "dev": true, + "dependencies": { + "assertion-error": "^1.1.0", + "check-error": "^1.0.3", + "deep-eql": "^4.1.3", + "get-func-name": "^2.0.2", + "loupe": "^2.3.6", + "pathval": "^1.1.1", + "type-detect": "^4.0.8" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -2638,6 +2800,18 @@ "node": ">=4" } }, + "node_modules/check-error": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", + "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.2" + }, + "engines": { + "node": "*" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2826,6 +3000,12 @@ "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "dev": true }, + "node_modules/confbox": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.1.7.tgz", + "integrity": "sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==", + "dev": true + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -2957,6 +3137,18 @@ } } }, + "node_modules/deep-eql": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", + "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", + "dev": true, + "dependencies": { + "type-detect": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -3023,6 +3215,15 @@ "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==" }, + "node_modules/diff-sequences": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "dev": true, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -3753,6 +3954,15 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3967,6 +4177,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-4.0.0.tgz", + "integrity": "sha512-nPmnhRmpNMjYWnp9EBMGs6z5lq9RXed5W1vuZcECrsDVQInM8AMQSooVb3X183Aole60adzjWbH9qlRFWzDDTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0" + }, + "engines": { + "node": ">=16.7" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -4095,6 +4327,15 @@ "node": ">= 6" } }, + "node_modules/formdata-node": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-6.0.3.tgz", + "integrity": "sha512-8e1++BCiTzUno9v5IZ2J6bv4RU+3UKDmqWUQD0MIMVCd9AdhWkO1gw57oo1mNEX1dMq2EGI+FbWz4B92pscSQg==", + "dev": true, + "engines": { + "node": ">= 18" + } + }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -4191,6 +4432,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-func-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", + "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/get-intrinsic": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", @@ -5218,6 +5468,22 @@ "node": ">=4" } }, + "node_modules/local-pkg": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/local-pkg/-/local-pkg-0.5.0.tgz", + "integrity": "sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==", + "dev": true, + "dependencies": { + "mlly": "^1.4.2", + "pkg-types": "^1.0.3" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -5379,6 +5645,15 @@ "loose-envify": "cli.js" } }, + "node_modules/loupe": { + "version": "2.3.7", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", + "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", + "dev": true, + "dependencies": { + "get-func-name": "^2.0.1" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -5396,6 +5671,15 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0" } }, + "node_modules/magic-string": { + "version": "0.30.10", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", + "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", + "dev": true, + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + } + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -5504,6 +5788,18 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mlly": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/mlly/-/mlly-1.7.0.tgz", + "integrity": "sha512-U9SDaXGEREBYQgfejV97coK0UL1r+qnF2SyO9A3qcI8MzKnsIFKHNVEkrDyNncQTKQQumsasmeq84eNMdBfsNQ==", + "dev": true, + "dependencies": { + "acorn": "^8.11.3", + "pathe": "^1.1.2", + "pkg-types": "^1.1.0", + "ufo": "^1.5.3" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -5548,6 +5844,25 @@ "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", "dev": true }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -6001,6 +6316,21 @@ "node": ">=8" } }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true + }, + "node_modules/pathval": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", + "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", + "dev": true, + "engines": { + "node": "*" + } + }, "node_modules/picocolors": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", @@ -6081,6 +6411,17 @@ "node": ">= 6" } }, + "node_modules/pkg-types": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.1.0.tgz", + "integrity": "sha512-/RpmvKdxKf8uILTtoOhAgf30wYbP2Qw+L9p3Rvshx1JZVX+XQNZQFjlbmGHEGIm4CkVPlSn+NXmIM8+9oWQaSA==", + "dev": true, + "dependencies": { + "confbox": "^0.1.7", + "mlly": "^1.6.1", + "pathe": "^1.1.2" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -6267,6 +6608,32 @@ "node": ">=6.0.0" } }, + "node_modules/pretty-format": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "dev": true, + "dependencies": { + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/process": { "version": "0.11.10", "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", @@ -6352,6 +6719,12 @@ "react": "^18.2.0" } }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true + }, "node_modules/react-refresh": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", @@ -6981,6 +7354,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -7097,6 +7476,12 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, "node_modules/statuses": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", @@ -7105,6 +7490,12 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.7.0.tgz", + "integrity": "sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==", + "dev": true + }, "node_modules/stream-wormhole": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/stream-wormhole/-/stream-wormhole-1.1.0.tgz", @@ -7313,6 +7704,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.0.tgz", + "integrity": "sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==", + "dev": true, + "dependencies": { + "js-tokens": "^9.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.0.tgz", + "integrity": "sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==", + "dev": true + }, "node_modules/sucrase": { "version": "3.35.0", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", @@ -7471,6 +7880,30 @@ "real-require": "^0.2.0" } }, + "node_modules/tinybench": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.8.0.tgz", + "integrity": "sha512-1/eK7zUnIklz4JUUlL+658n58XO2hHLQfSk1Zf2LKieUjxidN16eKFEoDEfjHc3ohofSSqK3X5yO6VGb6iW8Lw==", + "dev": true + }, + "node_modules/tinypool": { + "version": "0.8.4", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-0.8.4.tgz", + "integrity": "sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-2.2.1.tgz", + "integrity": "sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-fast-properties": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", @@ -7584,6 +8017,15 @@ "node": ">= 0.8.0" } }, + "node_modules/type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/type-fest": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", @@ -7682,6 +8124,12 @@ "node": ">=14.17" } }, + "node_modules/ufo": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.3.tgz", + "integrity": "sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==", + "dev": true + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -7852,6 +8300,28 @@ } } }, + "node_modules/vite-node": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-1.6.0.tgz", + "integrity": "sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.4", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/vite/node_modules/@esbuild/aix-ppc64": { "version": "0.20.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", @@ -8258,6 +8728,71 @@ "@esbuild/win32-x64": "0.20.2" } }, + "node_modules/vitest": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-1.6.0.tgz", + "integrity": "sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==", + "dev": true, + "dependencies": { + "@vitest/expect": "1.6.0", + "@vitest/runner": "1.6.0", + "@vitest/snapshot": "1.6.0", + "@vitest/spy": "1.6.0", + "@vitest/utils": "1.6.0", + "acorn-walk": "^8.3.2", + "chai": "^4.3.10", + "debug": "^4.3.4", + "execa": "^8.0.1", + "local-pkg": "^0.5.0", + "magic-string": "^0.30.5", + "pathe": "^1.1.1", + "picocolors": "^1.0.0", + "std-env": "^3.5.0", + "strip-literal": "^2.0.0", + "tinybench": "^2.5.1", + "tinypool": "^0.8.3", + "vite": "^5.0.0", + "vite-node": "1.6.0", + "why-is-node-running": "^2.2.2" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "1.6.0", + "@vitest/ui": "1.6.0", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -8307,6 +8842,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/why-is-node-running": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.2.2.tgz", + "integrity": "sha512-6tSwToZxTOcotxHeA+qGCq1mVzKR3CwcJGmVcY+QE8SHy6TnpFnh8PAvPNHYr7EcuVeG0QSMxtYCuO1ta/G/oA==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", diff --git a/package.json b/package.json index 3ab1e4c..1b29d71 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "dev:client": "vite", "dev:server": "tsx watch --ignore server/public server", "build": "tsc && vite build", - "test": "echo 'No tests specified'", + "test": "vitest src", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "prettier": "prettier ./src --check", "types": "tsc --noEmit", @@ -58,6 +58,8 @@ "eslint-plugin-prettier": "^5.1.3", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", + "fetch-blob": "^4.0.0", + "formdata-node": "^6.0.3", "husky": "^9.0.11", "lint-staged": "^15.2.2", "npm-run-all": "^4.1.5", @@ -66,6 +68,7 @@ "tailwindcss": "^3.4.3", "tsx": "^4.7.2", "typescript": "^5.4.5", - "vite": "^5.2.8" + "vite": "^5.2.8", + "vitest": "^1.6.0" } } diff --git a/src/infrastructure/feed/service.test.ts b/src/infrastructure/feed/service.test.ts new file mode 100644 index 0000000..10467e9 --- /dev/null +++ b/src/infrastructure/feed/service.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vitest } from "vitest"; + +import FeedService from "./service"; + +const feedApiMock = { + getFeed: vitest.fn().mockResolvedValue({ + data: [ + { + id: "shout-1", + type: "shout", + createdAt: 1717045146970, + attributes: { + authorId: "user-1", + text: "The world sucks!!!!", + likes: 5, + reshouts: 0, + }, + relationships: { + replies: ["shout-2"], + }, + }, + { + id: "shout-2", + type: "shout", + createdAt: 1717038768844, + attributes: { + authorId: "user-3", + text: "The world sucks!!!!", + likes: 1000, + reshouts: 666, + }, + relationships: { + replies: [], + }, + }, + ], + included: [ + { + id: "user-1", + type: "user", + attributes: { + handle: "darklord", + avatar: "/cdn/avatars/darklord.jpeg", + info: "I am the dark lord, the root of all evil. 'Tis I who brought the world to its knees. In blood I was born, and in blood I shall have my vengeance.", + blockedUserIds: ["user-2"], + followsUserIds: ["user-3"], + }, + relationships: { + followerIds: ["user-3"], + me: { + attributes: { + isBlocked: true, + isFollowing: false, + }, + }, + }, + }, + { + id: "user-2", + type: "user", + attributes: { + handle: "prettypinkpony", + avatar: "/cdn/avatars/prettypinkpony.jpeg", + info: "I like colors. I'm a colorful person (although I'm pretty white *giggles*). I'd like to make this world a better place. And sometimes I feel like the only one who can...", + blockedUserIds: ["user-1"], + followsUserIds: ["user-3"], + }, + relationships: { + followerIds: ["user-3"], + me: { + attributes: { + isBlocked: false, + isFollowing: false, + }, + }, + }, + }, + { + id: "user-3", + type: "user", + attributes: { + handle: "fcku", + avatar: "/cdn/avatars/fcku.jpeg", + blockedUserIds: [], + followsUserIds: ["user-1", "user-2"], + }, + relationships: { + followerIds: ["user-1", "user-2"], + me: { + attributes: { + isBlocked: false, + isFollowing: true, + }, + }, + }, + }, + { + id: "image-1", + type: "image", + attributes: { + url: "https://media.giphy.com/media/dG7ZiL6ImLyNO/giphy.gif", + }, + }, + ], + }), +}; + +describe("FeedService", () => { + it("should return shouts, users, and images from the feed", async () => { + const result = await FeedService.getFeed(feedApiMock); + + expect(feedApiMock.getFeed).toHaveBeenCalled(); + expect(result.shouts).toEqual([ + { + authorId: "user-1", + createdAt: 1717045146970, + id: "shout-1", + imageId: undefined, + likes: 5, + replies: ["shout-2"], + reshouts: 0, + text: "The world sucks!!!!", + }, + { + authorId: "user-3", + createdAt: 1717038768844, + id: "shout-2", + imageId: undefined, + likes: 1000, + replies: [], + reshouts: 666, + text: "The world sucks!!!!", + }, + ]); + expect(result.users).toEqual([ + { + avatar: "/cdn/avatars/darklord.jpeg", + followerIds: ["user-3"], + handle: "darklord", + id: "user-1", + info: "I am the dark lord, the root of all evil. 'Tis I who brought the world to its knees. In blood I was born, and in blood I shall have my vengeance.", + }, + { + avatar: "/cdn/avatars/prettypinkpony.jpeg", + followerIds: ["user-3"], + handle: "prettypinkpony", + id: "user-2", + info: "I like colors. I'm a colorful person (although I'm pretty white *giggles*). I'd like to make this world a better place. And sometimes I feel like the only one who can...", + }, + { + avatar: "/cdn/avatars/fcku.jpeg", + followerIds: ["user-1", "user-2"], + handle: "fcku", + id: "user-3", + info: undefined, + }, + ]); + expect(result.images).toEqual([ + { + id: "image-1", + url: "https://media.giphy.com/media/dG7ZiL6ImLyNO/giphy.gif", + }, + ]); + }); +}); diff --git a/src/infrastructure/media/service.test.ts b/src/infrastructure/media/service.test.ts new file mode 100644 index 0000000..8353183 --- /dev/null +++ b/src/infrastructure/media/service.test.ts @@ -0,0 +1,42 @@ +import { Blob } from "fetch-blob"; +import { FormData, File } from "formdata-node"; +import { describe, test, expect, vitest } from "vitest"; + +import MediaService from "./service"; + +const mockMediaAPI = { + uploadImage: vitest.fn((formData: FormData) => { + const file = formData.get("image") as File; + return Promise.resolve({ + data: { + id: "1", + type: "image" as const, + attributes: { + url: `https://example.com/${file.name}`, + }, + }, + }); + }), +}; + +function createMockFile(name: string) { + const byteCharacters = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); + const blob = new Blob([byteCharacters], { type: "image/png" }); + return new File([blob], name, { type: "image/png" }); +} + +describe("MediaService", () => { + test("uploads and returns an image", async () => { + const file = createMockFile("image.png"); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + const image = await MediaService.saveImage(file, mockMediaAPI); + + expect(mockMediaAPI.uploadImage).toHaveBeenCalled(); + expect(image).toEqual({ + id: "1", + url: `https://example.com/${file.name}`, + }); + }); +}); diff --git a/src/infrastructure/user/service.test.ts b/src/infrastructure/user/service.test.ts new file mode 100644 index 0000000..1288782 --- /dev/null +++ b/src/infrastructure/user/service.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vitest } from "vitest"; + +import UserService from "./service"; + +const userApiMock = { + getMe: vitest.fn(), + getUser: vitest.fn(), + getUserShouts: vitest.fn().mockResolvedValue({ + data: [ + { + id: "shout-5", + type: "shout", + createdAt: 1717046170490, + attributes: { + authorId: "user-2", + text: "Test message", + likes: 0, + reshouts: 0, + imageId: "image-2", + }, + relationships: { + replies: [], + replyTo: "shout-1", + }, + }, + { + id: "shout-3", + type: "shout", + createdAt: 1717036243553, + attributes: { + authorId: "user-2", + text: "You suck @darklord!!!!", + likes: 1000, + reshouts: 666, + }, + relationships: { + replies: ["shout-4"], + }, + }, + ], + included: [ + { + id: "image-1", + type: "image", + attributes: { + url: "/cdn/shouts/cute.gif", + }, + }, + ], + }), +}; + +describe("UserService", () => { + it("should return shouts, users, and images from the feed", async () => { + const result = await UserService.getUserShouts("fcku", userApiMock); + + expect(userApiMock.getUserShouts).toHaveBeenCalledWith("fcku"); + expect(result.shouts).toEqual([ + { + authorId: "user-2", + createdAt: 1717046170490, + id: "shout-5", + imageId: "image-2", + likes: 0, + replies: [], + reshouts: 0, + text: "Test message", + }, + { + authorId: "user-2", + createdAt: 1717036243553, + id: "shout-3", + imageId: undefined, + likes: 1000, + replies: ["shout-4"], + reshouts: 666, + text: "You suck @darklord!!!!", + }, + ]); + expect(result.images).toEqual([ + { + id: "image-1", + url: "/cdn/shouts/cute.gif", + }, + ]); + }); +});