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
25 changes: 21 additions & 4 deletions backend/internal/handlers/slackHandlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -693,16 +693,33 @@ func addParticipantToSlackCall(botToken, callID string, user *models.User) error
return api.CallAddParticipants(callID, []slack.CallParticipant{participant})
}

// buildDesktopJoinURL builds the hopp:// deep link that opens a session directly
// in the desktop app. Slack prefers this URL over the HTTPS join URL when the
// desktop app is installed, so users land in the app instead of the browser.
// The session ID is query-encoded, and the desktop app parses this shape in
// `processDeepLinkUrl` (tauri/src/lib/deepLinkUtils.ts).
func buildDesktopJoinURL(sessionID string) string {
deepLink := url.URL{
Scheme: "hopp",
Path: "/join-session",
RawQuery: url.Values{"sessionId": []string{sessionID}}.Encode(),
}
return deepLink.String()
}

// createSlackCall creates a call using Slack's Calls API for native call UI
func (h *SlackHandler) createSlackCall(botToken, externalID, createdBySlackUserID, creatorName, joinURL, channelID string) (*SlackCall, error) {
api := newSlackClient(botToken)

// Create the call using the SDK
// DesktopAppJoinURL sends users to the desktop app, while JoinURL stays as the
// HTTPS fallback for anyone without the app installed.
call, err := api.AddCall(slack.AddCallParameters{
JoinURL: joinURL,
ExternalUniqueID: externalID,
CreatedBy: createdBySlackUserID,
Title: fmt.Sprintf("%s started a Hopp pairing session", creatorName),
JoinURL: joinURL,
DesktopAppJoinURL: buildDesktopJoinURL(externalID),
ExternalUniqueID: externalID,
CreatedBy: createdBySlackUserID,
Title: fmt.Sprintf("%s started a Hopp pairing session", creatorName),
})
if err != nil {
return nil, fmt.Errorf("calls.add error: %w", err)
Expand Down
63 changes: 63 additions & 0 deletions backend/internal/handlers/slackHandlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package handlers

import (
"net/url"
"testing"
)

// TestBuildDesktopJoinURL checks the exact deep link shape Slack is given for
// the "Join" button, since the desktop app matches on the path and reads the
// session from the query string.
func TestBuildDesktopJoinURL(t *testing.T) {
tests := []struct {
name string
sessionID string
want string
}{
{
name: "uuid session id",
sessionID: "0f0f2a3c-3d4b-4a1e-9c2f-8f7b6a5d4c3b",
want: "hopp:///join-session?sessionId=0f0f2a3c-3d4b-4a1e-9c2f-8f7b6a5d4c3b",
},
{
name: "session id needing query escaping",
sessionID: "room id&other=1",
want: "hopp:///join-session?sessionId=room+id%26other%3D1",
},
{
name: "empty session id",
sessionID: "",
want: "hopp:///join-session?sessionId=",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := buildDesktopJoinURL(tt.sessionID); got != tt.want {
t.Errorf("buildDesktopJoinURL(%q) = %q, want %q", tt.sessionID, got, tt.want)
}
})
}
}

// TestBuildDesktopJoinURLParsesBackToSessionID guards the contract the desktop
// app relies on: scheme "hopp", path "/join-session", and a sessionId query
// parameter that round-trips to the original room ID.
func TestBuildDesktopJoinURLParsesBackToSessionID(t *testing.T) {
const sessionID = "room id&other=1"

parsed, err := url.Parse(buildDesktopJoinURL(sessionID))
if err != nil {
t.Fatalf("url.Parse() returned an error: %v", err)
}

if parsed.Scheme != "hopp" {
t.Errorf("scheme = %q, want %q", parsed.Scheme, "hopp")
}
if parsed.Path != "/join-session" {
t.Errorf("path = %q, want %q", parsed.Path, "/join-session")
}
if got := parsed.Query().Get("sessionId"); got != sessionID {
t.Errorf("sessionId = %q, want %q", got, sessionID)
}
}
256 changes: 256 additions & 0 deletions tauri/src/lib/deepLinkUtils.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/**
* Tests for the Slack join-session deep link handler.
*
* The app has no frontend test runner, so this runs on `node --test` and
* transpiles the real `deepLinkUtils.ts` in memory, injecting fake modules for
* its imports. That keeps the assertions against the actual handler code
* instead of a copy of it.
*
* Run with:
* cd tauri && node --test src/lib/deepLinkUtils.test.mjs
*/
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import ts from "typescript";

const sourcePath = path.join(path.dirname(fileURLToPath(import.meta.url)), "deepLinkUtils.ts");

const SESSION_ID = "session-123";
const TOKENS = { audioToken: "audio-token", videoToken: "video-token", participant: "participant-1" };

/**
* Transpiles deepLinkUtils.ts and loads it with fake modules, returning the
* module exports plus the recorded calls made against those fakes.
*/
function loadDeepLinkUtils({ authToken = "auth-token", callTokens = null, fetchImpl, callStartedImpl } = {}) {
const calls = [];
const record = (name, ...args) => calls.push({ name, args });

const state = {
authToken,
callTokens,
user: { id: "user-1" },
setCallTokens: (tokens) => {
state.callTokens = tokens;
record("setCallTokens", tokens);
},
setTab: (tab) => record("setTab", tab),
};

const useStore = { getState: () => state };

const tauriUtils = {
showWindow: async (label) => record("showWindow", label),
getCallStartPreferences: async () => ({ startMic: true, startCamera: false }),
callStarted: async (audioToken, videoToken) => {
record("callStarted", audioToken, videoToken);
if (callStartedImpl) await callStartedImpl();
},
endCallCleanup: () => record("endCallCleanup"),
stopSharing: () => record("stopSharing"),
};

const toast = (message) => record("toast", message);
toast.error = (message) => record("toast.error", message);
toast.loading = (message, options) => record("toast.loading", message, options);
toast.dismiss = (id) => record("toast.dismiss", id);
toast.success = (message) => record("toast.success", message);

const mocks = {
"react-hot-toast": { __esModule: true, default: toast },
"@/store/store": { __esModule: true, default: useStore, ParticipantRole: { NONE: "none", SHARER: "sharer" } },
"@/windows/window-utils": { __esModule: true, tauriUtils },
"@/constants": { __esModule: true, Constants: { backendUrl: "https://example.test" } },
"@/services/socket": {
__esModule: true,
socketService: { send: (message) => record("socket.send", message) },
},
"./authUtils": { __esModule: true, validateAndSetAuthToken: async () => record("validateAndSetAuthToken") },
};

const fetchMock = async (url, options) => {
record("fetch", url, options?.method ?? "GET");
return fetchImpl ? fetchImpl(url, options) : jsonResponse(TOKENS);
};

const { outputText } = ts.transpileModule(readFileSync(sourcePath, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
});

const module = { exports: {} };
const requireShim = (specifier) => {
if (!(specifier in mocks)) throw new Error(`Unexpected import in deepLinkUtils.ts: ${specifier}`);
return mocks[specifier];
};

// eslint-disable-next-line no-new-func
new Function("module", "exports", "require", "fetch", outputText)(module, module.exports, requireShim, fetchMock);

return { exports: module.exports, calls, state, names: () => calls.map((call) => call.name) };
}

const jsonResponse = (body, status = 200) => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});

