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
18 changes: 9 additions & 9 deletions apps/loom-example-app/src/todo-service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import * as Data from "effect/Data"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import * as Ref from "effect/Ref"
import * as ServiceMap from "effect/ServiceMap"

export interface TodoItem {
readonly id: number
Expand Down Expand Up @@ -38,14 +38,14 @@ export const initialTodoItems: ReadonlyArray<TodoItem> = [

export const cloneInitialTodos = (): Array<TodoItem> => initialTodoItems.map((todo) => ({ ...todo }))

export class TodoService extends ServiceMap.Service<TodoService, TodoServiceApi>()("LoomExampleTodoService", {
export class TodoService extends Context.Service<TodoService>()("LoomExampleTodoService", {
make: Effect.gen(function*() {
const todosRef = yield* Ref.make(cloneInitialTodos())
const nextIdRef = yield* Ref.make(initialTodoItems.length + 1)

return {
list: () => Ref.get(todosRef),
dispatch: (command) =>
dispatch: (command: TodoCommand) =>
Effect.gen(function*() {
switch (command.intent) {
case "create": {
Expand All @@ -59,7 +59,7 @@ export class TodoService extends ServiceMap.Service<TodoService, TodoServiceApi>
yield* Ref.update(todosRef, (current) => [...current, nextTodo])
yield* Ref.set(nextIdRef, nextId + 1)

return { intent: command.intent }
return { intent: command.intent } satisfies TodoCommandResult
}
case "toggle": {
const current = yield* Ref.get(todosRef)
Expand All @@ -71,7 +71,7 @@ export class TodoService extends ServiceMap.Service<TodoService, TodoServiceApi>
yield* Ref.update(todosRef, (todos) =>
todos.map((todo) => todo.id === command.id ? { ...todo, completed: !todo.completed } : todo))

return { intent: command.intent }
return { intent: command.intent } satisfies TodoCommandResult
}
case "remove": {
const current = yield* Ref.get(todosRef)
Expand All @@ -86,12 +86,12 @@ export class TodoService extends ServiceMap.Service<TodoService, TodoServiceApi>

yield* Ref.update(todosRef, (todos) => todos.filter((todo) => todo.id !== command.id))

return { intent: command.intent }
return { intent: command.intent } satisfies TodoCommandResult
}
case "clear-completed": {
yield* Ref.update(todosRef, (todos) => todos.filter((todo) => !todo.completed))

return { intent: command.intent }
return { intent: command.intent } satisfies TodoCommandResult
}
}
}),
Expand All @@ -100,10 +100,10 @@ export class TodoService extends ServiceMap.Service<TodoService, TodoServiceApi>
yield* Ref.set(todosRef, cloneInitialTodos())
yield* Ref.set(nextIdRef, initialTodoItems.length + 1)
}),
}
} satisfies TodoServiceApi
}),
}) {
static readonly layer = Layer.effect(TodoService, this.make)
static readonly layer = Layer.effect(this, this.make)
}

export const makeTodoService = (): TodoServiceApi => Effect.runSync(TodoService.make)
37 changes: 37 additions & 0 deletions apps/loom-example-app/tests/todo-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import * as Effect from "effect/Effect"
import { describe, expect, it } from "vitest"
import { makeTodoService, TodoService } from "../src/todo-service.js"

describe("loom example todo service", () => {
it("creates and resets todos through the Context.Service runtime", async () => {
const service = makeTodoService()

await Effect.runPromise(service.dispatch({ intent: "create", title: "Verify Context.Service migration" }))

expect(await Effect.runPromise(service.list())).toEqual([
{ completed: true, id: 1, title: "Sketch the shared Atom shape" },
{ completed: false, id: 2, title: "Wire the composer to shared state" },
{ completed: false, id: 3, title: "Show composition through child components" },
{ completed: false, id: 4, title: "Verify Context.Service migration" },
])

await Effect.runPromise(service.reset())

expect(await Effect.runPromise(service.list())).toEqual([
{ completed: true, id: 1, title: "Sketch the shared Atom shape" },
{ completed: false, id: 2, title: "Wire the composer to shared state" },
{ completed: false, id: 3, title: "Show composition through child components" },
])
})

it("fails with TodoNotFoundError for unknown ids", async () => {
await expect(
Effect.runPromise(
Effect.gen(function*() {
const service = yield* TodoService
return yield* service.dispatch({ id: 99, intent: "remove" })
}).pipe(Effect.provide(TodoService.layer)),
),
).rejects.toMatchObject({ _tag: "TodoNotFoundError", id: 99 })
})
})
7 changes: 7 additions & 0 deletions apps/react-remix-example/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
"projectType": "application",
"sourceRoot": "apps/react-remix-example/src",
"targets": {
"test": {
"executor": "nx:run-commands",
"options": {
"command": "vitest run",
"cwd": "apps/react-remix-example"
}
},
"build": {
"executor": "nx:run-script",
"outputs": ["{projectRoot}/build"],
Expand Down
105 changes: 105 additions & 0 deletions apps/react-remix-example/tests/unit/routes/api-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import * as Layer from "effect/Layer"
import { describe, expect, it, vi } from "vitest"

const { authHandlerMock } = vi.hoisted(() => ({
authHandlerMock: vi.fn(),
}))

vi.mock("../../../app/lib/better-auth-options.server.js", () => ({
authOptions: {
baseURL: "http://localhost:3000",
secret: "test-secret",
},
}))

vi.mock("@effectify/node-better-auth", () => {
class MockAuthServiceContext extends Context.Service<
MockAuthServiceContext,
{
readonly auth: {
readonly handler: (request: Request) => Promise<Response>
}
}
>()("AuthServiceContext") {}

return {
AuthService: {
AuthServiceContext: Object.assign(MockAuthServiceContext, {
layer: () =>
Layer.succeed(MockAuthServiceContext, {
auth: {
handler: authHandlerMock,
},
}),
}),
},
}
})

vi.mock("@effectify/react-router-better-auth", () => ({
betterAuthLoader: Effect.succeed(
new Response("loader ok", {
status: 200,
headers: {
"set-cookie": "session=loader",
"x-runtime": "loader",
},
}),
),
betterAuthAction: Effect.succeed(
new Response("action ok", {
status: 201,
headers: {
"set-cookie": "session=action",
"x-runtime": "action",
},
}),
),
}))

import { action, loader } from "../../../app/routes/api.auth.$.js"

const makeLoaderArgs = (request: Request): LoaderFunctionArgs => ({
context: {},
params: { "*": "session" },
request,
})

const makeActionArgs = (request: Request): ActionFunctionArgs => ({
context: {},
params: { "*": "session" },
request,
})

describe("api.auth route runtime integration", () => {
it("preserves auth loader responses through withLoaderEffect", async () => {
const response = await loader(
makeLoaderArgs(new Request("https://example.com/api/auth/session")),
).catch((error) => error)

expect(response).toBeInstanceOf(Response)
expect(response.status).toBe(200)
expect(response.headers.get("set-cookie")).toBe("session=loader")
expect(response.headers.get("x-runtime")).toBe("loader")
await expect(response.text()).resolves.toBe("loader ok")
})

it("preserves auth action responses through withActionEffect", async () => {
const response = await action(
makeActionArgs(
new Request("https://example.com/api/auth/session", {
method: "POST",
}),
),
).catch((error) => error)

expect(response).toBeInstanceOf(Response)
expect(response.status).toBe(201)
expect(response.headers.get("set-cookie")).toBe("session=action")
expect(response.headers.get("x-runtime")).toBe("action")
await expect(response.text()).resolves.toBe("action ok")
})
})
107 changes: 107 additions & 0 deletions apps/react-remix-example/tests/unit/routes/test-route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node"
import * as Context from "effect/Context"
import * as Layer from "effect/Layer"
import { describe, expect, it, vi } from "vitest"

const { authHandlerMock } = vi.hoisted(() => ({
authHandlerMock: vi.fn(),
}))

vi.mock("../../../app/lib/better-auth-options.server.js", () => ({
authOptions: {
baseURL: "http://localhost:3000",
secret: "test-secret",
},
}))

vi.mock("@effectify/node-better-auth", () => {
class MockAuthServiceContext extends Context.Service<
MockAuthServiceContext,
{
readonly auth: {
readonly handler: (request: Request) => Promise<Response>
}
}
>()("AuthServiceContext") {}

return {
AuthService: {
AuthServiceContext: Object.assign(MockAuthServiceContext, {
layer: () =>
Layer.succeed(MockAuthServiceContext, {
auth: {
handler: authHandlerMock,
},
}),
}),
},
}
})

import { action, loader } from "../../../app/routes/test.js"

const makeLoaderArgs = (request: Request): LoaderFunctionArgs => ({
context: {},
params: {},
request,
})

const makeActionArgs = (request: Request): ActionFunctionArgs => ({
context: {},
params: {},
request,
})

describe("test route runtime integration", () => {
it("returns loader data through withLoaderEffect", async () => {
const response = await loader(makeLoaderArgs(new Request("https://example.com/test")))

expect(response).toEqual({
ok: true,
data: {
message: "Test route works!",
},
})
})

it("returns a validation response when the form input is blank", async () => {
const response = await action(
makeActionArgs(
new Request("https://example.com/test", {
method: "POST",
body: new URLSearchParams({ inputValue: "" }),
}),
),
)

expect(response).toBeInstanceOf(Response)
if (!(response instanceof Response)) {
throw new Error("Expected a Remix validation Response")
}

expect(response.status).toBe(400)
await expect(response.json()).resolves.toEqual({
ok: false,
errors: ["Input value is required"],
})
})

it("returns processed action data through withActionEffect", async () => {
const response = await action(
makeActionArgs(
new Request("https://example.com/test", {
method: "POST",
body: new URLSearchParams({ inputValue: "Effectify" }),
}),
),
)

expect(response).toEqual({
ok: true,
response: {
message: "Received successfully!",
inputValue: "Effectify",
},
})
})
})
24 changes: 24 additions & 0 deletions apps/react-remix-example/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { dirname, resolve } from "node:path"
import { fileURLToPath } from "node:url"
import { defineConfig } from "vitest/config"

const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)

export default defineConfig({
root: __dirname,
resolve: {
alias: {
"~": resolve(__dirname, "app"),
},
conditions: ["@effectify/source"],
},
test: {
include: ["tests/**/*.{test,spec}.{ts,tsx}"],
exclude: ["**/node_modules/**", "**/build/**"],
globals: true,
},
esbuild: {
target: "node22",
},
})
2 changes: 1 addition & 1 deletion apps/react-router-example/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ generator client {
}

generator effect {
provider = "node ../../packages/prisma/dist/src/cli.js"
provider = "node ../../packages/prisma/bin/effect-prisma.mjs"
output = "./generated/effect"
}

Expand Down
1 change: 0 additions & 1 deletion apps/react-router-example/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@
},
"prisma:generate": {
"executor": "nx:run-commands",
"dependsOn": ["^build"],
"options": {
"command": "pnpm dlx prisma generate",
"cwd": "apps/react-router-example"
Expand Down
Loading
Loading