mirror of
https://github.com/Quad4-Software/MeshChatX.git
synced 2026-08-18 09:49:09 -04:00
1000 lines
36 KiB
JavaScript
1000 lines
36 KiB
JavaScript
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { mount, flushPromises } from "@vue/test-utils";
|
|
import { createRouter, createWebHashHistory } from "vue-router";
|
|
import { createI18n } from "vue-i18n";
|
|
import { createVuetify } from "vuetify";
|
|
import TutorialModal from "../../meshchatx/src/frontend/components/TutorialModal.vue";
|
|
import en from "../../meshchatx/src/frontend/locales/en.json";
|
|
import ToastUtils from "../../meshchatx/src/frontend/js/ToastUtils";
|
|
|
|
vi.mock("../../meshchatx/src/frontend/js/GlobalState", () => ({
|
|
default: {
|
|
config: { theme: "light", language: "en" },
|
|
hasPendingInterfaceChanges: false,
|
|
modifiedInterfaceNames: new Set(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../meshchatx/src/frontend/js/ToastUtils", () => ({
|
|
default: {
|
|
success: vi.fn(),
|
|
error: vi.fn(),
|
|
warning: vi.fn(),
|
|
info: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock("../../meshchatx/src/frontend/js/DialogUtils", () => ({
|
|
default: {
|
|
confirm: vi.fn().mockResolvedValue(true),
|
|
},
|
|
}));
|
|
|
|
const axiosMock = { get: vi.fn(), post: vi.fn(), patch: vi.fn() };
|
|
|
|
const vuetify = createVuetify();
|
|
|
|
const i18n = createI18n({
|
|
legacy: false,
|
|
locale: "en",
|
|
messages: { en },
|
|
});
|
|
|
|
function discoveryApiHandlers(migrationPayload) {
|
|
return (url) => {
|
|
if (url === "/api/v1/app/info") {
|
|
return Promise.resolve({
|
|
data: {
|
|
app_info: {
|
|
migration: migrationPayload,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
if (url === "/api/v1/reticulum/discovery") {
|
|
return Promise.resolve({ data: { discovery: {} } });
|
|
}
|
|
if (url === "/api/v1/community-interfaces") {
|
|
return Promise.resolve({ data: { interfaces: [] } });
|
|
}
|
|
if (url === "/api/v1/reticulum/discovered-interfaces") {
|
|
return Promise.resolve({ data: { interfaces: [], active: [] } });
|
|
}
|
|
if (url === "/api/v1/config") {
|
|
return Promise.resolve({ data: { config: { display_name: "Anonymous Peer" } } });
|
|
}
|
|
if (url === "/api/v1/identities") {
|
|
return Promise.resolve({
|
|
data: {
|
|
identities: [{ hash: "default_identity", display_name: "Anonymous Peer", is_current: true }],
|
|
},
|
|
});
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
};
|
|
}
|
|
|
|
const dialogStubs = {
|
|
LanguageSelector: true,
|
|
MaterialDesignIcon: true,
|
|
Toggle: true,
|
|
VIcon: { template: '<span class="v-icon-stub"/>' },
|
|
};
|
|
|
|
const pageStubs = {
|
|
VIcon: { template: '<span class="v-icon-stub"/>' },
|
|
LanguageSelector: true,
|
|
MaterialDesignIcon: true,
|
|
Toggle: true,
|
|
};
|
|
|
|
describe("TutorialModal getting started migration", () => {
|
|
beforeEach(() => {
|
|
window.api = axiosMock;
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterEach(() => {
|
|
delete window.electron;
|
|
});
|
|
|
|
it("dialog show() loads migration offer when app_info reports show_choice", async () => {
|
|
axiosMock.get.mockImplementation(
|
|
discoveryApiHandlers({
|
|
show_choice: true,
|
|
legacy_path: "/home/x/.reticulum-meshchat",
|
|
target_path: "/home/x/.reticulum-meshchatx",
|
|
mode: "redirect_to_legacy",
|
|
})
|
|
);
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
expect(wrapper.vm.migrationOffer).toEqual(
|
|
expect.objectContaining({
|
|
show_choice: true,
|
|
legacy_path: "/home/x/.reticulum-meshchat",
|
|
target_path: "/home/x/.reticulum-meshchatx",
|
|
})
|
|
);
|
|
await vi.waitFor(
|
|
() => {
|
|
const t = document.body.textContent || "";
|
|
return (
|
|
t.includes(en.tutorial.migration_title) &&
|
|
t.includes(en.tutorial.migration_migrate) &&
|
|
t.includes(en.tutorial.migration_fresh)
|
|
);
|
|
},
|
|
{ timeout: 4000 }
|
|
);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("dialog migrate posts storage-migration and toasts success", async () => {
|
|
axiosMock.get.mockImplementation(
|
|
discoveryApiHandlers({
|
|
show_choice: true,
|
|
legacy_path: "/a/.reticulum-meshchat",
|
|
target_path: "/a/.reticulum-meshchatx",
|
|
})
|
|
);
|
|
axiosMock.post.mockImplementation((url, body) => {
|
|
if (url === "/api/v1/setup/storage-migration") {
|
|
expect(body).toEqual({ action: "migrate" });
|
|
return Promise.resolve({ data: { ok: true, restart_required: true } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
window.electron = { relaunch: vi.fn().mockResolvedValue(undefined) };
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
await wrapper.vm.migrationMigrate();
|
|
await flushPromises();
|
|
|
|
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/setup/storage-migration", { action: "migrate" });
|
|
expect(ToastUtils.success).toHaveBeenCalledWith(en.tutorial.migration_done_restart);
|
|
expect(window.electron.relaunch).toHaveBeenCalled();
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("dialog fresh posts action fresh", async () => {
|
|
axiosMock.get.mockImplementation(
|
|
discoveryApiHandlers({
|
|
show_choice: true,
|
|
legacy_path: "/a/.reticulum-meshchat",
|
|
target_path: "/a/.reticulum-meshchatx",
|
|
})
|
|
);
|
|
axiosMock.post.mockResolvedValue({ data: { ok: true, restart_required: true } });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
await wrapper.vm.migrationFresh();
|
|
await flushPromises();
|
|
|
|
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/setup/storage-migration", { action: "fresh" });
|
|
expect(ToastUtils.success).toHaveBeenCalledWith(en.tutorial.migration_done_restart);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("dialog migration API error calls ToastUtils.error", async () => {
|
|
axiosMock.get.mockImplementation(
|
|
discoveryApiHandlers({
|
|
show_choice: true,
|
|
legacy_path: "/a/.reticulum-meshchat",
|
|
target_path: "/a/.reticulum-meshchatx",
|
|
})
|
|
);
|
|
axiosMock.post.mockRejectedValue({
|
|
response: { data: { error: "target already has data" } },
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
await wrapper.vm.$nextTick();
|
|
|
|
await wrapper.vm.migrationMigrate();
|
|
await flushPromises();
|
|
|
|
expect(ToastUtils.error).toHaveBeenCalledWith("target already has data");
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("migrationMigrate does nothing when migrationOffer is null", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
expect(wrapper.vm.migrationOffer).toBeNull();
|
|
|
|
await wrapper.vm.migrationMigrate();
|
|
await flushPromises();
|
|
|
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("dialog omits migration panel when show_choice is false", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.vm.migrationOffer).toBeNull();
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("tutorial page mode loads migration offer on mount", async () => {
|
|
axiosMock.get.mockImplementation(
|
|
discoveryApiHandlers({
|
|
show_choice: true,
|
|
legacy_path: "/z/.reticulum-meshchat",
|
|
target_path: "/z/.reticulum-meshchatx",
|
|
})
|
|
);
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [
|
|
{
|
|
path: "/tutorial",
|
|
name: "tutorial",
|
|
meta: { isPage: true },
|
|
component: { template: "<div/>" },
|
|
},
|
|
],
|
|
});
|
|
await router.push("/tutorial");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
global: { plugins: [router, vuetify, i18n], stubs: pageStubs },
|
|
});
|
|
|
|
await flushPromises();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.vm.migrationOffer).toEqual(
|
|
expect.objectContaining({
|
|
show_choice: true,
|
|
legacy_path: "/z/.reticulum-meshchat",
|
|
})
|
|
);
|
|
expect(wrapper.text()).toContain(en.tutorial.migration_title);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("refreshMigrationOffer leaves migration null when app_info has no migration", async () => {
|
|
axiosMock.get.mockImplementation((url) => {
|
|
if (url === "/api/v1/app/info") {
|
|
return Promise.resolve({ data: { app_info: { version: "1.0.0" } } });
|
|
}
|
|
if (url === "/api/v1/reticulum/discovery") {
|
|
return Promise.resolve({ data: { discovery: {} } });
|
|
}
|
|
if (url === "/api/v1/community-interfaces") {
|
|
return Promise.resolve({ data: { interfaces: [] } });
|
|
}
|
|
if (url === "/api/v1/reticulum/discovered-interfaces") {
|
|
return Promise.resolve({ data: { interfaces: [], active: [] } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.refreshMigrationOffer();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.vm.migrationOffer).toBeNull();
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("identity step new mode applies display name and continues", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.patch.mockResolvedValue({ data: {} });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "new";
|
|
wrapper.vm.identityName = "Mesh User";
|
|
await wrapper.vm.handlePrimaryAction();
|
|
|
|
expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", { display_name: "Mesh User" });
|
|
expect(wrapper.vm.currentStep).toBe(3);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("identity step import base32 switches to imported and deletes default on finish", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url, body) => {
|
|
if (url === "/api/v1/identity/restore") {
|
|
expect(body).toEqual({
|
|
base32: "ABCD1234",
|
|
display_name: "Imported User",
|
|
});
|
|
return Promise.resolve({
|
|
data: { identity: { hash: "imported_hash" }, message: "ok" },
|
|
});
|
|
}
|
|
if (url === "/api/v1/identities/switch") {
|
|
expect(body).toEqual({ identity_hash: "imported_hash" });
|
|
return Promise.resolve({ data: { hotswapped: true } });
|
|
}
|
|
if (url === "/api/v1/app/tutorial/seen") {
|
|
return Promise.resolve({ data: {} });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
axiosMock.delete = vi.fn().mockResolvedValue({ data: {} });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "import";
|
|
wrapper.vm.identityName = "Imported User";
|
|
wrapper.vm.identityImportBase32 = "ABCD1234";
|
|
await wrapper.vm.handlePrimaryAction();
|
|
|
|
expect(wrapper.vm.identityImportedHash).toBe("imported_hash");
|
|
expect(wrapper.vm.currentStep).toBe(3);
|
|
|
|
wrapper.vm.currentStep = wrapper.vm.totalSteps;
|
|
await wrapper.vm.finishTutorial();
|
|
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/identities/switch", {
|
|
identity_hash: "imported_hash",
|
|
});
|
|
expect(axiosMock.delete).toHaveBeenCalledWith("/api/v1/identities/default_identity");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("identity import mode requires file or base32 input", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "import";
|
|
wrapper.vm.identityImportBase32 = " ";
|
|
await wrapper.vm.handlePrimaryAction();
|
|
|
|
expect(wrapper.vm.currentStep).toBe(2);
|
|
expect(wrapper.vm.identityImportError).toBe(en.tutorial.identity_import_required);
|
|
expect(axiosMock.post).not.toHaveBeenCalled();
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("identity step new mode falls back to default username for blank input", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.patch.mockResolvedValue({ data: {} });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "new";
|
|
wrapper.vm.identityName = " ";
|
|
await wrapper.vm.handlePrimaryAction();
|
|
|
|
expect(axiosMock.patch).toHaveBeenCalledWith("/api/v1/config", { display_name: "Anonymous Peer" });
|
|
expect(wrapper.vm.currentStep).toBe(3);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("identity import continue is race-safe and only submits one restore request", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
let resolveRestore;
|
|
const restorePromise = new Promise((resolve) => {
|
|
resolveRestore = resolve;
|
|
});
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/identity/restore") {
|
|
return restorePromise;
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "import";
|
|
wrapper.vm.identityName = "Race User";
|
|
wrapper.vm.identityImportBase32 = "RACEKEY";
|
|
|
|
const p1 = wrapper.vm.handlePrimaryAction();
|
|
const p2 = wrapper.vm.handlePrimaryAction();
|
|
await flushPromises();
|
|
|
|
expect(axiosMock.post).toHaveBeenCalledTimes(1);
|
|
resolveRestore({
|
|
data: {
|
|
identity: { hash: "race_hash" },
|
|
},
|
|
});
|
|
await Promise.all([p1, p2]);
|
|
|
|
expect(wrapper.vm.identityImportedHash).toBe("race_hash");
|
|
expect(wrapper.vm.currentStep).toBe(3);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("hides footer Continue on connection and bootstrap steps", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
|
|
wrapper.vm.currentStep = 3;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.vm.showFooterContinue).toBe(false);
|
|
|
|
wrapper.vm.currentStep = 4;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.vm.showFooterContinue).toBe(false);
|
|
|
|
wrapper.vm.currentStep = 5;
|
|
await wrapper.vm.$nextTick();
|
|
expect(wrapper.vm.showFooterContinue).toBe(true);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("nextStep on connection step without a mode shows warning and does not advance", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 3;
|
|
wrapper.vm.connectionMode = null;
|
|
wrapper.vm.nextStep();
|
|
|
|
expect(wrapper.vm.currentStep).toBe(3);
|
|
expect(ToastUtils.warning).toHaveBeenCalledWith(en.tutorial.connect_mode_required);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("useLocalMode stays on connection step when Reticulum reload fails", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/reticulum/interfaces/add") {
|
|
return Promise.resolve({ data: { message: "added" } });
|
|
}
|
|
if (url === "/api/v1/reticulum/reload") {
|
|
return Promise.reject({ response: { data: { error: "reload failed" } } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 3;
|
|
await wrapper.vm.useLocalMode();
|
|
await flushPromises();
|
|
|
|
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/reticulum/interfaces/add", {
|
|
name: "Local Network",
|
|
type: "AutoInterface",
|
|
enabled: true,
|
|
});
|
|
expect(wrapper.vm.currentStep).toBe(3);
|
|
expect(ToastUtils.error).toHaveBeenCalledWith(en.tutorial.failed_reload_rns);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("confirmBootstraps reload failure keeps user on bootstrap step", async () => {
|
|
axiosMock.get.mockImplementation((url) => {
|
|
if (url === "/api/v1/community-interfaces") {
|
|
return Promise.resolve({
|
|
data: {
|
|
interfaces: [
|
|
{
|
|
name: "Test TCP",
|
|
type: "TCPClientInterface",
|
|
target_host: "1.2.3.4",
|
|
target_port: 4242,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
}
|
|
return discoveryApiHandlers({ show_choice: false })(url);
|
|
});
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/reticulum/interfaces/add") {
|
|
return Promise.resolve({ data: { message: "added" } });
|
|
}
|
|
if (url === "/api/v1/reticulum/reload") {
|
|
return Promise.reject({ response: { data: { error: "reload failed" } } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 4;
|
|
wrapper.vm.connectionMode = "discovery";
|
|
wrapper.vm.selectedBootstrapKeys = ["comm:Test TCP"];
|
|
await wrapper.vm.confirmBootstraps();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.vm.currentStep).toBe(4);
|
|
expect(ToastUtils.error).toHaveBeenCalledWith(en.tutorial.failed_reload_rns);
|
|
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("finishTutorial blocks when pending interface reload fails", async () => {
|
|
const GlobalState = (await import("../../meshchatx/src/frontend/js/GlobalState.js")).default;
|
|
GlobalState.hasPendingInterfaceChanges = true;
|
|
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/reticulum/reload") {
|
|
return Promise.reject({ response: { data: { error: "reload failed" } } });
|
|
}
|
|
if (url === "/api/v1/app/tutorial/seen") {
|
|
return Promise.resolve({ data: {} });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.visible = true;
|
|
wrapper.vm.currentStep = wrapper.vm.totalSteps;
|
|
await wrapper.vm.finishTutorial();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.vm.visible).toBe(true);
|
|
expect(axiosMock.post).not.toHaveBeenCalledWith("/api/v1/app/tutorial/seen", expect.anything());
|
|
|
|
GlobalState.hasPendingInterfaceChanges = false;
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("finishTutorial keeps modal open and reports error when identity switch fails", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/identities/switch") {
|
|
return Promise.reject({ response: { data: { message: "switch failed" } } });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
axiosMock.delete = vi.fn().mockResolvedValue({ data: {} });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.visible = true;
|
|
wrapper.vm.currentStep = wrapper.vm.totalSteps;
|
|
wrapper.vm.identityImportedHash = "imported_hash";
|
|
wrapper.vm.originalIdentityHash = "default_identity";
|
|
|
|
await wrapper.vm.finishTutorial();
|
|
|
|
expect(wrapper.vm.visible).toBe(true);
|
|
expect(axiosMock.delete).not.toHaveBeenCalled();
|
|
expect(ToastUtils.error).toHaveBeenCalledWith("switch failed");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("identity file import posts multipart and clears stale hash on re-pick", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/identity/restore") {
|
|
return Promise.resolve({
|
|
data: { identity: { hash: "file_hash" }, message: "ok" },
|
|
});
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "import";
|
|
wrapper.vm.identityName = "File User";
|
|
wrapper.vm.identityImportedHash = "stale_hash";
|
|
const file = new File([new Uint8Array([1, 2, 3, 4])], "identity.bin", {
|
|
type: "application/octet-stream",
|
|
});
|
|
wrapper.vm.onIdentityImportFileChange({ target: { files: [file], value: "x" } });
|
|
expect(wrapper.vm.identityImportedHash).toBeNull();
|
|
expect(wrapper.vm.identityImportFile).toBe(file);
|
|
|
|
await wrapper.vm.handlePrimaryAction();
|
|
expect(wrapper.vm.identityImportedHash).toBe("file_hash");
|
|
expect(axiosMock.post).toHaveBeenCalledWith(
|
|
"/api/v1/identity/restore",
|
|
expect.any(FormData),
|
|
expect.objectContaining({
|
|
headers: { "Content-Type": "multipart/form-data" },
|
|
})
|
|
);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("rejects empty identity import files", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
|
|
const empty = new File([], "identity.bin", { type: "application/octet-stream" });
|
|
wrapper.vm.onIdentityImportFileChange({ target: { files: [empty], value: "x" } });
|
|
expect(wrapper.vm.identityImportFile).toBeNull();
|
|
expect(wrapper.vm.identityImportError).toBe(en.tutorial.identity_import_empty_file);
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("normalizes whitespace in base32 before restore", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url, body) => {
|
|
if (url === "/api/v1/identity/restore") {
|
|
expect(body.base32).toBe("ABCD1234");
|
|
return Promise.resolve({
|
|
data: { identity: { hash: "ws_hash" }, message: "ok" },
|
|
});
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.currentStep = 2;
|
|
wrapper.vm.identityMode = "import";
|
|
wrapper.vm.identityImportBase32 = "AB CD\n1234";
|
|
await wrapper.vm.handlePrimaryAction();
|
|
expect(wrapper.vm.identityImportedHash).toBe("ws_hash");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("finishTutorial warns but continues when default identity delete fails", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/identities/switch") {
|
|
return Promise.resolve({ data: { hotswapped: true } });
|
|
}
|
|
if (url === "/api/v1/app/tutorial/seen") {
|
|
return Promise.resolve({ data: {} });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
axiosMock.delete = vi.fn().mockRejectedValue({ response: { data: { message: "delete failed" } } });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.visible = true;
|
|
wrapper.vm.currentStep = wrapper.vm.totalSteps;
|
|
wrapper.vm.identityImportedHash = "imported_hash";
|
|
wrapper.vm.originalIdentityHash = "default_identity";
|
|
await wrapper.vm.finishTutorial();
|
|
await flushPromises();
|
|
|
|
expect(wrapper.vm.visible).toBe(false);
|
|
expect(ToastUtils.warning).toHaveBeenCalledWith(en.tutorial.identity_default_delete_failed);
|
|
expect(axiosMock.post).toHaveBeenCalledWith("/api/v1/app/tutorial/seen");
|
|
wrapper.unmount();
|
|
});
|
|
|
|
it("finishTutorial is race-safe against double clicks", async () => {
|
|
axiosMock.get.mockImplementation(discoveryApiHandlers({ show_choice: false }));
|
|
let switchCalls = 0;
|
|
axiosMock.post.mockImplementation((url) => {
|
|
if (url === "/api/v1/identities/switch") {
|
|
switchCalls += 1;
|
|
return new Promise((resolve) => {
|
|
setTimeout(() => resolve({ data: { hotswapped: true } }), 20);
|
|
});
|
|
}
|
|
if (url === "/api/v1/app/tutorial/seen") {
|
|
return Promise.resolve({ data: {} });
|
|
}
|
|
return Promise.resolve({ data: {} });
|
|
});
|
|
axiosMock.delete = vi.fn().mockResolvedValue({ data: {} });
|
|
|
|
const router = createRouter({
|
|
history: createWebHashHistory(),
|
|
routes: [{ path: "/", name: "home", component: { template: "<div/>" } }],
|
|
});
|
|
await router.push("/");
|
|
await router.isReady();
|
|
|
|
const wrapper = mount(TutorialModal, {
|
|
attachTo: document.body,
|
|
global: { plugins: [router, vuetify, i18n], stubs: dialogStubs },
|
|
});
|
|
await wrapper.vm.show();
|
|
await flushPromises();
|
|
wrapper.vm.visible = true;
|
|
wrapper.vm.currentStep = wrapper.vm.totalSteps;
|
|
wrapper.vm.identityImportedHash = "imported_hash";
|
|
wrapper.vm.originalIdentityHash = "default_identity";
|
|
|
|
const first = wrapper.vm.finishTutorial();
|
|
const second = wrapper.vm.finishTutorial();
|
|
await Promise.all([first, second]);
|
|
await flushPromises();
|
|
|
|
expect(switchCalls).toBe(1);
|
|
wrapper.unmount();
|
|
});
|
|
});
|