1import * as IVSChat from "@/AWS/IVSChat";
2import * as Lambda from "@/AWS/Lambda";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7import { HttpServerRequest } from "effect/http/HttpServerRequest";
8import * as HttpServerResponse from "effect/http/HttpServerResponse";
9import path from "pathe";
10
11const main = path.resolve(import.meta.dirname, "handler.ts");
12
13/** The user ID the fixture mints tokens for and disconnects. */
14export const TEST_USER_ID = "alchemy-test-user";
15
16export class IVSChatTestFunction extends Lambda.Function<Lambda.Function>()(
17 "IVSChatTestFunction",
18) {}
19
20export default IVSChatTestFunction.make(
21 {
22 main,
23 functionUrl: true,
24 timeout: Duration.seconds(30),
25 },
26 Effect.gen(function* () {
27 const room = yield* IVSChat.Room("BindingsRoom", {
28 roomName: "alchemy-test-ivschat-bindings",
29 tags: { fixture: "ivschat-bindings" },
30 });
31
32 const createChatToken = yield* IVSChat.CreateChatToken(room);
33 const sendEvent = yield* IVSChat.SendEvent(room);
34 const deleteMessage = yield* IVSChat.DeleteMessage(room);
35 const disconnectUser = yield* IVSChat.DisconnectUser(room);
36
37 // Message review handler: deny messages containing "deny-me"; stamp
38 // everything else with a marker so the test can observe the review.
39 yield* IVSChat.onReviewMessage(room, (event) =>
40 Effect.succeed(
41 event.Content.includes("deny-me")
42 ? {
43 ReviewResult: "DENY" as const,
44 Attributes: { Reason: "alchemy-moderated" },
45 }
46 : {
47 ReviewResult: "ALLOW" as const,
48 Content: `${event.Content} [reviewed]`,
49 },
50 ),
51 );
52
53 const RoomArn = yield* room.roomArn;
54
55 return {
56 fetch: Effect.gen(function* () {
57 const request = yield* HttpServerRequest;
58 const pathname = new URL(request.originalUrl).pathname;
59
60 // Cheap readiness route — no AWS call.
61 if (request.method === "GET" && pathname === "/ping") {
62 return yield* HttpServerResponse.json({ ok: true });
63 }
64
65 if (request.method === "POST" && pathname === "/token") {
66 const result = yield* createChatToken({
67 userId: TEST_USER_ID,
68 capabilities: ["SEND_MESSAGE"],
69 // Exercises the Duration.Input -> sessionDurationInMinutes wiring.
70 sessionDuration: "30 minutes",
71 attributes: { displayName: "Alchemy" },
72 });
73 // The token is sensitive — assert on its shape, never echo it.
74 const token = result.token;
75 const tokenLength =
76 token === undefined
77 ? 0
78 : Redacted.isRedacted(token)
79 ? Redacted.value(token).length
80 : token.length;
81 return yield* HttpServerResponse.json({
82 tokenLength,
83 tokenIsRedacted: token !== undefined && Redacted.isRedacted(token),
84 tokenExpirationTime: result.tokenExpirationTime,
85 sessionExpirationTime: result.sessionExpirationTime,
86 });
87 }
88
89 if (request.method === "POST" && pathname === "/ws-info") {
90 // The test drives the room's WebSocket messaging API directly to
91 // exercise the message review handler; it needs the raw token
92 // (scoped to this test room, 30-minute session) and the regional
93 // edge endpoint.
94 const result = yield* createChatToken({
95 userId: TEST_USER_ID,
96 capabilities: ["SEND_MESSAGE"],
97 sessionDuration: "30 minutes",
98 });
99 const token = result.token;
100 const raw =
101 token === undefined
102 ? undefined
103 : Redacted.isRedacted(token)
104 ? Redacted.value(token)
105 : token;
106 const region = yield* Effect.sync(() => process.env.AWS_REGION);
107 const roomArn = yield* RoomArn;
108 return yield* HttpServerResponse.json({
109 token: raw,
110 endpoint: `wss://edge.ivschat.${region}.amazonaws.com`,
111 roomArn,
112 });
113 }
114
115 if (request.method === "POST" && pathname === "/send-event") {
116 const { id } = yield* sendEvent({
117 eventName: "app:announcement",
118 attributes: { note: "hello from alchemy" },
119 });
120 return yield* HttpServerResponse.json({ id });
121 }
122
123 if (request.method === "POST" && pathname === "/delete-message") {
124 // Broadcast an event first and use its ID as the deletion target —
125 // DeleteMessage broadcasts a DELETEMESSAGE event and succeeds
126 // whether or not clients hold the message.
127 const sent = yield* sendEvent({ eventName: "app:to-delete" });
128 const { id } = yield* deleteMessage({
129 id: sent.id!,
130 reason: "moderated by alchemy test",
131 });
132 return yield* HttpServerResponse.json({ deleted: id });
133 }
134
135 if (request.method === "POST" && pathname === "/disconnect-user") {
136 yield* disconnectUser({
137 userId: TEST_USER_ID,
138 reason: "alchemy test",
139 });
140 return yield* HttpServerResponse.json({ ok: true });
141 }
142
143 return yield* HttpServerResponse.json(
144 { error: "Not found", method: request.method, pathname },
145 { status: 404 },
146 );
147 }).pipe(Effect.orDie),
148 };
149 }).pipe(
150 Effect.provide(
151 Layer.mergeAll(
152 IVSChat.CreateChatTokenHttp,
153 IVSChat.SendEventHttp,
154 IVSChat.DeleteMessageHttp,
155 IVSChat.DisconnectUserHttp,
156 Lambda.RoomMessageReviewEventSource,
157 ),
158 ),
159 ),
160);