@@ -5,12 +5,13 @@ jest.mock('@db', () => ({
55 member : { findMany : jest . fn ( ) } ,
66 onboarding : { findUnique : jest . fn ( ) } ,
77 organization : { findUnique : jest . fn ( ) } ,
8- frameworkInstance : { findFirst : jest . fn ( ) } ,
8+ frameworkInstance : { findFirst : jest . fn ( ) , findMany : jest . fn ( ) } ,
99 employeeTrainingVideoCompletion : { findMany : jest . fn ( ) } ,
1010 device : { findMany : jest . fn ( ) } ,
1111 fleetPolicyResult : { findMany : jest . fn ( ) } ,
1212 evidenceSubmission : { groupBy : jest . fn ( ) } ,
1313 finding : { findMany : jest . fn ( ) } ,
14+ sOADocument : { findFirst : jest . fn ( ) } ,
1415 } ,
1516} ) ) ;
1617
@@ -20,7 +21,12 @@ jest.mock('../utils/compliance-filters', () => ({
2021
2122import { db } from '@db' ;
2223import { filterComplianceMembers } from '../utils/compliance-filters' ;
23- import { getOverviewScores } from './frameworks-scores.helper' ;
24+ import {
25+ computeFrameworkComplianceScore ,
26+ getOverviewScores ,
27+ } from './frameworks-scores.helper' ;
28+
29+ const SIX_MONTHS_MS = 6 * 30 * 24 * 60 * 60 * 1000 ;
2430
2531const mockDb = db as jest . Mocked < typeof db > ;
2632const mockFilterComplianceMembers =
@@ -42,6 +48,8 @@ describe('frameworks-scores.helper', () => {
4248 ( mockDb . fleetPolicyResult . findMany as jest . Mock ) . mockResolvedValue ( [ ] ) ;
4349 ( mockDb . evidenceSubmission . groupBy as jest . Mock ) . mockResolvedValue ( [ ] ) ;
4450 ( mockDb . finding . findMany as jest . Mock ) . mockResolvedValue ( [ ] ) ;
51+ ( mockDb . frameworkInstance . findMany as jest . Mock ) . mockResolvedValue ( [ ] ) ;
52+ ( ( mockDb as any ) . sOADocument . findFirst as jest . Mock ) . mockResolvedValue ( null ) ;
4553 } ) ;
4654
4755 it ( 'requires installed device for people completion when device agent step is enabled' , async ( ) => {
@@ -240,6 +248,107 @@ describe('frameworks-scores.helper', () => {
240248 } ) ;
241249 } ) ;
242250
251+ describe ( 'computeFrameworkComplianceScore' , ( ) => {
252+ it ( 'returns 0 when the framework has no artifacts' , ( ) => {
253+ expect (
254+ computeFrameworkComplianceScore ( { controls : [ ] } , [ ] , [ ] ) ,
255+ ) . toBe ( 0 ) ;
256+ } ) ;
257+
258+ it ( 'returns 100 when every artifact across the framework is complete' , ( ) => {
259+ const framework = {
260+ controls : [
261+ {
262+ id : 'c1' ,
263+ policies : [ { id : 'p1' , status : 'published' } ] ,
264+ controlDocumentTypes : [ ] ,
265+ } ,
266+ ] ,
267+ } ;
268+ const tasks = [
269+ { id : 't1' , status : 'done' , controls : [ { id : 'c1' } ] } ,
270+ ] ;
271+ expect ( computeFrameworkComplianceScore ( framework , tasks , [ ] ) ) . toBe ( 100 ) ;
272+ } ) ;
273+
274+ it ( 'weights every artifact equally instead of treating partial controls as 0%' , ( ) => {
275+ const framework = {
276+ controls : [
277+ {
278+ id : 'c1' ,
279+ policies : [ { id : 'p1' , status : 'published' } ] ,
280+ controlDocumentTypes : [ { formType : 'access_control_policy' } ] ,
281+ } ,
282+ {
283+ id : 'c2' ,
284+ policies : [ { id : 'p2' , status : 'draft' } ] ,
285+ controlDocumentTypes : [ ] ,
286+ } ,
287+ ] ,
288+ } ;
289+ const tasks = [
290+ { id : 't1' , status : 'done' , controls : [ { id : 'c1' } ] } ,
291+ { id : 't2' , status : 'todo' , controls : [ { id : 'c2' } ] } ,
292+ ] ;
293+ // 5 unique artifacts (2 policies, 2 tasks, 1 doc type), 2 completed → 40%
294+ // The old binary-completion implementation would have returned 0%
295+ // because no control is fully satisfied.
296+ expect ( computeFrameworkComplianceScore ( framework , tasks , [ ] ) ) . toBe ( 40 ) ;
297+ } ) ;
298+
299+ it ( 'only treats a document as completed when its latest submission is within 6 months' , ( ) => {
300+ const framework = {
301+ controls : [
302+ {
303+ id : 'c1' ,
304+ policies : [ ] ,
305+ controlDocumentTypes : [
306+ { formType : 'access_control_policy' } ,
307+ { formType : 'incident_response_plan' } ,
308+ ] ,
309+ } ,
310+ ] ,
311+ } ;
312+ const recent = new Date ( Date . now ( ) - 30 * 24 * 60 * 60 * 1000 ) ;
313+ const stale = new Date ( Date . now ( ) - SIX_MONTHS_MS - 24 * 60 * 60 * 1000 ) ;
314+ const submissions = [
315+ { formType : 'access_control_policy' , submittedAt : recent } ,
316+ { formType : 'incident_response_plan' , submittedAt : stale } ,
317+ ] ;
318+ expect ( computeFrameworkComplianceScore ( framework , [ ] , submissions ) ) . toBe (
319+ 50 ,
320+ ) ;
321+ } ) ;
322+
323+ it ( 'deduplicates artifacts shared across controls' , ( ) => {
324+ const framework = {
325+ controls : [
326+ {
327+ id : 'c1' ,
328+ policies : [ { id : 'p1' , status : 'published' } ] ,
329+ controlDocumentTypes : [ { formType : 'access_control_policy' } ] ,
330+ } ,
331+ {
332+ id : 'c2' ,
333+ policies : [ { id : 'p1' , status : 'published' } ] ,
334+ controlDocumentTypes : [ { formType : 'access_control_policy' } ] ,
335+ } ,
336+ ] ,
337+ } ;
338+ const sharedTask = {
339+ id : 't1' ,
340+ status : 'done' ,
341+ controls : [ { id : 'c1' } , { id : 'c2' } ] ,
342+ } ;
343+ // Without dedup: 6 artifacts (2 policies, 2 tasks, 2 docs), 4 completed → 67%
344+ // With dedup: 2 unique artifacts (1 policy, 1 task), 2 completed; 1 unmet doc → 67%
345+ // Wait: 1 policy (done) + 1 task (done) + 1 doc (no submission, not fresh) = 2/3 = 67%
346+ expect ( computeFrameworkComplianceScore ( framework , [ sharedTask ] , [ ] ) ) . toBe (
347+ 67 ,
348+ ) ;
349+ } ) ;
350+ } ) ;
351+
243352 it ( 'skips security training requirement when security training step is disabled' , async ( ) => {
244353 const members : Array < {
245354 id : string ;
0 commit comments