Skip to content
Merged
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
17 changes: 8 additions & 9 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,14 @@ jobs:
# ffc-geofence-admin.js shipped via #160 (S2 of #161).
# Coverage floor enforced in S1 of #163 — see step below.
env:
# Ratcheted 3 → 6 in S4 of #163 (panel tests pushed coverage to 7.37%),
# then 6 → 7 in #165 (architectural exclusions cleaned the denominator),
# then 7 → 15 in Sprint D of #168 (Sprints A+B+C of #168 added 60+
# tests across geofence helpers, remaining panels, and 5 small
# scripts, taking overall line coverage to 16.86%). The ~1.8% buffer
# below the current baseline catches a real regression without
# firing on minor flux. Bump again whenever a PR genuinely improves
# the number.
JS_COVERAGE_FLOOR_LINES: '15'
# Ratcheted 3 → 6 → 7 → 15 across #163 / #165 / #168, then 15 → 24
# in Sprint H of #170 (medium sprints E+F+G added ~45 tests
# covering core/frontend-helpers, dynamic-fragments, and the
# load-side of device-signals + frontend.js, taking overall line
# coverage to 25.78%). The ~1.8% buffer below the current baseline
# catches a real regression without firing on minor flux. Bump
# again whenever a PR genuinely improves the number.
JS_COVERAGE_FLOOR_LINES: '24'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
Expand Down
119 changes: 119 additions & 0 deletions tests/js/device-signals-and-frontend.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Tests for the early-return and load-side behaviour of:
//
// - assets/js/ffc-device-signals.js — bails when crypto.subtle or
// ThumbmarkJS is unavailable, disables thumbmark telemetry on load.
// - assets/js/ffc-frontend.js — exposes window.ffcUtils after
// load + wires the alert helper.
//
// Both files have heavy real-runtime dependencies (SubtleCrypto, the
// vendored thumbmarkjs library, jQuery AJAX, form submit pipelines)
// that make deep unit testing expensive. Sprint G of #170 covers the
// cheap parts here; the AJAX-heavy and crypto-heavy paths warrant
// integration testing in a real browser environment, tracked as a
// future sprint.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { loadScript } from './helpers.js';

// Save originals once.
const ORIG_CRYPTO = window.crypto;
const ORIG_THUMBMARK = window.ThumbmarkJS;
const ORIG_FFC_CONFIG = window.ffc_device_config;

beforeEach(() => {
// window.crypto is a non-writable property in jsdom — use
// defineProperty to swap it.
Object.defineProperty(window, 'crypto', { value: ORIG_CRYPTO, configurable: true });
window.ThumbmarkJS = ORIG_THUMBMARK;
window.ffc_device_config = ORIG_FFC_CONFIG;
document.body.innerHTML = '';
});

// ----------------------------------------------------------------------
// ffc-device-signals.js — early returns
// ----------------------------------------------------------------------

describe('ffc-device-signals — early returns', () => {
it('bails silently when crypto.subtle is unavailable', () => {
// jsdom's window.crypto exists but without subtle. We can also
// nuke it entirely to exercise the first guard.
const c = { /* no subtle */ };
// Override with a configurable descriptor so we can restore it later.
Object.defineProperty(window, 'crypto', { value: c, configurable: true });
// Ensure ThumbmarkJS doesn't accidentally satisfy the second guard.
delete window.ThumbmarkJS;

// Loading should not throw, and should not register any
// observable side effect.
expect(() => loadScript('assets/js/ffc-device-signals.js')).not.toThrow();
});

it('bails silently when ThumbmarkJS is unavailable', () => {
// crypto.subtle is faked to PASS the first guard so we hit the
// second one.
Object.defineProperty(window, 'crypto', {
value: { subtle: {}, randomUUID: () => 'uuid', getRandomValues: () => {} },
configurable: true,
});
delete window.ThumbmarkJS;

expect(() => loadScript('assets/js/ffc-device-signals.js')).not.toThrow();
});

it("calls ThumbmarkJS.setOption('logging', false) on first load when both deps are present", () => {
const setOption = vi.fn();
const getFingerprintData = vi.fn(() => Promise.resolve({}));
Object.defineProperty(window, 'crypto', {
value: { subtle: {}, randomUUID: () => 'uuid', getRandomValues: () => {} },
configurable: true,
});
window.ThumbmarkJS = {
setOption,
getFingerprintData,
stableStringify: JSON.stringify,
};

loadScript('assets/js/ffc-device-signals.js');
expect(setOption).toHaveBeenCalledWith('logging', false);
});
});

// ----------------------------------------------------------------------
// ffc-frontend.js — public surface after load
// ----------------------------------------------------------------------

