Skip to content
Draft
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
11 changes: 11 additions & 0 deletions app/domain/user/entities/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type UserRole = "admin" | "staff" | "viewer"

export interface User {
id: string
oidcSub: string
email: string
name: string | null
role: UserRole
createdAt: Date
updatedAt: Date
}
140 changes: 140 additions & 0 deletions app/domain/user/repositories/userCommandRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import { describe, expect, it } from "bun:test"
import type { TransactionDbClient } from "../../../infrastructure/db/client"
import type { User, UserRole } from "../entities/user"
import {
type CreateUser,
createUser,
type UpdateUser,
updateUser,
} from "./userCommandRepository"

const validUser: Omit<User, "id" | "createdAt" | "updatedAt"> = {
oidcSub: "auth0|123456789",
email: "test@example.com",
name: "Test User",
role: "staff" as UserRole,
}

describe("createUser", () => {
const mockDbClient = {} as TransactionDbClient

it("バリデーションを通過したユーザーを作成できる", async () => {
const mockImpl: CreateUser = async ({ user }) => ({
...user,
id: "test-id-123",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
})

const result = await createUser({
user: validUser,
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).not.toBeNull()
expect(result.email).toBe(validUser.email)
expect(result.oidcSub).toBe(validUser.oidcSub)
expect(result.role).toBe(validUser.role)
})

it("OIDC Subjectが空の場合はエラーを返す", async () => {
await expect(
createUser({
user: { ...validUser, oidcSub: "" },
repositoryImpl: async () => ({} as User),
dbClient: mockDbClient,
}),
).rejects.toThrow("OIDC Subjectは必須です")
})

it("無効なメールアドレスの場合はエラーを返す", async () => {
await expect(
createUser({
user: { ...validUser, email: "invalid-email" },
repositoryImpl: async () => ({} as User),
dbClient: mockDbClient,
}),
).rejects.toThrow("有効なメールアドレスを入力してください")
})

it("メールアドレスが空の場合はエラーを返す", async () => {
await expect(
createUser({
user: { ...validUser, email: "" },
repositoryImpl: async () => ({} as User),
dbClient: mockDbClient,
}),
).rejects.toThrow("有効なメールアドレスを入力してください")
})

it("無効なロールの場合はエラーを返す", async () => {
await expect(
createUser({
user: { ...validUser, role: "invalid" as UserRole },
repositoryImpl: async () => ({} as User),
dbClient: mockDbClient,
}),
).rejects.toThrow("無効なロールです")
})

it("nameがnullでも作成できる", async () => {
const mockImpl: CreateUser = async ({ user }) => ({
...user,
id: "test-id-123",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
})

const result = await createUser({
user: { ...validUser, name: null },
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).not.toBeNull()
expect(result.name).toBeNull()
})
})

describe("updateUser", () => {
const mockDbClient = {} as TransactionDbClient

it("バリデーションを通過したユーザーを更新できる", async () => {
const existingUser: Omit<User, "createdAt"> = {
...validUser,
id: "existing-id",
updatedAt: new Date("2025-01-02"),
}

const mockImpl: UpdateUser = async ({ user }) => ({
...user,
createdAt: new Date("2025-01-01"),
})

const result = await updateUser({
user: existingUser,
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).not.toBeNull()
expect(result.id).toBe(existingUser.id)
expect(result.email).toBe(existingUser.email)
})

it("無効なメールアドレスで更新しようとするとエラーを返す", async () => {
await expect(
updateUser({
user: {
...validUser,
id: "test-id",
email: "not-an-email",
updatedAt: new Date(),
},
repositoryImpl: async () => ({} as User),
dbClient: mockDbClient,
}),
).rejects.toThrow("有効なメールアドレスを入力してください")
})
})
49 changes: 49 additions & 0 deletions app/domain/user/repositories/userCommandRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {
createUserImpl,
updateUserImpl,
} from "../../../infrastructure/domain/user/userCommandRepositoryImpl"
import type { CommandRepositoryFunction, WithRepositoryImpl } from "../../types"
import type { User, UserRole } from "../entities/user"

const validateUser = (user: Omit<User, "id" | "createdAt" | "updatedAt">) => {
if (!user.oidcSub || user.oidcSub.trim() === "") {
throw new Error("OIDC Subjectは必須です")
}

if (!user.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(user.email)) {
throw new Error("有効なメールアドレスを入力してください")
}

const validRoles: UserRole[] = ["admin", "staff", "viewer"]
if (!validRoles.includes(user.role)) {
throw new Error("無効なロールです")
}
}

export type CreateUser = CommandRepositoryFunction<
{ user: Omit<User, "id" | "createdAt" | "updatedAt"> },
User
>

export type UpdateUser = CommandRepositoryFunction<
{ user: Omit<User, "createdAt"> },
User
>

export const createUser: WithRepositoryImpl<CreateUser> = async ({
repositoryImpl = createUserImpl,
dbClient,
user,
}) => {
validateUser(user)
return repositoryImpl({ user, dbClient })
}

export const updateUser: WithRepositoryImpl<UpdateUser> = async ({
repositoryImpl = updateUserImpl,
dbClient,
user,
}) => {
validateUser(user)
return repositoryImpl({ user, dbClient })
}
104 changes: 104 additions & 0 deletions app/domain/user/repositories/userQueryRepository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { describe, expect, it } from "bun:test"
import type { DbClient } from "../../../infrastructure/db/client"
import type { User } from "../entities/user"
import {
type FindUserById,
type FindUserByOidcSub,
findUserById,
findUserByOidcSub,
} from "./userQueryRepository"

const mockUser: User = {
id: "test-id-123",
oidcSub: "auth0|123456789",
email: "test@example.com",
name: "Test User",
role: "staff",
createdAt: new Date("2025-01-01"),
updatedAt: new Date("2025-01-01"),
}

describe("findUserById", () => {
const mockDbClient = {} as DbClient

it("存在するユーザーをIDで取得できる", async () => {
const mockImpl: FindUserById = async ({ user }) =>
user.id === mockUser.id ? mockUser : null

const result = await findUserById({
user: { id: "test-id-123" },
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).not.toBeNull()
expect(result?.id).toBe(mockUser.id)
expect(result?.email).toBe(mockUser.email)
})

it("存在しないIDならnullを返す", async () => {
const mockImpl: FindUserById = async () => null

const result = await findUserById({
user: { id: "non-existent" },
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).toBeNull()
})
})

describe("findUserByOidcSub", () => {
const mockDbClient = {} as DbClient

it("存在するユーザーをOIDC Subjectで取得できる", async () => {
const mockImpl: FindUserByOidcSub = async ({ oidcSub }) =>
oidcSub === mockUser.oidcSub ? mockUser : null

const result = await findUserByOidcSub({
oidcSub: "auth0|123456789",
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).not.toBeNull()
expect(result?.id).toBe(mockUser.id)
expect(result?.oidcSub).toBe(mockUser.oidcSub)
})

it("存在しないOIDC Subjectならnullを返す", async () => {
const mockImpl: FindUserByOidcSub = async () => null

const result = await findUserByOidcSub({
oidcSub: "non-existent",
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).toBeNull()
})

it("異なるプロバイダーのOIDC Subjectでも取得できる", async () => {
const googleUser: User = {
...mockUser,
id: "google-user-id",
oidcSub: "google-oauth2|987654321",
}

const mockImpl: FindUserByOidcSub = async ({ oidcSub }) => {
if (oidcSub === mockUser.oidcSub) return mockUser
if (oidcSub === googleUser.oidcSub) return googleUser
return null
}

const result = await findUserByOidcSub({
oidcSub: "google-oauth2|987654321",
repositoryImpl: mockImpl,
dbClient: mockDbClient,
})

expect(result).not.toBeNull()
expect(result?.oidcSub).toBe(googleUser.oidcSub)
})
})
32 changes: 32 additions & 0 deletions app/domain/user/repositories/userQueryRepository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
findUserByIdImpl,
findUserByOidcSubImpl,
} from "../../../infrastructure/domain/user/userQueryRepositoryImpl"
import type { QueryRepositoryFunction, WithRepositoryImpl } from "../../types"
import type { User } from "../entities/user"

export type FindUserById = QueryRepositoryFunction<
{ user: Pick<User, "id"> },
User | null
>

export type FindUserByOidcSub = QueryRepositoryFunction<
{ oidcSub: string },
User | null
>

export const findUserById: WithRepositoryImpl<FindUserById> = async ({
user,
repositoryImpl = findUserByIdImpl,
dbClient,
}) => {
return repositoryImpl({ user, dbClient })
}

export const findUserByOidcSub: WithRepositoryImpl<FindUserByOidcSub> = async ({
oidcSub,
repositoryImpl = findUserByOidcSubImpl,
dbClient,
}) => {
return repositoryImpl({ oidcSub, dbClient })
}
29 changes: 29 additions & 0 deletions app/infrastructure/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,3 +154,32 @@ export const orderItemRelations = relations(orderItemTable, ({ one }) => ({
references: [orderTable.id],
}),
}))

export const userRoleEnum = pgEnum("user_role", ["admin", "staff", "viewer"])

/** ユーザー */
export const userTable = pgTable(
"user",
{
id: text("id").primaryKey(), // nanoidで生成される21文字のID
oidcSub: text("oidc_sub").notNull().unique(),
email: text("email").notNull(),
name: text("name"),
role: userRoleEnum().notNull().default("viewer"),
createdAt: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow(),
},
(table) => [
index("user_oidc_sub_idx").on(table.oidcSub),
index("user_email_idx").on(table.email),
check(
"user_email_format",
sql`${table.email} ~ '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$'`,
),
check("user_oidc_sub_not_empty", sql`char_length(${table.oidcSub}) >= 1`),
],
)
Loading