| 1 | import * as IVSChat from "@/AWS/IVSChat"; |
| 2 | import * as Lambda from "@/AWS/Lambda"; |
| 3 | import * as Duration from "effect/Duration"; |
| 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Layer from "effect/Layer"; |
| 6 | import * as Redacted from "effect/Redacted"; |
| 7 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 8 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 9 | import path from "pathe"; |
| 10 | |
| 11 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 12 | |
| 13 | /** The user ID the fixture mints tokens for and disconnects. */ |
| 14 | export const TEST_USER_ID = "alchemy-test-user"; |
| 15 | |
| 16 | export class IVSChatTestFunction extends Lambda.Function<Lambda.Function>()( |
| 17 | "IVSChatTestFunction", |
| 18 | ) {} |
| 19 | |
| 20 | export 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 | ); |