Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
const { JiraIssueLoader } = require("../../../../../utils/extensions/Jira/JiraLoader");

describe("JiraIssueLoader", () => {
const baseUrl = "https://example.atlassian.net";
const projectKey = "TEST";
const username = "user";
const accessToken = "token";
const personalAccessToken = "pat";

let loader;

beforeEach(() => {
loader = new JiraIssueLoader({ baseUrl, projectKey, username, accessToken });
});

test("generates Basic auth header with username and token", () => {
const expected = `Basic ${Buffer.from(`${username}:${accessToken}`).toString("base64")}`;
expect(loader.authorizationHeader).toBe(expected);
});

test("generates Bearer auth header with personal access token", () => {
const patLoader = new JiraIssueLoader({ baseUrl, projectKey, personalAccessToken });
expect(patLoader.authorizationHeader).toBe(`Bearer ${personalAccessToken}`);
});

test("createDocumentFromIssue extracts code blocks correctly", () => {
const issue = {
id: "1",
key: "TEST-1",
fields: {
summary: "Test Issue",
description: {
storage: {
value: `<ac:structured-macro ac:name="code"><ac:parameter ac:name="language">js</ac:parameter><ac:plain-text-body><![CDATA[console.log("Hello World");]]></ac:plain-text-body></ac:structured-macro>`
}
},
status: { name: "Open" },
issuetype: { name: "Task" },
creator: { displayName: "Alice" },
created: "2025-01-01T00:00:00.000Z",
},
};

const doc = loader.createDocumentFromIssue(issue);

expect(doc.pageContent).toContain("```js\nconsole.log(\"Hello World\");\n```");
expect(doc.metadata.id).toBe("1");
expect(doc.metadata.key).toBe("TEST-1");
expect(doc.metadata.status).toBe("Open");
expect(doc.metadata.title).toBe("Test Issue");
expect(doc.metadata.created_by).toBe("Alice");
expect(doc.metadata.url).toBe(`${baseUrl}/jira/browse/TEST-1`);
});

test("load returns empty array on fetch failure", async () => {
// Suppress console.error output
const consoleSpy = jest.spyOn(console, "error").mockImplementation(() => {});

// Mock fetchJiraData to throw an error
jest.spyOn(loader, "fetchJiraData").mockImplementation(async () => {
throw new Error("Network error");
});

const result = await loader.load();
expect(result).toEqual([]);
expect(consoleSpy).toHaveBeenCalledWith("Error:", expect.any(Error));

consoleSpy.mockRestore();
});

test("fetchAllIssuesInProject handles paginated results", async () => {
const totalIssues = 4;

// Mock fetchJiraData to simulate paginated API
jest.spyOn(loader, "fetchJiraData").mockImplementation(async (url) => {
const urlObj = new URL(url);
const startAt = parseInt(urlObj.searchParams.get("startAt") || "0", 10);
const limit = parseInt(urlObj.searchParams.get("maxResults") || "25", 10);

const issues = [];
for (let i = startAt + 1; i <= Math.min(startAt + limit, totalIssues); i++) {
issues.push({ id: i, key: `TEST-${i}`, fields: {} });
}

return { issues, total: totalIssues };
});

const issues = await loader.fetchAllIssuesInProject();
expect(issues).toHaveLength(totalIssues);
expect(issues.map(i => i.key)).toEqual(["TEST-1", "TEST-2", "TEST-3", "TEST-4"]);
});
});
118 changes: 118 additions & 0 deletions collector/__tests__/utils/extensions/JIra/jira-serivice.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
process.env.NODE_ENV = "development";
process.env.STORAGE_DIR = "C:/temp";

const fs = require("fs");
const path = require("path");
const { v4 } = require("uuid");
const { loadJira, fetchJiraIssue } = require("../../../../utils/extensions/Jira");
const { JiraIssueLoader } = require("../../../../utils/extensions/Jira/JiraLoader");
const { writeToServerDocuments, sanitizeFileName } = require("../../../../utils/files");
const { tokenizeString } = require("../../../../utils/tokenizer");

jest.mock("../../../../utils/extensions/Jira/JiraLoader");
jest.mock("../../../../utils/files");
jest.mock("../../../../utils/tokenizer");
jest.mock("fs");

