Sync src/WorkflowTest.gs

This commit is contained in:
2026-04-24 17:41:50 +00:00
parent 58dfe8750c
commit c29f2a5f4a
+700
View File
@@ -0,0 +1,700 @@
/**
* 🧪 WORKFLOW INTEGRATION TESTS
* =============================================================================
* This file contains manual test helpers to verify the connection between
* SchoolHub and external workflow tools (Vikunja & Gitea).
*
* INSTRUCTIONS:
* 1. Select the desired test function from the toolbar dropdown.
* 2. Click "Run".
* 3. Check the "Execution Log" for the results.
* =============================================================================
*/
/**
* Test 1: Verify Script Properties are readable.
* Goal: Ensure VIKUNJA_BASE_URL, VIKUNJA_TOKEN, etc., are set.
*/
function testWorkflowConfig() {
console.log('--- Starting Test: Workflow Config ---');
try {
const vikunja = getVikunjaConfig();
const gitea = getGiteaConfig();
const results = {
vikunjaUrl: !!vikunja.baseUrl,
vikunjaToken: !!vikunja.token,
giteaUrl: !!gitea.baseUrl,
giteaToken: !!gitea.token,
giteaOwner: !!gitea.owner,
giteaRepo: !!gitea.repo
};
const allPassed = Object.values(results).every(val => val === true);
if (allPassed) {
console.log('✅ PASS: All required Script Properties are present.');
} else {
console.error('❌ FAIL: Some properties are missing:');
console.log(JSON.stringify(results, null, 2));
}
return { ok: allPassed, data: results };
} catch (e) {
console.error('❌ ERROR: ' + e.message);
return { ok: false, message: e.message };
}
}
/**
* Test 2: Verify Gitea Connectivity.
* Goal: Perform a read-only request to the repository root.
*/
function testGiteaConnection() {
console.log('--- Starting Test: Gitea Connection ---');
const res = getGiteaRepo();
if (res.ok) {
console.log('✅ PASS: Gitea authenticated successfully.');
console.log('Repo Name: ' + res.data.name);
return { ok: true, data: res.data };
} else {
console.error('❌ FAIL: Gitea connection failed: ' + res.message);
return { ok: false, message: res.message };
}
}
/**
* Test 3: Verify Vikunja Connectivity using a Task ID.
* @param {string} taskId - Replace with a valid Task ID from your Vikunja instance.
*/
function testVikunjaRead() {
const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
console.warn('⚠️ SKIP: Please provide a valid taskId in the code first.');
return { ok: false, message: 'No Task ID provided' };
}
console.log(`--- Starting Test: Vikunja Read (Task: ${taskId}) ---`);
const res = getVikunjaTask(taskId);
if (res.ok) {
console.log('✅ PASS: Vikunja authenticated and task retrieved.');
console.log('Task Title: ' + (res.data.title || 'No Title'));
return { ok: true, data: res.data };
} else {
console.error('❌ FAIL: Vikunja read failed: ' + res.message);
if (res.rawBody) console.log('Raw Response: ' + res.rawBody);
return { ok: false, message: res.message };
}
}
/**
* Test 4: Verify Vikunja Write Operation.
* Goal: Add a clearly marked test comment.
* @param {string} taskId - Replace with a valid Task ID.
*/
function testVikunjaComment() {
const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
console.warn('⚠️ SKIP: Please provide a valid taskId in the code first.');
return { ok: false, message: 'No Task ID provided' };
}
console.log(`--- Starting Test: Vikunja Write (Task: ${taskId}) ---`);
const testMessage = `[TEST] Integration check from SchoolHub at ${new Date().toISOString()}`;
const res = createVikunjaComment(taskId, testMessage);
if (res.ok) {
console.log('✅ PASS: Test comment successfully created.');
return { ok: true };
} else {
console.error('❌ FAIL: Vikunja write failed: ' + res.message);
return { ok: false, message: res.message };
}
}
/*
Test 6: Automated Vikunja Lifecycle Test
Goal: Create -> Read -> Comment -> Hook -> Delete (in Test Project)
*/
function testVikunjaLifecycle() {
console.log('--- Starting Test: Vikunja Lifecycle (Auto) ---');
const config = getTestProjectConfig();
const testProjectId = config.testProjectId;
if (!testProjectId) {
console.warn('⚠️ SKIP: VIKUNJA_TEST_PROJECT_ID not found in Script Properties.');
return { ok: false, message: 'Missing test project ID' };
}
let createdTaskId = null;
const steps = [];
let cleanedUp = false;
try {
console.log('Step 1: Creating temporary task...');
const createData = {
title: `[TEST][AUTO][SchoolHub] Lifecycle Test ${new Date().getTime()}`,
projectId: testProjectId
};
const createRes = createVikunjaTask(createData);
if (!createRes.ok || !createRes.data || !createRes.data.id) {
throw new Error(`Task Creation Failed: ${createRes.message || 'No ID returned'}. Raw: ${createRes.rawBody || 'N/A'}`);
}
createdTaskId = createRes.data.id;
steps.push('TASK_CREATED');
console.log(`✅ Task created with ID: ${createdTaskId}`);
console.log('Step 2: Reading task back...');
const readRes = getVikunjaTask(createdTaskId);
if (!readRes.ok) throw new Error(`Read failed: ${readRes.message}`);
steps.push('TASK_READ');
console.log(`✅ Task verified: ${readRes.data.title}`);
console.log('Step 3: Adding test comment...');
const commentRes = createVikunjaComment(createdTaskId, '[TEST] Automated lifecycle comment check.');
if (!commentRes.ok) {
throw new Error(`Comment failed (Code: ${commentRes.code}): ${commentRes.message}. Raw: ${commentRes.rawBody}`);
}
steps.push('COMMENT_ADDED');
console.log('✅ Comment added successfully');
console.log('Step 4: Running workflow hook...');
const hookRes = workflowStartFeature('TEST-AUTO', createdTaskId);
if (!hookRes.ok) throw new Error(`Hook failed: ${hookRes.message}`);
steps.push('HOOK_EXECUTED');
console.log('✅ Workflow hook executed successfully');
return {
ok: true,
createdTaskId,
cleanedUp: cleanedUp,
steps,
message: 'Full lifecycle completed successfully'
};
} catch (e) {
console.error(`❌ Lifecycle Test Failed at step ${steps.length + 1}: ${e.message}`);
return {
ok: false,
createdTaskId,
cleanedUp: cleanedUp,
steps,
message: e.message
};
} finally {
if (createdTaskId) {
console.log('Final Step: Cleaning up temporary task...');
const delRes = deleteVikunjaTask(createdTaskId);
if (delRes.ok) {
console.log('✅ Temporary task deleted.');
cleanedUp = true;
} else {
console.error(`❌ Cleanup failed for task ${createdTaskId}: ${delRes.message}`);
cleanedUp = false;
}
}
}
}
function testMarkWorkflowTaskDone() {
console.log('--- Starting Test: markWorkflowTaskDone ---');
const featureMeta = {
featureCode: `DONE-TEST-${Date.now()}`,
title: 'Automated Done Status Test',
type: 'feature',
module: 'general'
};
try {
// 1. Ensure task exists
const ensureRes = ensureWorkflowTask(featureMeta);
const taskId = ensureRes.taskId;
console.log(`Step 1: Task created/resolved: ${taskId}`);
// 2. Mark as done
markWorkflowTaskDone_(featureMeta, true);
console.log('Step 2: markWorkflowTaskDone_ executed');
// 3. Verify status
const taskRes = getVikunjaTask(taskId);
if (!taskRes.ok) throw new Error(`Read failed: ${taskRes.message}`);
const task = taskRes.data;
assertEqual_('Task is marked done', task.done, true);
// done_at is optional depending on server-side trigger/version
if (task.done_at) {
assertTrue_('Task has done_at timestamp', !!task.done_at);
} else {
console.log('️ Note: Server did not return done_at, but task.done is true.');
}
console.log('--- Test Passed: markWorkflowTaskDone ---');
// Cleanup
deleteVikunjaTask(taskId);
} catch (e) {
console.error(`❌ Test Failed: ${e.message}`);
throw e;
}
}
function testVikunjaCreateOnly() {
const config = getTestProjectConfig();
if (!config.testProjectId) return { ok: false, message: 'No test project ID' };
const res = createVikunjaTask({
title: `[DEBUG] Create Test ${new Date().getTime()}`,
projectId: config.testProjectId,
priority: 4
});
console.log(res.ok ? `✅ Created: ${res.data && res.data.id}` : `❌ Failed: ${res.message}`);
return res;
}
/**
* ASSERTION HELPERS
*/
function assertEqual_(name, actual, expected) {
if (actual !== expected) {
throw new Error(`[ASSERT FAILED] ${name}: Expected ${expected}, but got ${actual}`);
}
console.log(`✅ ${name}: ${actual} == ${expected}`);
}
function assertArrayEqual_(name, actual, expected) {
const a = JSON.stringify(actual);
const e = JSON.stringify(expected);
if (a !== e) {
throw new Error(`[ASSERT FAILED] ${name}: Expected ${e}, but got ${a}`);
}
console.log(`✅ ${name}: ${a} == ${e}`);
}
function assertTrue_(name, condition) {
if (!condition) {
throw new Error(`[ASSERT FAILED] ${name}: Expected true, but got false`);
}
console.log(`✅ ${name}: true`);
}
/**
* Test: Workflow Priority Rules
* Goal: Validate that the priority mapping (5..1) follows business rules.
*/
function testWorkflowPriorityRules() {
console.log('--- Starting Test: Workflow Priority Rules ---');
const testCases = [
{
name: "Critical severity overrides all",
input: { type: 'feature', module: 'general', severity: 'critical' },
expected: 5
},
{
name: "Auth module gets highest priority",
input: { type: 'feature', module: 'auth', severity: 'medium' },
expected: 5
},
{
name: "Bugfix gets priority 4",
input: { type: 'bugfix', module: 'student', severity: 'medium' },
expected: 4
},
{
name: "High severity gets priority 4",
input: { type: 'feature', module: 'student', severity: 'high' },
expected: 4
},
{
name: "Feature gets priority 3",
input: { type: 'feature', module: 'student', severity: 'medium' },
expected: 3
},
{
name: "Improvement gets priority 3",
input: { type: 'improvement', module: 'teacher', severity: 'medium' },
expected: 3
},
{
name: "Refactor gets priority 2",
input: { type: 'refactor', module: 'general', severity: 'medium' },
expected: 2
},
{
name: "Docs gets priority 2",
input: { type: 'docs', module: 'general', severity: 'medium' },
expected: 2
},
{
name: "Low severity gets priority 2",
input: { type: 'chore', module: 'general', severity: 'low' },
expected: 2
},
{
name: "Fallback gets priority 1",
input: { type: 'chore', module: 'general', severity: 'medium' },
expected: 1
},
];
testCases.forEach(tc => {
const actual = deriveWorkflowPriority_(tc.input);
assertEqual_(tc.name, actual, tc.expected);
});
console.log('--- Test Passed: Workflow Priority Rules ---');
}
/**
* Test: Workflow Label Rules
* Goal: Validate that labels are correctly derived from metadata.
*/
function testWorkflowLabelRules() {
console.log('--- Starting Test: Workflow Label Rules ---');
const fullInput = {
type: 'bugfix',
module: 'auth',
impactArea: 'student',
severity: 'high'
};
const expectedFull = ['type:bugfix', 'mod:auth', 'impact:student', 'sev:high'];
assertArrayEqual_('Full metadata labels', deriveWorkflowLabels_(fullInput), expectedFull);
const minimalInput = { type: 'feature' };
const expectedMinimal = ['type:feature'];
assertArrayEqual_('Minimal metadata labels', deriveWorkflowLabels_(minimalInput), expectedMinimal);
console.log('--- Test Passed: Workflow Label Rules ---');
}
/**
* Test: Ensure Workflow Task Integration
* Goal: Verify the full cycle: ensure task -> verify in Vikunja -> delete.
*/
function testEnsureWorkflowTask() {
console.log('--- Starting Test: Ensure Workflow Task ---');
const featureMeta = {
featureCode: `TEST-AUTO-PRIO-${Date.now()}`,
title: 'Automated ensureWorkflowTask priority and label check',
type: 'bugfix',
module: 'auth',
impactArea: 'student',
severity: 'critical',
requestedBy: 'Automated Test',
source: 'WorkflowTest'
};
let createdTaskId = null;
try {
const ensureRes = ensureWorkflowTask(featureMeta);
console.log('Ensure Result: ' + JSON.stringify(ensureRes));
assertTrue_('Ensure task ok', ensureRes.ok);
assertTrue_('Task ID exists', !!ensureRes.taskId);
assertTrue_('Task state is valid', ['CREATED', 'EXISTING'].includes(ensureRes.state));
createdTaskId = ensureRes.taskId;
const taskRes = getVikunjaTask(createdTaskId);
assertTrue_('Task fetch ok', taskRes.ok);
assertTrue_('Task data exists', !!taskRes.data);
const task = taskRes.data;
assertTrue_('Title contains feature code', task.title.includes(`[${featureMeta.featureCode}]`));
assertEqual_('Task priority matches rule', task.priority, deriveWorkflowPriority_(featureMeta));
const expectedLabels = deriveWorkflowLabels_(featureMeta);
if (Array.isArray(task.labels)) {
const actualLabels = task.labels.map(l => l.title || l.name);
expectedLabels.forEach(el => {
assertTrue_(`Label ${el} present`, actualLabels.includes(el));
});
} else {
console.log('️ Labels not returned as array; verification skipped for this instance.');
}
console.log('--- Test Passed: Ensure Workflow Task ---');
} catch (e) {
console.error(`❌ Test Failed: ${e.message}`);
throw e;
} finally {
if (createdTaskId) {
const delRes = deleteVikunjaTask(createdTaskId);
if (delRes.ok) console.log('✅ Cleanup: Temporary task deleted.');
else console.warn('⚠️ Cleanup: Failed to delete temporary task.');
}
}
}
/**
* Test 5: Workflow Hook Dry Run.
* Goal: Verify the orchestrator can call the client.
* @param {string} taskId - Replace with a valid Task ID.
*/
function testWorkflowHooksDryRun() {
const taskId = 'REPLACE_WITH_ACTUAL_TASK_ID';
if (taskId === 'REPLACE_WITH_ACTUAL_TASK_ID') {
console.warn('⚠️ SKIP: Please provide a valid taskId in the code first.');
return { ok: false, message: 'No Task ID provided' };
}
console.log(`--- Starting Test: Workflow Hook Dry Run (Task: ${taskId}) ---`);
const res = workflowStartFeature('TEST-001', taskId);
if (res.ok) {
console.log('✅ PASS: workflowStartFeature executed successfully.');
return { ok: true };
} else {
console.error(`❌ FAIL: Workflow hook failed: ${res.message}`);
return { ok: false, message: res.message };
}
}
/*
* 🧪 GITEA SYNC INTEGRATION TESTS
* =============================================================================
*/
function testGiteaPathMapping() {
console.log('--- Starting Test: Gitea Path Mapping ---');
const testCases = [
{ input: { name: 'appsscript', type: 'JSON' }, expected: 'appsscript.json' },
{ input: { name: 'Code', type: 'SERVER_JS' }, expected: 'src/Code.gs' },
{ input: { name: 'Index', type: 'HTML' }, expected: 'src/Index.html' },
{ input: { name: 'styles', type: 'HTML' }, expected: 'src/styles.html' },
{ input: { name: 'Unknown', type: 'OTHER' }, expected: null }
];
testCases.forEach(function(tc) {
const actual = mapAppsScriptFileToRepoPath(tc.input);
assertEqual_(
`Path mapping for ${tc.input.name} (${tc.input.type})`,
actual,
tc.expected
);
});
console.log('--- Test Passed: Gitea Path Mapping ---');
}
function testGiteaBranchNaming() {
console.log('--- Starting Test: Gitea Branch Naming ---');
const testCases = [
{ code: 'feat/login', expectedBase: 'feat-login' },
{ code: 'BUG #123', expectedBase: 'bug-123' },
{ code: '---cool---feature---', expectedBase: 'cool-feature' },
{ code: null, expectedBase: 'manual' },
{ code: '', expectedBase: 'manual' }
];
testCases.forEach(function(tc) {
const branch = buildSyncBranchName(tc.code);
assertTrue_(
`Branch starts with sync/ for input: ${tc.code}`,
branch.indexOf('sync/') === 0
);
assertTrue_(
`Branch contains sanitized base "${tc.expectedBase}"`,
branch.indexOf(tc.expectedBase) !== -1
);
assertTrue_(
`Branch ends with timestamp pattern for input: ${tc.code}`,
/\d{8}-\d{4}$/.test(branch)
);
assertTrue_(
`Branch has no duplicate hyphens for input: ${tc.code}`,
!/--/.test(branch)
);
});
console.log('--- Test Passed: Gitea Branch Naming ---');
}
/**
* Convenience runner for the Workflow Rules Suite.
* Runs priority, label, and task assurance tests.
*/
function testWorkflowRulesSuite() {
console.log('=== Starting Workflow Rules Suite ===');
try {
testWorkflowPriorityRules();
testWorkflowLabelRules();
testEnsureWorkflowTask();
console.log('=== Workflow Rules Suite Passed ===');
} catch (e) {
console.error('=== Workflow Rules Suite FAILED ===');
console.error(e.message);
throw e;
}
}
function testPrepareGitSyncPayload() {
console.log('--- Starting Test: Prepare Git Sync Payload ---');
const mockFiles = [
{ name: 'appsscript', type: 'JSON', source: '{ "timeZone": "UTC" }' },
{ name: 'Code', type: 'SERVER_JS', source: 'function test() {}' },
{ name: 'Index', type: 'HTML', source: '<html></html>' },
{ name: 'InvalidFile', type: 'UNKNOWN', source: 'junk' },
{ name: 'MissingSource', type: 'SERVER_JS' }, // valid file type mapping, missing source resolves to empty string
null
];
const payload = prepareGitSyncPayload('test-feature', mockFiles);
assertTrue_('Base branch is set', !!payload.baseBranch);
assertTrue_('Sync branch starts with sync/', payload.syncBranch.indexOf('sync/') === 0);
assertTrue_('Sync branch contains feature code', payload.syncBranch.indexOf('test-feature') !== -1);
assertEqual_('Valid files mapped correctly', payload.files.length, 4);
assertEqual_('JSON mapped to appsscript.json', payload.files[0].repoPath, 'appsscript.json');
assertEqual_('SERVER_JS mapped to src/Code.gs', payload.files[1].repoPath, 'src/Code.gs');
assertEqual_('HTML mapped to src/Index.html', payload.files[2].repoPath, 'src/Index.html');
assertEqual_('SERVER_JS missing source mapped to src/MissingSource.gs', payload.files[3].repoPath, 'src/MissingSource.gs');
console.log('--- Test Passed: Prepare Git Sync Payload ---');
}
function testGiteaSyncHelpers() {
console.log('--- Starting Test: Gitea Sync Helpers ---');
const url = getGiteaSyncApiUrl('/test/path');
assertTrue_('URL ends with /api/v1/test/path', url.indexOf('/api/v1/test/path') !== -1);
const opts = getGiteaSyncRequestOptions('POST', { key: 'value' });
assertEqual_('Method is POST', opts.method, 'POST');
assertTrue_('Has Authorization header', !!opts.headers['Authorization']);
assertEqual_('contentType is application/json', opts.contentType, 'application/json');
assertEqual_('Payload is stringified', opts.payload, '{"key":"value"}');
console.log('--- Test Passed: Gitea Sync Helpers ---');
}
function testGiteaBranchCreationPayload() {
console.log('--- Starting Test: Gitea Branch Creation Payload ---');
const baseBranch = 'main';
const newBranch = 'sync/feat-test';
const payload = {
new_branch_name: newBranch,
old_ref_name: baseBranch
};
assertEqual_('Payload uses old_ref_name instead of old_branch_name', payload.old_ref_name, 'main');
assertTrue_('old_branch_name is undefined', payload.old_branch_name === undefined);
console.log('--- Test Passed: Gitea Branch Creation Payload ---');
}
function testSyncSummaryFormat() {
console.log('--- Starting Test: Sync Summary Format ---');
const mockResults = {
created: 2,
updated: 1,
errors: 0,
details: [
{ file: "src/Code.gs", ok: true, message: "" },
{ file: "appsscript.json", ok: true, message: "" },
{ file: "sync-manifest.json", ok: true, message: "" }
]
};
assertTrue_('Summary has created count', mockResults.created === 2);
assertTrue_('Summary has updated count', mockResults.updated === 1);
assertTrue_('Summary has errors count', mockResults.errors === 0);
assertEqual_('Summary details length is correct', mockResults.details.length, 3);
console.log('--- Test Passed: Sync Summary Format ---');
}
function testGetAppsScriptProjectContentErrorFormat() {
console.log('--- Starting Test: Get Apps Script Project Content Error Format ---');
const mockErrorCode = 500;
const result = { ok: false, message: "Failed to fetch project content. Code: " + mockErrorCode };
assertEqual_('Result ok is false', result.ok, false);
assertEqual_('Message formatting correct', result.message, "Failed to fetch project content. Code: 500");
const result403 = {
ok: false,
message: "HTTP 403 Forbidden. Ensure Google Apps Script API is enabled in user settings (https://script.google.com/home/usersettings) and OAuth scopes are correctly configured.",
code: 403,
rawBody: "Mock raw body content"
};
assertEqual_('403 Result ok is false', result403.ok, false);
assertEqual_('403 Result code is 403', result403.code, 403);
assertTrue_('Message contains HTTP 403 Forbidden', result403.message.indexOf("HTTP 403 Forbidden") !== -1);
assertEqual_('rawBody is preserved', result403.rawBody, "Mock raw body content");
console.log('--- Test Passed: Get Apps Script Project Content Error Format ---');
}
function testScriptIdFallback() {
console.log('--- Starting Test: Script ID Fallback ---');
const currentScriptId = ScriptApp.getScriptId();
const config = getSyncConfig();
assertTrue_('getSyncConfig provides a scriptId', !!config.scriptId);
console.log('Current ScriptApp ID: ' + currentScriptId);
console.log('Config Script ID: ' + config.scriptId);
if (config.scriptId === currentScriptId) {
console.log('✅ Fallback working or explicitly matched.');
} else {
console.log('️ Configured scriptId is different from current ScriptApp ID.');
}
console.log('--- Test Passed: Script ID Fallback ---');
}
function testEmptyRepoErrorDetection() {
console.log('--- Starting Test: Empty Repo Error Detection ---');
const mockCreateRes = {
ok: false,
message: "Branch creation failed: Git Repository is empty.",
code: 409
};
const isRepoEmptyError = !mockCreateRes.ok && mockCreateRes.message && mockCreateRes.message.toLowerCase().indexOf('empty') !== -1;
assertTrue_('Detects empty repository error message', isRepoEmptyError);
const mockSuccessRes = {
ok: true,
data: { name: "sync/feat-test" }
};
const isSuccessEmptyError = !mockSuccessRes.ok && mockSuccessRes.message && mockSuccessRes.message.toLowerCase().indexOf('empty') !== -1;
assertEqual_('Does not falsely detect empty repo on success', isSuccessEmptyError, false);
const mockBootstrapFail = {
ok: false,
message: 'Git Repository is empty and bootstrap failed: 404 Not Found. Harap inisialisasi repositori secara manual (misal: centang "Initialize Repository" saat membuat repo).'
};
const detectsManualInit = !mockBootstrapFail.ok && mockBootstrapFail.message.toLowerCase().indexOf('manual') !== -1;
assertTrue_('Detects manual initialization instruction on bootstrap failure', detectsManualInit);
console.log('--- Test Passed: Empty Repo Error Detection ---');
}