describe('ffc-frontend — load-side', () => {
beforeEach(() => {
// frontend.js touches window.FFC.Frontend.Masks at module scope;
// load the helpers module first so that namespace exists.
window.ffc_ajax = {
ajax_url: '/wp-admin/admin-ajax.php',
nonce: 'test-nonce',
strings: {},
};
loadScript('assets/js/ffc-core.js');
loadScript('assets/js/ffc-frontend-helpers.js');
});

it('republishes window.ffcUtils as the Frontend.Masks namespace', () => {
loadScript('assets/js/ffc-frontend.js');
// frontend.js does `window.ffcUtils = window.FFC.Frontend.Masks;`
expect(window.ffcUtils).toBe(window.FFC.Frontend.Masks);
// And the mask helpers still resolve.
expect(typeof window.ffcUtils.applyCpfRf).toBe('function');
});

it('does not throw when loaded against a page that has no forms', () => {
document.body.innerHTML = '<div>no forms here</div>';
expect(() => loadScript('assets/js/ffc-frontend.js')).not.toThrow();
});

it('does not throw when loaded against a page with one .ffc-submission-form', () => {
document.body.innerHTML = `
<form class="ffc-submission-form" data-form-id="1">
<input name="cpf_rf" />
<input type="hidden" name="form_id" value="1" />
</form>
`;
expect(() => loadScript('assets/js/ffc-frontend.js')).not.toThrow();
});
});
200 changes: 200 additions & 0 deletions tests/js/dynamic-fragments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
// Tests for `assets/js/ffc-dynamic-fragments.js`.
//
// The script is a pure IIFE that:
// 1. Bails early if the page has none of the FFC interactive elements
// (`.ffc-captcha-row`, `.ffc-verification-form`, etc.).
// 2. Bails if it can't find an ajax URL via `ffcDynamic` / `ffc_ajax`
// / `ffcCalendar`.
// 3. Otherwise fires an XHR to `ffc_get_dynamic_fragments` and patches
// the DOM with fresh captcha, nonces, user data, and geofence config.
//
// Sprint F of #170.
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { loadScript } from './helpers.js';

// Stash the original XMLHttpRequest so tests can restore it.
const RealXHR = window.XMLHttpRequest;

class MockXHR {
constructor() {
MockXHR.lastInstance = this;
this.status = 200;
this.responseText = '{}';
this.requestHeaders = {};
this.payload = '';
}
open(method, url) { this.method = method; this.url = url; }
setRequestHeader(k, v) { this.requestHeaders[k] = v; }
send(payload) { this.payload = payload; MockXHR.sendCount++; }
// Test helper — triggers the onload handler with the canned response.
deliver(responseObj, status = 200) {
this.status = status;
this.responseText = JSON.stringify(responseObj);
if (typeof this.onload === 'function') this.onload();
}
}
MockXHR.sendCount = 0;
MockXHR.lastInstance = null;

function installXHRMock() {
MockXHR.sendCount = 0;
MockXHR.lastInstance = null;
window.XMLHttpRequest = MockXHR;
}

beforeEach(() => {
document.body.innerHTML = '';
window.XMLHttpRequest = RealXHR;
window.ffcDynamic = undefined;
window.ffc_ajax = undefined;
window.ffcCalendar = undefined;
window.ffcGeofenceConfig = undefined;
});

describe('ffc-dynamic-fragments — early returns', () => {
it('does nothing when no FFC element is on the page', () => {
installXHRMock();
document.body.innerHTML = '<div>nothing here</div>';
window.ffcDynamic = { ajaxUrl: '/ajax' };
loadScript('assets/js/ffc-dynamic-fragments.js');
expect(MockXHR.sendCount).toBe(0);
});

it('does nothing when no ajaxUrl source is configured', () => {
installXHRMock();
document.body.innerHTML = '<div class="ffc-captcha-row"></div>';
// ffcDynamic / ffc_ajax / ffcCalendar all undefined.
loadScript('assets/js/ffc-dynamic-fragments.js');
expect(MockXHR.sendCount).toBe(0);
});

it('fires XHR when there is a captcha row + an ajaxUrl', () => {
installXHRMock();
document.body.innerHTML = '<div class="ffc-captcha-row"></div>';
window.ffcDynamic = { ajaxUrl: '/wp-admin/admin-ajax.php' };
loadScript('assets/js/ffc-dynamic-fragments.js');
expect(MockXHR.sendCount).toBe(1);
expect(MockXHR.lastInstance.method).toBe('POST');
expect(MockXHR.lastInstance.url).toBe('/wp-admin/admin-ajax.php');
expect(MockXHR.lastInstance.payload).toContain('action=ffc_get_dynamic_fragments');
});

it('falls back to ffc_ajax when ffcDynamic is missing', () => {
installXHRMock();
document.body.innerHTML = '<div class="ffc-form-container"></div>';
window.ffc_ajax = { ajax_url: '/fallback' };
loadScript('assets/js/ffc-dynamic-fragments.js');
expect(MockXHR.lastInstance.url).toBe('/fallback');
});

it('collects form_ids from ffc-form-* wrappers into the payload', () => {
installXHRMock();
document.body.innerHTML = `
<div class="ffc-form-wrapper" id="ffc-form-42">
<div class="ffc-captcha-row"></div>
</div>
<div class="ffc-form-wrapper" id="ffc-form-99"></div>
`;
window.ffcDynamic = { ajaxUrl: '/x' };
loadScript('assets/js/ffc-dynamic-fragments.js');
expect(MockXHR.lastInstance.payload).toContain('form_ids%5B%5D=42');
expect(MockXHR.lastInstance.payload).toContain('form_ids%5B%5D=99');
});
});