describe("Jira Service", () => {
const mockResponse = { locals: { encryptionWorker: { encrypt: jest.fn((s) => `encrypted:${s}`) } } };

beforeEach(() => {
jest.clearAllMocks();
});

describe("loadJira", () => {
test("fails when no credentials provided", async () => {
const result = await loadJira({ baseUrl: "https://example.atlassian.net", projectKey: "TEST" }, mockResponse);
expect(result.success).toBe(false);
expect(result.reason).toContain("You need either a personal access token");
});

test("fails when invalid baseUrl", async () => {
const result = await loadJira({ baseUrl: "invalid-url", projectKey: "TEST", personalAccessToken: "pat" }, mockResponse);
expect(result.success).toBe(false);
expect(result.reason).toContain("Provided base URL is not a valid URL");
});

test("saves documents correctly when Jira returns issues", async () => {
const mockDocs = [
{
pageContent: "Test content",
metadata: { title: "Issue 1", url: "https://example.atlassian.net/browse/TEST-1", source: "Test source" },
},
];

JiraIssueLoader.mockImplementation(() => ({
load: jest.fn().mockResolvedValue(mockDocs),
}));

fs.existsSync.mockReturnValue(false);
fs.mkdirSync.mockImplementation(() => {});

sanitizeFileName.mockImplementation((s) => s);
tokenizeString.mockReturnValue(5);
writeToServerDocuments.mockImplementation(() => {});

const result = await loadJira(
{ baseUrl: "https://example.atlassian.net", projectKey: "TEST", personalAccessToken: "pat" },
mockResponse
);

expect(result.success).toBe(true);
expect(writeToServerDocuments).toHaveBeenCalled();
expect(tokenizeString).toHaveBeenCalledWith("Test content");
expect(mockResponse.locals.encryptionWorker.encrypt).toHaveBeenCalled();
});
});

describe("fetchJiraIssue", () => {
test("fails when required params are missing", async () => {
const result = await fetchJiraIssue({ baseUrl: null, pageUrl: "url", projectKey: "TEST", username: "user", accessToken: "token" });
expect(result.success).toBe(false);
expect(result.reason).toContain("You need either a username and access token");
});

test("returns content when Jira issue found", async () => {
const mockDocs = [
{ pageContent: "Issue content", metadata: { url: "url" } },
];

JiraIssueLoader.mockImplementation(() => ({
load: jest.fn().mockResolvedValue(mockDocs),
}));

const result = await fetchJiraIssue({
baseUrl: "https://example.atlassian.net",
pageUrl: "url",
projectKey: "TEST",
username: "user",
accessToken: "token",
});

expect(result.success).toBe(true);
expect(result.content).toBe("Issue content");
});

test("returns failure when issue not found", async () => {
const mockDocs = [
{ pageContent: "Other content", metadata: { url: "other-url" } },
];

JiraIssueLoader.mockImplementation(() => ({
load: jest.fn().mockResolvedValue(mockDocs),
}));

const result = await fetchJiraIssue({
baseUrl: "https://example.atlassian.net",
pageUrl: "url",
projectKey: "TEST",
username: "user",
accessToken: "token",
});

expect(result.success).toBe(false);
expect(result.content).toBeNull();
expect(result.reason).toContain("Target page could not be found");
});
});
});
29 changes: 27 additions & 2 deletions collector/extensions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,32 @@ function extensions(app) {
return;
}
);
}

app.post(
"/ext/jira",
[verifyPayloadIntegrity, setDataSigner],
async function (request, response) {
try {
const { loadJira } = require("../utils/extensions/Jira");
const { success, reason, data } = await loadJira(
reqBody(request),
response
);
response.status(200).json({ success, reason, data });
} catch (e) {
console.error(e);
response.status(400).json({
success: false,
reason: e.message,
data: {
title: null,
author: null,
},
});
}
return;
}
);
}

module.exports = extensions;
module.exports = extensions;
33 changes: 33 additions & 0 deletions collector/extensions/resync/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -144,10 +144,43 @@ async function resyncDrupalWiki({ chunkSource }, response) {
}
}

/**
* Fetches the content of a specific jira issue via its chunkSource.
* Returns the content as a text string of the page in question and only that issue.
* @param {object} data - metadata from a document (e.g.: chunkSource)
* @param {import("../../middleware/setDataSigner").ResponseWithSigner} response
*/
async function resyncJira({ chunkSource }, response) {
if (!chunkSource) throw new Error('Invalid source property provided');
try {
// Jira data is `payload` encrypted. So we need to expand its
// encrypted payload back into query params so we can reFetch the page with same access token/params.
const source = response.locals.encryptionWorker.expandPayload(chunkSource);
const { fetchJiraIssue } = require("../../utils/extensions/Jira");
const { success, reason, content } = await fetchJiraIssue({
pageUrl: `https:${source.pathname}`,
baseUrl: source.searchParams.get('baseUrl'),
spaceKey: source.searchParams.get('projectKey'),
accessToken: source.searchParams.get('token'),
username: source.searchParams.get('username'),
});

if (!success) throw new Error(`Failed to sync Jira issue content. ${reason}`);
response.status(200).json({ success, content });
} catch (e) {
console.error(e);
response.status(200).json({
success: false,
content: null,
});
}
}

module.exports = {
link: resyncLink,
youtube: resyncYouTube,
confluence: resyncConfluence,
github: resyncGithub,
drupalwiki: resyncDrupalWiki,
jira: resyncJira,
}
Loading