diff --git a/src/views/DeploymentDetailView.test.ts b/src/views/DeploymentDetailView.test.ts
new file mode 100644
index 0000000..ce2d245
--- /dev/null
+++ b/src/views/DeploymentDetailView.test.ts
@@ -0,0 +1,361 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { mount, flushPromises } from "@vue/test-utils";
+import { createTestingPinia } from "@pinia/testing";
+import DeploymentDetailView from "./DeploymentDetailView.vue";
+
+const mockRoute = {
+ params: { name: "test-app" },
+ query: {},
+};
+
+vi.mock("vue-router", () => ({
+ useRoute: vi.fn(() => mockRoute),
+ useRouter: vi.fn(() => ({
+ push: vi.fn(),
+ back: vi.fn(),
+ })),
+}));
+
+vi.mock("@/services/api", () => ({
+ deploymentsApi: {
+ get: vi.fn().mockResolvedValue({
+ data: {
+ deployment: {
+ name: "test-app",
+ status: "running",
+ type: "docker-compose",
+ path: "/deployments/test-app",
+ services: [{ name: "web", status: "running", container_id: "abc123" }],
+ created_at: "2024-01-01T00:00:00Z",
+ updated_at: "2024-01-01T00:00:00Z",
+ },
+ proxy_status: { enabled: true, domain: "test-app.example.com" },
+ },
+ }),
+ getEnvVars: vi.fn().mockResolvedValue({
+ data: { env_vars: [] },
+ }),
+ getLogs: vi.fn().mockResolvedValue({
+ data: { logs: "Test logs" },
+ }),
+ logs: vi.fn().mockResolvedValue({
+ data: { logs: "Test logs" },
+ }),
+ getStats: vi.fn().mockResolvedValue({
+ data: {
+ stats: {
+ cpu_percent: 10,
+ memory_usage: 100000000,
+ memory_limit: 1000000000,
+ },
+ },
+ }),
+ listFiles: vi.fn().mockResolvedValue({
+ data: { files: [] },
+ }),
+ getComposeFile: vi.fn().mockResolvedValue({
+ data: { content: "version: '3'\nservices:\n web:\n image: nginx" },
+ }),
+ start: vi.fn().mockResolvedValue({ data: { message: "Started" } }),
+ stop: vi.fn().mockResolvedValue({ data: { message: "Stopped" } }),
+ restart: vi.fn().mockResolvedValue({ data: { message: "Restarted" } }),
+ delete: vi.fn().mockResolvedValue({ data: { message: "Deleted" } }),
+ },
+ proxyApi: {
+ getStatus: vi.fn().mockResolvedValue({
+ data: { status: { enabled: true } },
+ }),
+ },
+ securityApi: {
+ getDeploymentSecurity: vi.fn().mockResolvedValue({
+ data: {
+ enabled: false,
+ blocked_ips: [],
+ protected_paths: [],
+ rate_limits: [],
+ },
+ }),
+ getDeploymentEvents: vi.fn().mockResolvedValue({
+ data: { events: [] },
+ }),
+ updateDeploymentSecurity: vi.fn().mockResolvedValue({
+ data: { message: "Updated" },
+ }),
+ },
+ containersApi: {
+ exec: vi.fn().mockResolvedValue({ data: {} }),
+ },
+ filesApi: {
+ read: vi.fn().mockResolvedValue({ data: { content: "" } }),
+ write: vi.fn().mockResolvedValue({ data: { message: "Written" } }),
+ getContent: vi.fn().mockResolvedValue({ data: { content: "" } }),
+ },
+}));
+
+vi.mock("@/composables/useNotifications", () => ({
+ useNotifications: () => ({
+ success: vi.fn(),
+ error: vi.fn(),
+ info: vi.fn(),
+ }),
+}));
+
+describe("DeploymentDetailView", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ const mountView = () => {
+ return mount(DeploymentDetailView, {
+ global: {
+ plugins: [
+ createTestingPinia({
+ createSpy: vi.fn,
+ }),
+ ],
+ mocks: {
+ $route: mockRoute,
+ },
+ stubs: {
+ RouterLink: {
+ template: "",
+ props: ["to"],
+ },
+ teleport: true,
+ ContainerTerminal: true,
+ LogViewer: true,
+ },
+ },
+ });
+ };
+
+ describe("View structure", () => {
+ it("renders the deployment detail view container", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".deployment-detail").exists()).toBe(true);
+ });
+
+ it("contains detail header", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".detail-header").exists()).toBe(true);
+ });
+
+ it("contains detail tabs section", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".detail-tabs").exists()).toBe(true);
+ });
+ });
+
+ describe("Tab navigation", () => {
+ it("displays all eight tabs", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ const tabs = wrapper.findAll(".tab-btn");
+ expect(tabs.length).toBe(8);
+ });
+
+ it("has Overview tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Overview");
+ });
+
+ it("has Files tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Files");
+ });
+
+ it("has Logs tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Logs");
+ });
+
+ it("has Terminal tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Terminal");
+ });
+
+ it("has Environment tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Environment");
+ });
+
+ it("has Quick Actions tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Quick Actions");
+ });
+
+ it("has Security tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Security");
+ });
+
+ it("has Configuration tab", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.text()).toContain("Configuration");
+ });
+
+ it("Overview tab is active by default", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ const activeTab = wrapper.find(".tab-btn.active");
+ expect(activeTab.text()).toContain("Overview");
+ });
+
+ it("can switch tabs by clicking", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+
+ const logsTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Logs"));
+ await logsTab?.trigger("click");
+
+ expect(wrapper.find(".tab-btn.active").text()).toContain("Logs");
+ });
+ });
+
+ describe("Tab definitions", () => {
+ it("has correct tab structure", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect((wrapper.vm as any).tabs).toEqual([
+ { id: "overview", label: "Overview", icon: "pi pi-info-circle" },
+ { id: "files", label: "Files", icon: "pi pi-folder" },
+ { id: "logs", label: "Logs", icon: "pi pi-file-edit" },
+ { id: "terminal", label: "Terminal", icon: "pi pi-desktop" },
+ { id: "environment", label: "Environment", icon: "pi pi-list" },
+ { id: "actions", label: "Quick Actions", icon: "pi pi-bolt" },
+ { id: "security", label: "Security", icon: "pi pi-shield" },
+ { id: "config", label: "Configuration", icon: "pi pi-cog" },
+ ]);
+ });
+ });
+
+ describe("Security tab with incomplete data", () => {
+ it("renders security tab without errors when protected_paths is undefined", async () => {
+ const { securityApi } = await import("@/services/api");
+ vi.mocked(securityApi.getDeploymentSecurity).mockResolvedValueOnce({
+ data: {
+ enabled: false,
+ blocked_ips: [],
+ },
+ } as any);
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security"));
+ await securityTab?.trigger("click");
+ await flushPromises();
+
+ expect(wrapper.find(".tab-btn.active").text()).toContain("Security");
+ });
+
+ it("renders security tab without errors when rate_limits is undefined", async () => {
+ const { securityApi } = await import("@/services/api");
+ vi.mocked(securityApi.getDeploymentSecurity).mockResolvedValueOnce({
+ data: {
+ enabled: false,
+ blocked_ips: [],
+ protected_paths: [],
+ },
+ } as any);
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security"));
+ await securityTab?.trigger("click");
+ await flushPromises();
+
+ expect(wrapper.find(".tab-btn.active").text()).toContain("Security");
+ });
+
+ it("renders security tab without errors when security config is empty object", async () => {
+ const { securityApi } = await import("@/services/api");
+ vi.mocked(securityApi.getDeploymentSecurity).mockResolvedValueOnce({
+ data: {},
+ } as any);
+
+ const wrapper = mountView();
+ await flushPromises();
+
+ const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security"));
+ await securityTab?.trigger("click");
+ await flushPromises();
+
+ expect(wrapper.find(".tab-btn.active").text()).toContain("Security");
+ });
+
+ it("displays zero counts when security arrays are empty", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+
+ const securityTab = wrapper.findAll(".tab-btn").find((t) => t.text().includes("Security"));
+ await securityTab?.trigger("click");
+ await flushPromises();
+
+ const summaryValues = wrapper.findAll(".summary-value");
+ const protectedPathsCount = summaryValues.find((v) =>
+ v.element.parentElement?.textContent?.includes("Protected Paths"),
+ );
+ expect(protectedPathsCount?.text()).toBe("0");
+ });
+ });
+
+ describe("Header actions", () => {
+ it("displays start button", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".btn-success").text()).toContain("Start");
+ });
+
+ it("displays stop button", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".btn-warning").text()).toContain("Stop");
+ });
+
+ it("displays restart button", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".btn-info").text()).toContain("Restart");
+ });
+
+ it("displays delete button", async () => {
+ const wrapper = mountView();
+ await flushPromises();
+ expect(wrapper.find(".btn-danger").text()).toContain("Delete");
+ });
+ });
+
+ describe("Data fetching", () => {
+ it("fetches deployment on mount", async () => {
+ const { deploymentsApi } = await import("@/services/api");
+ mountView();
+ await flushPromises();
+ expect(deploymentsApi.get).toHaveBeenCalledWith("test-app");
+ });
+
+ it("fetches environment variables on mount", async () => {
+ const { deploymentsApi } = await import("@/services/api");
+ mountView();
+ await flushPromises();
+ expect(deploymentsApi.getEnvVars).toHaveBeenCalledWith("test-app");
+ });
+ });
+});
diff --git a/src/views/DeploymentDetailView.vue b/src/views/DeploymentDetailView.vue
index 61acf50..676a7e1 100644
--- a/src/views/DeploymentDetailView.vue
+++ b/src/views/DeploymentDetailView.vue
@@ -519,7 +519,9 @@
- {{ securityConfig.protected_paths.filter((p) => p.enabled).length }}
+ {{
+ (securityConfig.protected_paths || []).filter((p) => p.enabled).length
+ }}
Protected Paths
@@ -528,7 +530,9 @@
- {{ securityConfig.rate_limits.filter((r) => r.enabled).length }}
+ {{
+ (securityConfig.rate_limits || []).filter((r) => r.enabled).length
+ }}
Rate Limits
@@ -579,14 +583,14 @@
-
+
No protected paths
Click presets above or add custom paths
-
+
No rate limits
Add limits to protect against abuse
{
};
const isPathProtected = (pattern: string) => {
- return securityConfig.value.protected_paths.some((p) => p.pattern === pattern);
+ return (securityConfig.value.protected_paths || []).some((p) => p.pattern === pattern);
};
const toggleProtectedPath = (pattern: string) => {
+ if (!securityConfig.value.protected_paths) securityConfig.value.protected_paths = [];
const index = securityConfig.value.protected_paths.findIndex((p) => p.pattern === pattern);
if (index >= 0) {
securityConfig.value.protected_paths.splice(index, 1);
@@ -1439,6 +1444,7 @@ const toggleProtectedPath = (pattern: string) => {
const addProtectedPath = () => {
if (!newProtectedPath.value) return;
+ if (!securityConfig.value.protected_paths) securityConfig.value.protected_paths = [];
if (!isPathProtected(newProtectedPath.value)) {
securityConfig.value.protected_paths.push({ pattern: newProtectedPath.value, enabled: true });
saveSecurityConfig();
@@ -1447,12 +1453,14 @@ const addProtectedPath = () => {
};
const removeProtectedPath = (index: number) => {
+ if (!securityConfig.value.protected_paths) return;
securityConfig.value.protected_paths.splice(index, 1);
saveSecurityConfig();
};
const addRateLimit = () => {
if (!newRateLimit.value.path) return;
+ if (!securityConfig.value.rate_limits) securityConfig.value.rate_limits = [];
securityConfig.value.rate_limits.push({
path: newRateLimit.value.path,
rate: newRateLimit.value.rate || 10,
@@ -1464,6 +1472,7 @@ const addRateLimit = () => {
};
const removeRateLimit = (index: number) => {
+ if (!securityConfig.value.rate_limits) return;
securityConfig.value.rate_limits.splice(index, 1);
saveSecurityConfig();
};