describe('ffc-dynamic-fragments — applyFragments via XHR onload', () => {
beforeEach(() => {
installXHRMock();
});

function setupAndDeliver(html, response) {
document.body.innerHTML = html;
window.ffcDynamic = { ajaxUrl: '/x' };
loadScript('assets/js/ffc-dynamic-fragments.js');
MockXHR.lastInstance.deliver(response);
}

it('updates the per-form captcha (label, hash, blanks the answer)', () => {
setupAndDeliver(
`<div class="ffc-form-wrapper" id="ffc-form-7">
<div class="ffc-captcha-row"><span class="ffc-captcha-label-text">old-label</span></div>
<input type="hidden" name="ffc_captcha_hash" value="old-hash" />
<input type="text" name="ffc_captcha_ans" value="123" />
</div>`,
{
success: true,
data: {
captchas: {
'7': { label: 'new-label', hash: 'new-hash' },
},
},
}
);
expect(document.querySelector('.ffc-captcha-label-text').textContent).toBe('new-label');
expect(document.querySelector('input[name="ffc_captcha_hash"]').value).toBe('new-hash');
expect(document.querySelector('input[name="ffc_captcha_ans"]').value).toBe('');
});

it('updates the default captcha when no per-form captchas matched', () => {
setupAndDeliver(
`<div class="ffc-form-container">
<div class="ffc-captcha-row"><span class="ffc-captcha-label-text">stale</span></div>
<input type="hidden" name="ffc_captcha_hash" value="stale-hash" />
<input type="text" name="ffc_captcha_ans" value="abc" />
</div>`,
{
success: true,
data: {
captcha: { label: 'fresh', hash: 'fresh-hash' },
},
}
);
expect(document.querySelector('.ffc-captcha-label-text').textContent).toBe('fresh');
expect(document.querySelector('input[name="ffc_captcha_hash"]').value).toBe('fresh-hash');
expect(document.querySelector('input[name="ffc_captcha_ans"]').value).toBe('');
});

it('refreshes ffcGeofenceConfig and triggers FFCGeofence.recheck()', () => {
// Pre-existing geofence config + recheck spy.
window.ffcGeofenceConfig = { 7: { datetime: { enabled: false } } };
window.FFCGeofence = { recheck: vi.fn() };

setupAndDeliver(
`<div class="ffc-form-wrapper" id="ffc-form-7"><div class="ffc-captcha-row"></div></div>`,
{
success: true,
data: { geofence: { 7: { datetime: { enabled: true, dateStart: '2026-01-01' } } } },
}
);
expect(window.ffcGeofenceConfig[7].datetime.enabled).toBe(true);
expect(window.FFCGeofence.recheck).toHaveBeenCalledOnce();
});

it('does nothing when response.success is false', () => {
setupAndDeliver(
`<div class="ffc-form-wrapper" id="ffc-form-9">
<div class="ffc-captcha-row"><span class="ffc-captcha-label-text">stale</span></div>
</div>`,
{ success: false, data: { captchas: { 9: { label: 'x', hash: 'y' } } } }
);
expect(document.querySelector('.ffc-captcha-label-text').textContent).toBe('stale');
});

it('does nothing when xhr.status !== 200', () => {
document.body.innerHTML = '<div class="ffc-form-wrapper" id="ffc-form-1"><div class="ffc-captcha-row"><span class="ffc-captcha-label-text">stale</span></div></div>';
window.ffcDynamic = { ajaxUrl: '/x' };
loadScript('assets/js/ffc-dynamic-fragments.js');
MockXHR.lastInstance.deliver({ success: true, data: { captcha: { label: 'fresh', hash: 'h' } } }, 500);
expect(document.querySelector('.ffc-captcha-label-text').textContent).toBe('stale');
});

it('silently swallows JSON parse errors', () => {
document.body.innerHTML = '<div class="ffc-form-container"><div class="ffc-captcha-row"></div></div>';
window.ffcDynamic = { ajaxUrl: '/x' };
loadScript('assets/js/ffc-dynamic-fragments.js');
const xhr = MockXHR.lastInstance;
xhr.responseText = 'not-json{';
// Should not throw.
expect(() => { if (typeof xhr.onload === 'function') xhr.onload(); }).not.toThrow();
});
});
Loading
Loading