diff --git a/Dockerfile b/Dockerfile index b1aa7e2e9..dcc10ff7f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -38,7 +38,7 @@ RUN apk add --no-cache --update curl WORKDIR /root ADD \ - --checksum=sha256:390fdc813e2e58ec5a0def8ce6422b83d75032899167052ab981d8e1b3b14ff2 \ + --checksum=sha256:05c6b4a9bf4daa95c888711481e19cc8e2e11aed4de8db759f51f4a6fce64917 \ https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem \ aws-cert-bundle.pem diff --git a/assets/src/hooks/useDetours.ts b/assets/src/hooks/useDetours.ts new file mode 100644 index 000000000..751cfde90 --- /dev/null +++ b/assets/src/hooks/useDetours.ts @@ -0,0 +1,280 @@ +import { Channel, Socket } from "phoenix" +import { + SimpleDetour, + SimpleDetourData, + simpleDetourFromData, +} from "../models/detoursList" +import { useEffect, useState } from "react" +import { reload } from "../models/browser" +import { userUuid } from "../util/userUuid" +import { ByRouteId, RouteId } from "../schedule" +import { equalByElements } from "../helpers/array" +import { array, create } from "superstruct" + +interface DetoursMap { + [key: number]: SimpleDetour +} + +const subscribe = ( + socket: Socket, + topic: string, + initializeChannel: React.Dispatch>, + handleDrafted: ((data: SimpleDetour) => void) | undefined, + handleActivated: ((data: SimpleDetour) => void) | undefined, + handleDeactivated: ((data: SimpleDetour) => void) | undefined +): Channel => { + const channel = socket.channel(topic) + + handleDrafted && + channel.on("drafted", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, SimpleDetourData) + handleDrafted(simpleDetourFromData(data)) + }) + handleActivated && + channel.on("activated", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, SimpleDetourData) + handleActivated(simpleDetourFromData(data)) + }) + handleDeactivated && + channel.on("deactivated", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, SimpleDetourData) + handleDeactivated(simpleDetourFromData(data)) + }) + channel.on("auth_expired", reload) + + channel + .join() + .receive("ok", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, array(SimpleDetourData)) + + const detoursMap = Object.fromEntries( + data.map((v) => [v.id, simpleDetourFromData(v)]) + ) + initializeChannel(detoursMap) + }) + + .receive("error", ({ reason }) => { + if (reason === "not_authenticated") { + reload() + } else { + // eslint-disable-next-line no-console + console.error(`joining topic ${topic} failed`, reason) + } + }) + .receive("timeout", reload) + + return channel +} + +// This is to refresh the Detours List page. We need all active detours +export const useActiveDetours = (socket: Socket | undefined) => { + const topic = "detours:active" + const [activeDetours, setActiveDetours] = useState({}) + + const handleActivated = (data: SimpleDetour) => { + setActiveDetours((activeDetours) => ({ ...activeDetours, [data.id]: data })) + } + + const handleDeactivated = (data: SimpleDetour) => { + setActiveDetours((activeDetours) => { + delete activeDetours[data.id] + return activeDetours + }) + } + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + channel = subscribe( + socket, + topic, + setActiveDetours, + undefined, + handleActivated, + handleDeactivated + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket]) + return activeDetours +} + +// This is to refresh the Detours List page, past detours section +export const usePastDetours = (socket: Socket | undefined) => { + const topic = "detours:past" + const [pastDetours, setPastDetours] = useState({}) + + const handleDeactivated = (data: SimpleDetour) => { + setPastDetours((pastDetours) => ({ ...pastDetours, [data.id]: data })) + } + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + channel = subscribe( + socket, + topic, + setPastDetours, + undefined, + undefined, + handleDeactivated + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket]) + return pastDetours +} + +// This is to refresh the Detours List page, just the current user drafts +export const useDraftDetours = (socket: Socket | undefined) => { + const topic = "detours:draft:" + userUuid() + const [draftDetours, setDraftDetours] = useState({}) + + const handleDrafted = (data: SimpleDetour) => { + setDraftDetours((draftDetours) => ({ ...draftDetours, [data.id]: data })) + } + + const handleActivated = (data: SimpleDetour) => { + setDraftDetours((draftDetours) => { + delete draftDetours[data.id] + return draftDetours + }) + } + + useEffect(() => { + let channel: Channel | undefined + if (socket) { + channel = subscribe( + socket, + topic, + setDraftDetours, + handleDrafted, + handleActivated, + undefined + ) + } + + return () => { + if (channel !== undefined) { + channel.leave() + channel = undefined + } + } + }, [socket, topic]) + return draftDetours +} + +const subscribeByRoute = ( + socket: Socket, + topic: string, + routeId: string, + setDetours: React.Dispatch>> +): Channel => { + const channel = socket.channel(topic + routeId) + + channel.on("activated", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, SimpleDetourData) + setDetours((activeDetours) => ({ + ...activeDetours, + [routeId]: { + ...activeDetours[routeId], + [data.id]: simpleDetourFromData(data), + }, + })) + }) + channel.on("deactivated", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, SimpleDetourData) + setDetours((activeDetours) => { + delete activeDetours[routeId][data.id] + return activeDetours + }) + }) + channel.on("auth_expired", reload) + + channel + .join() + .receive("ok", ({ data: unknownData }: { data: unknown }) => { + const data = create(unknownData, array(SimpleDetourData)) + const detoursMap = Object.fromEntries( + data.map((v) => [v.id, simpleDetourFromData(v)]) + ) + setDetours((detoursByRouteId) => ({ + ...detoursByRouteId, + [routeId]: detoursMap, + })) + }) + + .receive("error", ({ reason }) => { + if (reason === "not_authenticated") { + reload() + } else { + // eslint-disable-next-line no-console + console.error(`joining topic ${topic} failed`, reason) + } + }) + .receive("timeout", reload) + + return channel +} + +// This is to refresh the Route Ladders +export const useActiveDetoursByRoute = ( + socket: Socket | undefined, + routeIds: RouteId[] +): ByRouteId => { + const baseTopic = "detours:active:" + const [activeDetoursByRoute, setActiveDetoursByRoute] = useState< + ByRouteId + >({}) + // eslint-disable-next-line react/hook-use-state + const [, setChannelsByRouteId] = useState>({}) + + const [currentRouteIds, setCurrentRouteIds] = useState(routeIds) + + if (!equalByElements(currentRouteIds, routeIds)) { + setCurrentRouteIds(routeIds) + } + + useEffect(() => { + if (socket) { + setChannelsByRouteId((oldChannelsByRoutId) => { + const channelsByRouteId: ByRouteId = {} + + Object.entries(oldChannelsByRoutId).forEach(([routeId, channel]) => { + if (!currentRouteIds.includes(routeId)) { + channel.leave() + } + }) + + currentRouteIds.forEach((routeId) => { + if (routeId in oldChannelsByRoutId) { + channelsByRouteId[routeId] = oldChannelsByRoutId[routeId] + } else { + channelsByRouteId[routeId] = subscribeByRoute( + socket, + baseTopic, + routeId, + setActiveDetoursByRoute + ) + } + }) + + return channelsByRouteId + }) + } + }, [socket, currentRouteIds]) + + return activeDetoursByRoute +} diff --git a/assets/tests/factories/detourListFactory.ts b/assets/tests/factories/detourListFactory.ts index d90ba8315..4884046be 100644 --- a/assets/tests/factories/detourListFactory.ts +++ b/assets/tests/factories/detourListFactory.ts @@ -1,25 +1,34 @@ import { Factory } from "fishery" import { GroupedSimpleDetours, - SimpleDetour, + SimpleDetourData, + simpleDetourFromData, } from "../../src/models/detoursList" export const detourListFactory = Factory.define(() => { return { active: [ - simpleDetourFactory.build(), - simpleDetourFactory.build({ direction: "Outbound" }), + simpleDetourFromData(simpleDetourDataFactory.build()), + simpleDetourFromData( + simpleDetourDataFactory.build({ direction: "Outbound" }) + ), ], draft: undefined, - past: [simpleDetourFactory.build({ name: "Headsign Z" })], + past: [ + simpleDetourFromData( + simpleDetourDataFactory.build({ name: "Headsign Z" }) + ), + ], } }) -const simpleDetourFactory = Factory.define(({ sequence }) => ({ - id: sequence, - route: `${sequence}`, - direction: "Inbound", - name: `Headsign ${sequence}`, - intersection: `Street A${sequence} & Avenue B${sequence}`, - updatedAt: 1724866392, -})) +export const simpleDetourDataFactory = Factory.define( + ({ sequence }) => ({ + id: sequence, + route: `${sequence}`, + direction: "Inbound", + name: `Headsign ${sequence}`, + intersection: `Street A${sequence} & Avenue B${sequence}`, + updated_at: 1724866392, + }) +) diff --git a/assets/tests/hooks/useDetours.test.ts b/assets/tests/hooks/useDetours.test.ts new file mode 100644 index 000000000..17c04b258 --- /dev/null +++ b/assets/tests/hooks/useDetours.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "@jest/globals" +import { makeMockChannel, makeMockSocket } from "../testHelpers/socketHelpers" +import { act, renderHook } from "@testing-library/react" +import { simpleDetourDataFactory } from "../factories/detourListFactory" +import { + useActiveDetours, + useActiveDetoursByRoute, + useDraftDetours, + usePastDetours, +} from "../../src/hooks/useDetours" +import { + SimpleDetourData, + simpleDetourFromData, +} from "../../src/models/detoursList" +import { RouteId } from "../../src/schedule" + +const detourA = simpleDetourDataFactory.build() +const detourB = simpleDetourDataFactory.build() +const detourC = simpleDetourDataFactory.build() +const detourD = simpleDetourDataFactory.build() + +const parsedDetourA = simpleDetourFromData(detourA) +const parsedDetourB = simpleDetourFromData(detourB) +const parsedDetourC = simpleDetourFromData(detourC) +const parsedDetourD = simpleDetourFromData(detourD) + +const detours = [detourA, detourB, detourC] + +describe("useActiveDetours", () => { + test("parses initial detours message from joining a channel", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + mockSocket.channel.mockImplementation(() => mockChannel) + const { result } = renderHook(() => useActiveDetours(mockSocket)) + + expect(result.current).toStrictEqual({ + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + }) + }) + + test("parses an activated detour event", async () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + + const mockEvents: Record< + string, + undefined | ((data: { data: SimpleDetourData }) => void) + > = { + activated: undefined, + } + mockChannel.on.mockImplementation((event, fn) => { + mockEvents[event] = fn + return 1 + }) + + mockSocket.channel.mockImplementation(() => mockChannel) + + const { result } = renderHook(() => useActiveDetours(mockSocket)) + + act(() => mockEvents["activated"]?.({ data: detourD })) + + expect(result.current).toStrictEqual({ + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + [detourD.id]: parsedDetourD, + }) + }) + + test("parses a deactivated detour event", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + + const mockEvents: Record< + string, + undefined | ((data: { data: SimpleDetourData }) => void) + > = { + deactivated: undefined, + } + mockChannel.on.mockImplementation((event, fn) => { + mockEvents[event] = fn + return 1 + }) + + mockSocket.channel.mockImplementation(() => mockChannel) + + const { result } = renderHook(() => useActiveDetours(mockSocket)) + + act(() => mockEvents["deactivated"]?.({ data: detourA })) + + expect(result.current).toStrictEqual({ + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + }) + }) +}) + +describe("usePastDetours", () => { + test("parses initial detours message", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + mockSocket.channel.mockImplementation(() => mockChannel) + const { result } = renderHook(() => usePastDetours(mockSocket)) + + expect(result.current).toStrictEqual({ + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + }) + }) + + test("parses a deactivated detour event", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + + const mockEvents: Record< + string, + undefined | ((data: { data: SimpleDetourData }) => void) + > = { + deactivated: undefined, + } + mockChannel.on.mockImplementation((event, fn) => { + mockEvents[event] = fn + return 1 + }) + + mockSocket.channel.mockImplementation(() => mockChannel) + + const { result } = renderHook(() => usePastDetours(mockSocket)) + + act(() => mockEvents["deactivated"]?.({ data: detourD })) + + expect(result.current).toStrictEqual({ + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + [detourD.id]: parsedDetourD, + }) + }) +}) + +describe("useDraftDetours", () => { + test("parses initial detours message", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + mockSocket.channel.mockImplementation(() => mockChannel) + const { result } = renderHook(() => useDraftDetours(mockSocket)) + + expect(result.current).toStrictEqual({ + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + }) + }) + + test("parses a drafted detour event", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + + const mockEvents: Record< + string, + undefined | ((data: { data: SimpleDetourData }) => void) + > = { + drafted: undefined, + } + mockChannel.on.mockImplementation((event, fn) => { + mockEvents[event] = fn + return 1 + }) + + mockSocket.channel.mockImplementation(() => mockChannel) + + const { result } = renderHook(() => useDraftDetours(mockSocket)) + + act(() => mockEvents["drafted"]?.({ data: detourD })) + + expect(result.current).toStrictEqual({ + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + [detourD.id]: parsedDetourD, + }) + }) + + test("parses an activated detour event", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel("ok", { data: detours }) + + const mockEvents: Record< + string, + undefined | ((data: { data: SimpleDetourData }) => void) + > = { + activated: undefined, + } + mockChannel.on.mockImplementation((event, fn) => { + mockEvents[event] = fn + return 1 + }) + + mockSocket.channel.mockImplementation(() => mockChannel) + + const { result } = renderHook(() => useDraftDetours(mockSocket)) + + act(() => mockEvents["activated"]?.({ data: detourA })) + + expect(result.current).toStrictEqual({ + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + }) + }) +}) + +// Not totally comprehensive, but was having difficulty mocking both initial results +// from joining a channel and results pushed to a channel at the same time +describe("useActiveDetoursByRoute", () => { + test("selecting a new route subscribes to the new channel", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel() + mockSocket.channel.mockImplementation(() => mockChannel) + + const { rerender } = renderHook(() => + useActiveDetoursByRoute(mockSocket, ["1", "2"]) + ) + + // Needs to be kicked to do the effects again after the socket initializes + rerender() + + expect(mockSocket.channel).toHaveBeenCalledTimes(2) + expect(mockSocket.channel).toHaveBeenCalledWith("detours:active:1") + expect(mockSocket.channel).toHaveBeenCalledWith("detours:active:2") + expect(mockChannel.join).toHaveBeenCalledTimes(2) + }) + + test("unselecting a route unsubscribes from the channel", () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel() + mockSocket.channel.mockImplementation(() => mockChannel) + + const { rerender } = renderHook( + (selectedRouteIds: RouteId[]) => + useActiveDetoursByRoute(mockSocket, selectedRouteIds), + { initialProps: ["1", "2"] } + ) + rerender(["1"]) // Deselect the route + + expect(mockChannel.leave).toHaveBeenCalledTimes(1) + }) + + test("returns results from joining a channel", async () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel() + mockSocket.channel.mockImplementation(() => mockChannel) + mockChannel.receive.mockImplementation((event, handler) => { + if (event === "ok") { + handler({ data: detours }) + } + return mockChannel + }) + + const { result } = renderHook(() => + useActiveDetoursByRoute(mockSocket, ["1"]) + ) + + expect(result.current).toEqual({ + "1": { + [detourA.id]: parsedDetourA, + [detourB.id]: parsedDetourB, + [detourC.id]: parsedDetourC, + }, + }) + }) + + test("returns results pushed to the channel", async () => { + const mockSocket = makeMockSocket() + const mockChannel = makeMockChannel() + mockSocket.channel.mockImplementation(() => mockChannel) + mockChannel.on.mockImplementation((event, handler) => { + if (event === "activated") { + handler({ data: detourD }) + } + return 1 + }) + + const { result } = renderHook(() => + useActiveDetoursByRoute(mockSocket, ["1"]) + ) + + expect(result.current).toEqual({ + "1": { + [detourD.id]: parsedDetourD, + }, + }) + }) +}) diff --git a/lib/skate/detours/db/detour.ex b/lib/skate/detours/db/detour.ex index 060dd19cf..9a8b32b55 100644 --- a/lib/skate/detours/db/detour.ex +++ b/lib/skate/detours/db/detour.ex @@ -22,5 +22,6 @@ defmodule Skate.Detours.Db.Detour do detour |> cast(attrs, [:state, :activated_at]) |> validate_required([:state]) + |> foreign_key_constraint(:author_id) end end diff --git a/lib/skate/detours/detours.ex b/lib/skate/detours/detours.ex index 53e0b2fc1..9483835eb 100644 --- a/lib/skate/detours/detours.ex +++ b/lib/skate/detours/detours.ex @@ -10,6 +10,7 @@ defmodule Skate.Detours.Detours do alias Skate.Detours.Detour.Detailed, as: DetailedDetour alias Skate.Detours.Detour.WithState, as: DetourWithState alias Skate.Settings.User + alias Skate.Settings.Db.User, as: DbUser @doc """ Returns the list of detours with author, sorted by updated_at @@ -28,6 +29,22 @@ defmodule Skate.Detours.Detours do |> Repo.all() end + @doc """ + Returns the list of detours by route id with author, sorted by updated_at + + ## Examples + + iex> active_detours_by_route() + [%Detour{}, ...] + """ + def active_detours_by_route(route_id) do + list_detours() + |> Enum.filter(fn detour -> + categorize_detour(detour) == :active and get_detour_route_id(detour) == route_id + end) + |> Enum.map(fn detour -> db_detour_to_detour(detour) end) + end + @doc """ Returns the detours grouped by active, draft, and past. @@ -53,10 +70,12 @@ defmodule Skate.Detours.Detours do |> Enum.group_by(fn detour -> detour.status end) %{ - active: Map.get(detours, :active), + active: Map.get(detours, :active, []), draft: - detours |> Map.get(:draft) |> Enum.filter(fn detour -> detour.author_id == user_id end), - past: Map.get(detours, :past) + detours + |> Map.get(:draft, []) + |> Enum.filter(fn detour -> detour.author_id == user_id end), + past: Map.get(detours, :past, []) } end @@ -112,6 +131,10 @@ defmodule Skate.Detours.Detours do def categorize_detour(_detour_context), do: :draft + @spec get_detour_route_id(detour :: map()) :: String.t() + defp get_detour_route_id(%{state: %{"context" => %{"route" => %{"id" => route_id}}}}), + do: route_id + @doc """ Gets a single detour. @@ -208,6 +231,10 @@ defmodule Skate.Detours.Detours do case detour_db_result do {:ok, %Detour{} = new_record} -> + new_record + |> categorize_detour() + |> broadcast_detour(new_record, author_id) + send_notification(new_record, previous_record) _ -> @@ -217,6 +244,69 @@ defmodule Skate.Detours.Detours do detour_db_result end + @spec broadcast_detour(detour_type(), Detour.t(), DbUser.id()) :: :ok + defp broadcast_detour(:draft, detour, author_id) do + author_uuid = + author_id + |> User.get_by_id!() + |> Map.get(:uuid) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:draft:" <> author_uuid, + {:detour_drafted, db_detour_to_detour(detour)} + ) + end + + defp broadcast_detour(:active, detour, author_id) do + author_uuid = + author_id + |> User.get_by_id!() + |> Map.get(:uuid) + + route_id = get_detour_route_id(detour) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:draft:" <> author_uuid, + {:detour_activated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active:" <> route_id, + {:detour_activated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active", + {:detour_activated, db_detour_to_detour(detour)} + ) + end + + defp broadcast_detour(:past, detour, _author_id) do + route_id = get_detour_route_id(detour) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active:" <> route_id, + {:detour_deactivated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:active", + {:detour_deactivated, db_detour_to_detour(detour)} + ) + + Phoenix.PubSub.broadcast( + Skate.PubSub, + "detours:past", + {:detour_deactivated, db_detour_to_detour(detour)} + ) + end + @doc """ Retrieves a `Skate.Detours.Db.Detour` from the database by it's ID and then resolves the detour's category via `categorize_detour/2` diff --git a/lib/skate_web/channels/detours_channel.ex b/lib/skate_web/channels/detours_channel.ex new file mode 100644 index 000000000..616ca19b2 --- /dev/null +++ b/lib/skate_web/channels/detours_channel.ex @@ -0,0 +1,60 @@ +defmodule SkateWeb.DetoursChannel do + @moduledoc false + + use SkateWeb, :channel + use SkateWeb.AuthenticatedChannel + + alias Skate.Detours.Detours + + # Active + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:active", _message, socket) do + SkateWeb.Endpoint.subscribe("detours:active") + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + detours = Detours.grouped_detours(user_id)[:active] + {:ok, %{data: detours}, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:active:" <> route_id, _message, socket) do + SkateWeb.Endpoint.subscribe("detours:active:" <> route_id) + detours = Detours.active_detours_by_route(route_id) + {:ok, %{data: detours}, socket} + end + + # Past + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:past", _message, socket) do + SkateWeb.Endpoint.subscribe("detours:past") + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + detours = Detours.grouped_detours(user_id)[:past] + {:ok, %{data: detours}, socket} + end + + # Draft + @impl SkateWeb.AuthenticatedChannel + def join_authenticated("detours:draft:" <> author_uuid, _message, socket) do + SkateWeb.Endpoint.subscribe("detours:draft:" <> author_uuid) + %{id: user_id} = Guardian.Phoenix.Socket.current_resource(socket) + detours = Detours.grouped_detours(user_id)[:draft] + {:ok, %{data: detours}, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def handle_info_authenticated({:detour_activated, detour}, socket) do + :ok = push(socket, "activated", %{data: detour}) + {:noreply, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def handle_info_authenticated({:detour_deactivated, detour}, socket) do + :ok = push(socket, "deactivated", %{data: detour}) + {:noreply, socket} + end + + @impl SkateWeb.AuthenticatedChannel + def handle_info_authenticated({:detour_drafted, detour}, socket) do + :ok = push(socket, "drafted", %{data: detour}) + {:noreply, socket} + end +end diff --git a/lib/skate_web/channels/user_socket.ex b/lib/skate_web/channels/user_socket.ex index b1a03456d..42e8f2bfc 100644 --- a/lib/skate_web/channels/user_socket.ex +++ b/lib/skate_web/channels/user_socket.ex @@ -10,6 +10,7 @@ defmodule SkateWeb.UserSocket do channel("train_vehicles:*", SkateWeb.TrainVehiclesChannel) channel("notifications", SkateWeb.NotificationsChannel) channel("alerts:*", SkateWeb.AlertsChannel) + channel("detours:*", SkateWeb.DetoursChannel) # Socket params are passed from the client and can # be used to verify and authenticate a user. After diff --git a/test/skate_web/channels/detours_channel_test.exs b/test/skate_web/channels/detours_channel_test.exs new file mode 100644 index 000000000..6fc74271c --- /dev/null +++ b/test/skate_web/channels/detours_channel_test.exs @@ -0,0 +1,376 @@ +defmodule SkateWeb.DetoursChannelTest do + use SkateWeb.ConnCase + + import Phoenix.ChannelTest + + import Test.Support.Helpers + import Skate.Factory + + alias Phoenix.Socket + alias SkateWeb.{UserSocket, DetoursChannel} + + setup %{conn: conn} do + reassign_env(:skate, :valid_token_fn, fn _socket -> true end) + + %{id: authenticated_user_id} = SkateWeb.AuthManager.Plug.current_resource(conn) + + socket = + UserSocket + |> socket("", %{}) + |> Guardian.Phoenix.Socket.put_current_resource(%{id: authenticated_user_id}) + + start_supervised({Phoenix.PubSub, name: Skate.PubSub}) + + {:ok, %{conn: conn, socket: socket}} + end + + describe "join/3" do + @tag :authenticated + test "subscribes to all active detours with initial detours", %{socket: socket} do + :detour |> build() |> with_id(1) |> insert() + + :detour + |> build() + |> activated + |> with_id(2) + |> with_route(%{name: "57", id: "57"}) + |> insert() + + :detour + |> build() + |> activated + |> with_id(3) + |> with_route(%{name: "66", id: "66"}) + |> insert() + + :detour |> build() |> deactivated |> with_id(4) |> insert() + + assert {:ok, + %{ + data: [ + %Skate.Detours.Detour.Detailed{ + author_id: _, + direction: _, + id: 2, + intersection: "detour_nearest_intersection:" <> _, + name: "detour_route_pattern_headsign:" <> _, + route: "57", + status: :active, + updated_at: _ + }, + %Skate.Detours.Detour.Detailed{ + author_id: _, + direction: _, + id: 3, + intersection: "detour_nearest_intersection:" <> _, + name: "detour_route_pattern_headsign:" <> _, + route: "66", + status: :active, + updated_at: _ + } + ] + }, + %Socket{}} = + subscribe_and_join(socket, DetoursChannel, "detours:active") + end + + @tag :authenticated + test "subscribes to active detours for one route", %{socket: socket} do + :detour |> build() |> with_id(1) |> insert() + + :detour + |> build() + |> activated + |> with_id(2) + |> with_route(%{name: "57", id: "57"}) + |> insert() + + :detour + |> build() + |> activated + |> with_id(3) + |> with_route(%{name: "66", id: "66"}) + |> insert() + + :detour |> build() |> deactivated |> with_id(4) |> insert() + + assert {:ok, + %{ + data: [ + %Skate.Detours.Detour.Detailed{ + author_id: _, + direction: _, + id: 3, + intersection: "detour_nearest_intersection:" <> _, + name: "detour_route_pattern_headsign:" <> _, + route: "66", + status: :active, + updated_at: _ + } + ] + }, + %Socket{}} = + subscribe_and_join(socket, DetoursChannel, "detours:active:66") + end + + @tag :authenticated + test "subscribes to active detours for SL1", %{socket: socket} do + :detour |> build() |> with_id(1) |> insert() + + :detour + |> build() + |> activated + |> with_id(2) + |> with_route(%{name: "SL1", id: "741"}) + |> insert() + + :detour |> build() |> deactivated |> with_id(3) |> insert() + + assert {:ok, + %{ + data: [ + %Skate.Detours.Detour.Detailed{ + author_id: _, + direction: _, + id: 2, + intersection: "detour_nearest_intersection:" <> _, + name: "detour_route_pattern_headsign:" <> _, + route: "SL1", + status: :active, + updated_at: _ + } + ] + }, + %Socket{}} = + subscribe_and_join(socket, DetoursChannel, "detours:active:741") + end + + @tag :authenticated + test "subscribes to draft detours with initial detours", %{ + conn: conn, + socket: socket, + user: user + } do + %{id: authenticated_user_id} = SkateWeb.AuthManager.Plug.current_resource(conn) + + :detour |> build() |> with_id(1) |> insert() + :detour |> build() |> with_id(2) |> insert() + :detour |> build() |> with_id(3) |> insert() + :detour |> build(author: user) |> with_id(4) |> insert() + + assert {:ok, + %{ + data: [ + %Skate.Detours.Detour.Detailed{ + author_id: ^authenticated_user_id, + direction: _, + id: 4, + intersection: "detour_nearest_intersection:" <> _, + name: "detour_route_pattern_headsign:" <> _, + route: "detour_route_name:" <> _, + status: :draft, + updated_at: _ + } + ] + }, + %Socket{}} = + subscribe_and_join( + socket, + DetoursChannel, + "detours:draft:" <> Integer.to_string(authenticated_user_id) + ) + end + + @tag :authenticated + test "subscribes to past detours with initial detours", %{socket: socket} do + :detour |> build() |> with_id(1) |> insert() + + :detour + |> build() + |> activated + |> with_id(2) + |> with_route(%{name: "57", id: "57"}) + |> insert() + + :detour + |> build() + |> activated + |> with_id(3) + |> with_route(%{name: "66", id: "66"}) + |> insert() + + :detour |> build() |> deactivated |> with_id(4) |> insert() + + assert {:ok, + %{ + data: [ + %Skate.Detours.Detour.Detailed{ + author_id: _, + direction: _, + id: 4, + intersection: "detour_nearest_intersection:" <> _, + name: "detour_route_pattern_headsign:" <> _, + route: "detour_route_name:" <> _, + status: :past, + updated_at: _ + } + ] + }, + %Socket{}} = + subscribe_and_join(socket, DetoursChannel, "detours:past") + end + + @tag :authenticated + test "deny topic subscription when socket token validation fails", %{socket: socket} do + :detour |> build() |> with_id(1) |> insert() + + :detour + |> build() + |> activated + |> with_id(2) + |> with_route(%{name: "57", id: "57"}) + |> insert() + + :detour + |> build() + |> activated + |> with_id(3) + |> with_route(%{name: "66", id: "66"}) + |> insert() + + :detour |> build() |> deactivated |> with_id(4) |> insert() + + reassign_env(:skate, :valid_token_fn, fn _socket -> false end) + + for route <- [ + "detours:active", + "random:topic:" + ], + do: + assert( + {:error, %{reason: :not_authenticated}} = + subscribe_and_join(socket, DetoursChannel, route) + ) + end + end + + describe "handle_info/2" do + @tag :authenticated + test "pushes newly drafted detour onto the draft detour socket", %{conn: conn, socket: socket} do + %{id: user_id} = SkateWeb.AuthManager.Plug.current_resource(conn) + + {:ok, _, socket} = + subscribe_and_join(socket, DetoursChannel, "detours:draft:" <> Integer.to_string(user_id)) + + detour = build(:detour_snapshot) + + assert {:noreply, _socket} = + DetoursChannel.handle_info( + {:detour_drafted, detour}, + socket + ) + + assert_push("drafted", %{data: ^detour}) + end + + @tag :authenticated + test "pushes newly activated detour onto the active detour socket", %{socket: socket} do + {:ok, _, socket} = subscribe_and_join(socket, DetoursChannel, "detours:active") + + detour = :detour_snapshot |> build() |> activated + + assert {:noreply, _socket} = + DetoursChannel.handle_info( + {:detour_activated, detour}, + socket + ) + + assert_push("activated", %{data: ^detour}) + end + + @tag :authenticated + test "pushes newly activated detour onto the draft detour socket", %{ + conn: conn, + socket: socket + } do + %{id: user_id} = SkateWeb.AuthManager.Plug.current_resource(conn) + + {:ok, _, socket} = + subscribe_and_join(socket, DetoursChannel, "detours:draft:" <> Integer.to_string(user_id)) + + detour = :detour_snapshot |> build() |> activated + + assert {:noreply, _socket} = + DetoursChannel.handle_info( + {:detour_activated, detour}, + socket + ) + + assert_push("activated", %{data: ^detour}) + end + + @tag :authenticated + test "rejects sending activated detour when socket is not authenticated", %{socket: socket} do + {:ok, _, socket} = subscribe_and_join(socket, DetoursChannel, "detours:active") + + reassign_env(:skate, :valid_token_fn, fn _socket -> false end) + + detour = :detour_snapshot |> build() |> activated + + assert {:stop, :normal, _socket} = + DetoursChannel.handle_info( + {:detour_activated, detour}, + socket + ) + + assert_push("auth_expired", _) + end + + @tag :authenticated + test "pushes newly deactivated detour onto the active detour socket", %{socket: socket} do + {:ok, _, socket} = subscribe_and_join(socket, DetoursChannel, "detours:active") + + detour = :detour_snapshot |> build() |> deactivated + + assert {:noreply, _socket} = + DetoursChannel.handle_info( + {:detour_deactivated, detour}, + socket + ) + + assert_push("deactivated", %{data: ^detour}) + end + + @tag :authenticated + test "pushes newly deactivated detour onto the past detour socket", %{socket: socket} do + {:ok, _, socket} = subscribe_and_join(socket, DetoursChannel, "detours:past") + + detour = :detour_snapshot |> build() |> deactivated + + assert {:noreply, _socket} = + DetoursChannel.handle_info( + {:detour_deactivated, detour}, + socket + ) + + assert_push("deactivated", %{data: ^detour}) + end + + @tag :authenticated + test "rejects sending deactivated detour when socket is not authenticated", %{socket: socket} do + {:ok, _, socket} = subscribe_and_join(socket, DetoursChannel, "detours:past") + + reassign_env(:skate, :valid_token_fn, fn _socket -> false end) + + detour = :detour_snapshot |> build() |> deactivated + + assert {:stop, :normal, _socket} = + DetoursChannel.handle_info( + {:detour_deactivated, detour}, + socket + ) + + assert_push("auth_expired", _) + end + end +end diff --git a/test/skate_web/controllers/detours_controller_test.exs b/test/skate_web/controllers/detours_controller_test.exs index bc2097dba..5721aef3c 100644 --- a/test/skate_web/controllers/detours_controller_test.exs +++ b/test/skate_web/controllers/detours_controller_test.exs @@ -149,6 +149,7 @@ defmodule SkateWeb.DetoursControllerTest do "snapshot" => %{ "context" => %{ "route" => %{ + "id" => "23", "name" => "23", "directionNames" => %{ "0" => "Outbound", @@ -171,6 +172,7 @@ defmodule SkateWeb.DetoursControllerTest do "snapshot" => %{ "context" => %{ "route" => %{ + "id" => "47", "name" => "47", "directionNames" => %{ "0" => "Outbound", @@ -193,6 +195,7 @@ defmodule SkateWeb.DetoursControllerTest do "snapshot" => %{ "context" => %{ "route" => %{ + "id" => "75", "name" => "75", "directionNames" => %{ "0" => "Outbound", @@ -403,12 +406,13 @@ defmodule SkateWeb.DetoursControllerTest do test "will not return detours from other users", %{conn: conn} do current_user_id = populate_db_and_get_user(conn) - other_user = build(:user) + other_user = insert(:user) # Manually insert a detour by another user Detours.upsert_from_snapshot(other_user.id, %{ "context" => %{ "route" => %{ + # "id" => "23", "name" => "23", "directionNames" => %{ "0" => "Outbound", @@ -474,6 +478,7 @@ defmodule SkateWeb.DetoursControllerTest do "snapshot" => %{ "context" => %{ "route" => %{ + "id" => "23", "name" => "23", "directionNames" => %{ "0" => "Outbound", @@ -495,6 +500,7 @@ defmodule SkateWeb.DetoursControllerTest do "snapshot" => %{ "context" => %{ "route" => %{ + "id" => "23", "name" => "23" }, "routePattern" => %{ diff --git a/test/support/factories/detour_factory.ex b/test/support/factories/detour_factory.ex index 723f3f261..0b9d7edbd 100644 --- a/test/support/factories/detour_factory.ex +++ b/test/support/factories/detour_factory.ex @@ -26,6 +26,7 @@ defmodule Skate.DetourFactory do "context" => %{ "uuid" => nil, "route" => %{ + "id" => sequence("detour_route_id:"), "name" => sequence("detour_route_name:"), "directionNames" => %{ "0" => "Outbound", @@ -36,7 +37,8 @@ defmodule Skate.DetourFactory do "name" => sequence("detour_route_pattern_name:"), "headsign" => sequence("detour_route_pattern_headsign:"), "directionId" => sequence(:detour_route_pattern_direction, [0, 1]) - } + }, + "nearestIntersection" => sequence("detour_nearest_intersection:") }, "value" => %{}, "children" => %{}, @@ -88,6 +90,41 @@ defmodule Skate.DetourFactory do put_in(state["value"], %{"Detour Drawing" => "Past"}) end + def with_route(%Skate.Detours.Db.Detour{} = detour, %{name: _, id: _} = route) do + %{detour | state: with_route(detour.state, route)} + end + + def with_route( + %{"context" => %{"route" => %{}}} = state, + %{name: route_name, id: route_id} + ) do + state + |> with_route_id(route_id) + |> with_route_name(route_name) + end + + def with_route_name(%Skate.Detours.Db.Detour{} = detour, name) do + %{detour | state: with_route_name(detour.state, name)} + end + + def with_route_name( + %{"context" => %{"route" => %{"name" => _}}} = state, + name + ) do + put_in(state["context"]["route"]["name"], name) + end + + def with_route_id(%Skate.Detours.Db.Detour{} = detour, id) do + %{detour | state: with_route_id(detour.state, id)} + end + + def with_route_id( + %{"context" => %{"route" => %{"id" => _}}} = state, + id + ) do + put_in(state["context"]["route"]["id"], id) + end + def with_direction(%Skate.Detours.Db.Detour{} = detour, direction) do %{ detour