const find = (calls, name) => calls.find((call) => call.name === name);

test("starts the call in core with the fetched tokens", async () => {
const { exports, calls } = loadDeepLinkUtils();

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, true);
assert.deepEqual(find(calls, "callStarted").args, [TOKENS.audioToken, TOKENS.videoToken]);
assert.equal(find(calls, "setCallTokens").args[0].isInitialisingCall, true);
assert.equal(find(calls, "setCallTokens").args[0].room.id, SESSION_ID);
});

test("does not set a tab on a successful join, leaving call navigation to the app", async () => {
const { exports, calls } = loadDeepLinkUtils();

await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(find(calls, "setTab"), undefined);
});

test("clears call state and cleans up when core fails to start the call", async () => {
const { exports, calls, state } = loadDeepLinkUtils({
callStartedImpl: async () => {
throw new Error("core refused to start the call");
},
});

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, false);
// Call state must be rolled back, otherwise the UI shows a call that is not running.
assert.equal(state.callTokens, null);
assert.ok(find(calls, "endCallCleanup"), "core cleanup should run after a failed start");
assert.deepEqual(find(calls, "toast.error").args, ["Failed to start call"]);

// Same rollback as the shared join path: tell the server the call ended,
// using the participant from the tokens we were given.
assert.deepEqual(find(calls, "socket.send").args[0], {
type: "call_end",
payload: { participant_id: TOKENS.participant },
});
});

test("surfaces the existing call instead of rejoining the same session", async () => {
const { exports, calls } = loadDeepLinkUtils({
callTokens: { room: { id: SESSION_ID }, participant: "p" },
});

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, true);
assert.deepEqual(find(calls, "setTab").args, ["call"]);
assert.equal(find(calls, "fetch"), undefined, "should not refetch tokens for the call it is already in");
assert.equal(find(calls, "callStarted"), undefined);
});

test("refuses to join while a different call is active, leaving that call untouched", async () => {
const activeCall = { room: { id: "other-room" }, participant: "p-old", role: "none" };
const { exports, calls, state } = loadDeepLinkUtils({ callTokens: activeCall });

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, false);
assert.deepEqual(find(calls, "toast.error").args, ["Leave your current call first"]);
// The active call must survive untouched: no teardown, no token fetch, no new call.
assert.equal(state.callTokens, activeCall);
assert.equal(find(calls, "fetch"), undefined);
assert.equal(find(calls, "callStarted"), undefined);
assert.equal(find(calls, "endCallCleanup"), undefined);
assert.equal(find(calls, "socket.send"), undefined);
});

test("does not overwrite a call that starts while the token fetch is pending", async () => {
let state;
const { exports, calls, ...loaded } = loadDeepLinkUtils({
fetchImpl: async () => {
// A call begins after the deep link was accepted but before tokens arrive.
state.callTokens = { room: { id: "raced-room" }, participant: "p-race" };
return jsonResponse(TOKENS);
},
});
state = loaded.state;

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, false);
assert.equal(state.callTokens.room.id, "raced-room", "the racing call must survive");
assert.equal(find(calls, "callStarted"), undefined);
assert.deepEqual(find(calls, "toast.error").args, ["Leave your current call first"]);
});

test("does not start a call when the session is gone", async () => {
const { exports, calls } = loadDeepLinkUtils({ fetchImpl: async () => jsonResponse({}, 404) });

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, false);
assert.equal(find(calls, "callStarted"), undefined);
assert.deepEqual(find(calls, "toast.error").args, ["Session has ended or doesn't exist"]);
});

test("asks the user to log in when there is no auth token", async () => {
const { exports, calls } = loadDeepLinkUtils({ authToken: null });

const joined = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(joined, false);
assert.deepEqual(find(calls, "setTab").args, ["login"]);
assert.equal(find(calls, "fetch"), undefined);
});

test("routes a join-session deep link URL to the join handler", async () => {
const { exports, calls } = loadDeepLinkUtils();

const handled = await exports.processDeepLinkUrl(`hopp:///join-session?sessionId=${SESSION_ID}`);

assert.equal(handled, true);
assert.deepEqual(find(calls, "callStarted").args, [TOKENS.audioToken, TOKENS.videoToken]);
});

test("ignores deep links that are not hopp:// URLs", async () => {
const { exports, calls } = loadDeepLinkUtils();

const handled = await exports.processDeepLinkUrl(`https://example.test/join-session?sessionId=${SESSION_ID}`);

assert.equal(handled, false);
assert.equal(find(calls, "fetch"), undefined);
});

test("ignores a second deep link while a join is already in progress", async () => {
let releaseTokens;
const tokensPending = new Promise((resolve) => {
releaseTokens = resolve;
});
const { exports, calls } = loadDeepLinkUtils({
fetchImpl: async () => {
await tokensPending;
return jsonResponse(TOKENS);
},
});

// Start one join and leave it waiting on the token fetch, then fire a second.
const firstJoin = exports.handleJoinSessionDeepLink(SESSION_ID);
const secondJoin = await exports.handleJoinSessionDeepLink(SESSION_ID);

assert.equal(secondJoin, false, "the second deep link must not start a competing join");

releaseTokens();
assert.equal(await firstJoin, true, "the first join should still complete");
assert.equal(
calls.filter((call) => call.name === "callStarted").length,
1,
"core should be asked to start the call exactly once",
);
});
Loading