From 17a3035d2817422d328b3b489f67494a8137fd40 Mon Sep 17 00:00:00 2001 From: Thomas Jung Date: Mon, 26 Oct 2026 18:14:43 +0100 Subject: [PATCH] fix(display): group event buckets by app-space track, not completion histogram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event display-app's views (Most Popular Tracks, Finished by Topic, Who's on Fire, Topic Distribution, Activity Ticker) rendered the wrong labels — "1 tutorial / 2 tutorials…" — because getEventBuckets returned a per-attendee completion histogram while every consumer (socket, DonutChart, TrendingView, leaderboards, ticker) expects completions grouped by name. The prior date-window fix made the (mis-shaped) histogram data appear, surfacing the long-standing mismatch. Group buckets by TRACK instead, reusing the SAME resolution the app-space page uses (no new grouping invented): event → mission (Events.mission_ID, else Missions.event_ID backlink) → CompletionPaths (each = one track, label = CompletionPaths.name) → completions matched to a track via CompletionPathItems, with GROUP items expanded through GroupPathItems to child tutorials Window-scoped to [startDate,endDate]; distinct users per track. New shared lib srv/lib/event-path-buckets.js is the single source of truth: - computePathBuckets() — used by BOTH the anonymous EventStreamService AND the authed DisplayService getEventBuckets (both previously keyed on the always-NULL event_ID → empty results). - trackNamesForTutorial() — the realtime socket emit now labels each live completion with its track name(s) in the active event's mission (one emit per matching track), replacing the primaryTag keying that never matched the bucket names. Completions outside every track emit nothing. Verified against PROD event 10000002: returns real tracks ("ABAP":2, "Joule Studio":1) resolved via the Missions.event_ID backlink. --- srv/developer-service.js | 53 +++++---- srv/display-service.js | 15 +-- srv/event-stream-service.js | 27 ++--- srv/lib/event-path-buckets.js | 161 ++++++++++++++++++++++++++ test/display-service.test.js | 20 +++- test/lib/event-path-buckets.test.js | 89 ++++++++++++++ test/lib/event-stream-service.test.js | 56 +++++---- 7 files changed, 347 insertions(+), 74 deletions(-) create mode 100644 srv/lib/event-path-buckets.js create mode 100644 test/lib/event-path-buckets.test.js diff --git a/srv/developer-service.js b/srv/developer-service.js index 50ebf1df7..11ebc036c 100644 --- a/srv/developer-service.js +++ b/srv/developer-service.js @@ -789,25 +789,33 @@ export default class DeveloperService extends cds.ApplicationService { try { // A completion is event-agnostic — it carries no event_ID. Match it to // any event whose [startDate,endDate] window contains the completion - // instant and broadcast to that event's WS context. (The old code bailed - // on `!result.event_ID`, so normal completions — which never set it — - // never reached the display; see getEventBuckets for the same fix.) + // instant, and label it with the TRACK(s) it belongs to in that event's + // mission — the SAME grouping the display buckets + app-space page use + // (lib/event-path-buckets.js). A tutorial outside every track emits + // nothing (no bar). (The old code bailed on `!result.event_ID` — NULL on + // normal completions — and keyed bucketName on primaryTag, which did not + // match the path-grouped buckets; see getEventBuckets for the fetch fix.) const at = result.completionDate || new Date().toISOString(); const events = await SELECT.from(dbEvents) - .columns('legacyId') + .columns('ID', 'legacyId', 'startDate', 'endDate', 'mission_ID') .where(`startDate <=`, at).and(`endDate >=`, at); if (!events.length) return; - const tutorial = await SELECT.one.from(dbTutorials).where({ legacyId: result.taskLegacyId }); - const user = await SELECT.one.from(dbUsers).where({ ID: result.user_ID }); + const { trackNamesForTutorial } = await import('./lib/event-path-buckets.js'); + const db = await cds.connect.to('db'); - const payload = { - bucketName: tutorial?.primaryTag || 'unknown', - completeDate: new Date().toISOString().slice(0, 10), - tutorialTitle: tutorial?.title || 'Unknown Tutorial', - }; + // Build [{ context, bucketName }] — one per (event, track) the completion lands in. + const targets = []; + for (const ev of events) { + const names = await trackNamesForTutorial(ev, result.taskLegacyId, db); + for (const name of names) targets.push({ context: String(ev.legacyId), bucketName: name }); + } + if (!targets.length) return; - const contexts = events.map(e => String(e.legacyId)); + const tutorial = await SELECT.one.from(dbTutorials).where({ legacyId: result.taskLegacyId }); + const user = await SELECT.one.from(dbUsers).where({ ID: result.user_ID }); + const completeDate = new Date().toISOString().slice(0, 10); + const tutorialTitle = tutorial?.title || 'Unknown Tutorial'; // Broadcast to kiosks (unauthenticated, no user info). // Broadcast to authenticated clients (with user name). @@ -827,16 +835,19 @@ export default class DeveloperService extends cds.ApplicationService { const privileged = cds.User.privileged; const broadcast = async () => { const eventStream = await cds.connect.to('EventStreamService'); - await eventStream.tx({ user: privileged }, tx => - tx.emit('tutorialCompleted', payload, { contexts }) - ); const display = await cds.connect.to('DisplayService'); - await display.tx({ user: privileged }, tx => - tx.emit('tutorialCompleted', - { ...payload, userName: user?.displayName || 'Someone' }, - { contexts } - ) - ); + for (const t of targets) { + const payload = { bucketName: t.bucketName, completeDate, tutorialTitle }; + await eventStream.tx({ user: privileged }, tx => + tx.emit('tutorialCompleted', payload, { contexts: [t.context] }) + ); + await display.tx({ user: privileged }, tx => + tx.emit('tutorialCompleted', + { ...payload, userName: user?.displayName || 'Someone' }, + { contexts: [t.context] } + ) + ); + } }; broadcast().catch(e => cds.log('ws').warn('Failed to emit tutorialCompleted:', e.message)); } catch (e) { diff --git a/srv/display-service.js b/srv/display-service.js index 13cee921c..7fcf5cda3 100644 --- a/srv/display-service.js +++ b/srv/display-service.js @@ -1,5 +1,6 @@ import cds from '@sap/cds'; -import { computeBuckets, computeBurnup, computeTrackStats, computeCompletionSpeed, computeLeaderboard } from './lib/event-statistics.js'; +import { computeBurnup, computeTrackStats, computeCompletionSpeed, computeLeaderboard } from './lib/event-statistics.js'; +import { computePathBuckets } from './lib/event-path-buckets.js'; import { cached } from './lib/ttl-cache.js'; const CACHE_TTL = 600_000; // 600 seconds, matches IMS leaderboard cache @@ -25,13 +26,13 @@ export default class DisplayService extends cds.ApplicationService { const event = await resolveEvent(eventLegacyId, req); if (!event) return; + // Per-track ("CompletionPath") completion counts, grouped the SAME way as + // the app-space page (event → mission → paths), window-scoped. Shared with + // the anonymous EventStreamService.getEventBuckets via lib/event-path-buckets.js. + // Replaces the old event_ID join (NULL on all normal completions → always []). return cached(`buckets:${eventLegacyId}`, CACHE_TTL, async () => { - const records = await SELECT.from(TaskRecords).where({ - event_ID: event.ID, - taskType: 'TUTORIAL', - status: { in: COMPLETION_STATUSES } - }); - return computeBuckets(records); + const db = await cds.connect.to('db'); + return computePathBuckets(event, db); }); }); diff --git a/srv/event-stream-service.js b/srv/event-stream-service.js index c847b3699..e36dd1949 100644 --- a/srv/event-stream-service.js +++ b/srv/event-stream-service.js @@ -1,16 +1,13 @@ import cds from '@sap/cds'; -import { computeBuckets } from './lib/event-statistics.js'; +import { computePathBuckets } from './lib/event-path-buckets.js'; import { cached } from './lib/ttl-cache.js'; const CACHE_TTL = 600_000; -// "Has-ever-completed" semantic (issue #600) — see DisplayService for rationale. -const COMPLETION_STATUSES = ['COMPLETED', 'SUPERSEDED']; - export default class EventStreamService extends cds.ApplicationService { async init() { - const { Events, TaskRecords } = cds.entities('com.sap.developers.ims'); + const { Events } = cds.entities('com.sap.developers.ims'); this.on('getEventBuckets', async (req) => { const { eventLegacyId } = req.data; @@ -18,20 +15,14 @@ export default class EventStreamService extends cds.ApplicationService { const event = await SELECT.one.from(Events).where({ legacyId: eventLegacyId }); if (!event) return req.reject(404, `Event with legacy ID ${eventLegacyId} not found`); - // Scope completions by the event's date window, NOT by TaskRecords.event_ID. - // event_ID is only stamped by the TechEd scan-in / app-space flow; normal - // tutorial completions leave it NULL, so an event_ID join reported 0 for - // every date-windowed event (TechEd 2026 showed "0 completed" despite live - // activity). The display is already scoped to one event via its URL - // eventId, so [startDate,endDate] unambiguously selects the right rows. - // No window configured → nothing to count. + // Buckets = per-track ("CompletionPath") completion counts, grouped the + // SAME way the app-space page groups them: event → mission → paths, with + // completions matched to a path via CompletionPathItems (+ GroupPathItems + // for GROUP items). Window-scoped to [startDate,endDate]. See + // lib/event-path-buckets.js. Returns [] when there's no window/mission/path. const buckets = await cached(`es-buckets:${eventLegacyId}`, CACHE_TTL, async () => { - if (!event.startDate || !event.endDate) return []; - const records = await SELECT.from(TaskRecords) - .where({ taskType: 'TUTORIAL', status: { in: COMPLETION_STATUSES } }) - .and(`completionDate >=`, event.startDate) - .and(`completionDate <=`, event.endDate); - return computeBuckets(records); + const db = await cds.connect.to('db'); + return computePathBuckets(event, db); }); return { diff --git a/srv/lib/event-path-buckets.js b/srv/lib/event-path-buckets.js new file mode 100644 index 000000000..6d2e3b5e6 --- /dev/null +++ b/srv/lib/event-path-buckets.js @@ -0,0 +1,161 @@ +import cds from '@sap/cds'; + +const NS = 'com.sap.developers.ims'; + +/** + * Resolve the event's mission ID using the SAME precedence as app-space + * (getAppSpaceProgress): forward Events.mission_ID, else the Missions.event_ID + * backlink (the only link the admin UI exposes), tie-broken by published then + * legacyId. Returns null when no mission is configured. + */ +export async function resolveEventMissionId(event, db) { + const { Missions } = cds.entities(NS); + if (event?.mission_ID) return event.mission_ID; + const linked = await db.run( + SELECT.one.from(Missions).columns('ID') + .where({ event_ID: event.ID }) + .orderBy('published desc', 'legacyId') + ); + return linked?.ID ?? null; +} + +/** + * Build the map (taskType:taskLegacyId) → Set(path display-name) for an event's + * mission tracks — direct CompletionPathItems plus GROUP items expanded through + * GroupPathItems to their child tutorials. This is the single source of truth + * for "which track(s) does a completed tutorial belong to", shared by the + * display bucket aggregation (computePathBuckets) and the realtime socket emit + * so both label completions with the SAME track names the app-space page uses. + * + * Returns { pathNameByToken: Map>, pathNameById: Map }. + */ +export async function buildTrackMap(missionId, db) { + const { CompletionPaths, CompletionPathItems, GroupPathItems } = cds.entities(NS); + const pathNameByToken = new Map(); + + const paths = await db.run( + SELECT.from(CompletionPaths).columns('ID', 'name').where({ mission_ID: missionId }).orderBy('legacyId') + ); + const pathNameById = new Map(paths.map(p => [p.ID, p.name || ''])); + if (!paths.length) return { pathNameByToken, pathNameById }; + + const pathIds = paths.map(p => p.ID); + const items = await db.run( + SELECT.from(CompletionPathItems).columns('path_ID', 'taskType', 'taskLegacyId', 'group_ID') + .where({ path_ID: { in: pathIds } }) + ); + + const addToken = (tok, pid) => { + const name = pathNameById.get(pid); + if (name == null) return; + if (!pathNameByToken.has(tok)) pathNameByToken.set(tok, new Set()); + pathNameByToken.get(tok).add(name); + }; + + const groupToPaths = new Map(); // group_ID → Set(path_ID) + for (const it of items) { + if (!it.path_ID) continue; + if (it.taskType === 'GROUP' && it.group_ID) { + if (!groupToPaths.has(it.group_ID)) groupToPaths.set(it.group_ID, new Set()); + groupToPaths.get(it.group_ID).add(it.path_ID); + } else if (it.taskLegacyId != null) { + addToken(`${it.taskType}:${it.taskLegacyId}`, it.path_ID); + } + } + + const groupIds = [...groupToPaths.keys()]; + if (groupIds.length) { + const gpi = await db.run( + SELECT.from(GroupPathItems).columns('group_ID', 'tutorial.legacyId as tutLegacyId') + .where({ group_ID: { in: groupIds } }) + ); + for (const row of gpi) { + if (row.tutLegacyId == null) continue; + for (const pid of groupToPaths.get(row.group_ID) ?? []) { + addToken(`TUTORIAL:${row.tutLegacyId}`, pid); + } + } + } + + return { pathNameByToken, pathNameById }; +} + +/** + * Track names a single completed tutorial belongs to within an event's mission. + * Used by the realtime socket emit to label a live completion with the SAME + * track name(s) the display buckets use. Returns [] if the tutorial is not a + * member of any track (→ no bar, consistent with the fetch aggregation). + */ +export async function trackNamesForTutorial(event, tutorialLegacyId, db) { + const missionId = await resolveEventMissionId(event, db); + if (!missionId) return []; + const { pathNameByToken } = await buildTrackMap(missionId, db); + return [...(pathNameByToken.get(`TUTORIAL:${tutorialLegacyId}`) ?? [])]; +} + +/** + * Per-path ("track") completion buckets for an event's display dashboard. + * + * A "track" is one CompletionPaths row under the event's Mission — the SAME + * definition the app-space page renders (srv/developer-service.js + * getAppSpaceProgress). We deliberately reuse that resolution rather than + * invent a new grouping: + * + * event → mission (Events.mission_ID, else Missions.event_ID backlink) + * → CompletionPaths where mission_ID (each row = one track) + * → CompletionPathItems where path_ID in … ((taskType,taskLegacyId) → path) + * + GroupPathItems for GROUP items (child tutorial → group → path) + * + * Then window-scoped TUTORIAL completions ([startDate,endDate]) are mapped to + * their path(s) and counted. A completion can belong to more than one path + * (a tutorial reused across tracks) — it is counted once per path it belongs + * to, matching how app-space shows the tutorial in each track. + * + * Returns [{ bucketName: path.name, count, percentage }] sorted desc by count. + * Empty array when the event has no window, no mission, or no paths. + * + * @param {object} event resolved Events row (needs ID, startDate, endDate, mission_ID) + * @param {object} db cds db service + */ +export async function computePathBuckets(event, db) { + if (!event?.startDate || !event?.endDate) return []; + + const { TaskRecords } = cds.entities(NS); + + // 1. Resolve the event's mission + the (taskType:taskLegacyId) → track-name + // map — SAME resolution the app-space page uses (shared helpers). + const missionId = await resolveEventMissionId(event, db); + if (!missionId) return []; + const { pathNameByToken } = await buildTrackMap(missionId, db); + if (pathNameByToken.size === 0) return []; + + // 2. Window-scoped TUTORIAL completions → count distinct users per track. + const records = await db.run( + SELECT.from(TaskRecords).columns('user_ID', 'taskLegacyId') + .where({ taskType: 'TUTORIAL', status: { in: ['COMPLETED', 'SUPERSEDED'] } }) + .and(`completionDate >=`, event.startDate) + .and(`completionDate <=`, event.endDate) + ); + + const seen = new Set(); // dedupe (user, track-name) + const countByName = new Map(); + for (const r of records) { + const names = pathNameByToken.get(`TUTORIAL:${r.taskLegacyId}`); + if (!names) continue; + for (const name of names) { + const key = `${r.user_ID}|${name}`; + if (seen.has(key)) continue; + seen.add(key); + countByName.set(name, (countByName.get(name) || 0) + 1); + } + } + + const total = [...countByName.values()].reduce((s, n) => s + n, 0); + return [...countByName.entries()] + .map(([bucketName, count]) => ({ + bucketName, + count, + percentage: total > 0 ? Math.round((count / total) * 10000) / 100 : 0, + })) + .sort((a, b) => b.count - a.count); +} diff --git a/test/display-service.test.js b/test/display-service.test.js index 98bdf77a1..05205da89 100644 --- a/test/display-service.test.js +++ b/test/display-service.test.js @@ -24,7 +24,7 @@ describe('DisplayService', () => { describe('Event Data', () => { beforeAll(async () => { - const { Events, Users, Tutorials, Missions, TaskRecords } = cds.entities('com.sap.developers.ims'); + const { Events, Users, Tutorials, Missions, CompletionPaths, CompletionPathItems, TaskRecords } = cds.entities('com.sap.developers.ims'); await INSERT.into(Events).entries({ ID: 'aaaaaaaa-1111-0000-0000-000000000001', @@ -42,11 +42,18 @@ describe('DisplayService', () => { ]); await INSERT.into(Tutorials).entries([ - { ID: 'aaaaaaaa-1111-0000-0000-000000000021', title: 'Tut 1', slug: 'disp-tut-1', legacyId: 5021 }, + { ID: 'aaaaaaaa-1111-0000-0000-000000000021', title: 'Tut 1', slug: 'disp-tut-1', legacyId: 5021, status: 'ACTIVE' }, ]); + // Mission linked to the event (backlink), with one track containing Tut 1. await INSERT.into(Missions).entries([ - { ID: 'aaaaaaaa-1111-0000-0000-000000000031', title: 'Mission Alpha', legacyId: 5031 }, + { ID: 'aaaaaaaa-1111-0000-0000-000000000031', title: 'Mission Alpha', legacyId: 5031, event_ID: 'aaaaaaaa-1111-0000-0000-000000000001', published: true }, + ]); + await INSERT.into(CompletionPaths).entries([ + { ID: 'aaaaaaaa-1111-0000-0000-000000000041', name: 'Track One', legacyId: 5041, mission_ID: 'aaaaaaaa-1111-0000-0000-000000000031' }, + ]); + await INSERT.into(CompletionPathItems).entries([ + { ID: 'aaaaaaaa-1111-0000-0000-000000000051', legacyId: 5051, path_ID: 'aaaaaaaa-1111-0000-0000-000000000041', taskType: 'TUTORIAL', taskLegacyId: 5021, itemOrder: 1 }, ]); await INSERT.into(TaskRecords).entries([ @@ -69,13 +76,14 @@ describe('DisplayService', () => { expect(data.value[1].cumulative).toBe(3); }); - it('getEventBuckets returns user distribution', async () => { + it('getEventBuckets returns per-track completion counts', async () => { const { status, data } = await project.get( '/display/getEventBuckets(eventLegacyId=5001)', displayAuth ); expect(status).toBe(200); - expect(data.value.length).toBeGreaterThan(0); - expect(data.value[0].bucketName).toBe('1 tutorial'); + // Tut 1 is in "Track One"; 3 users completed it in-window → Track One = 3. + expect(data.value.length).toBe(1); + expect(data.value[0].bucketName).toBe('Track One'); expect(data.value[0].count).toBe(3); }); diff --git a/test/lib/event-path-buckets.test.js b/test/lib/event-path-buckets.test.js new file mode 100644 index 000000000..873992c90 --- /dev/null +++ b/test/lib/event-path-buckets.test.js @@ -0,0 +1,89 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import cds from '@sap/cds'; +import { computePathBuckets } from '../../srv/lib/event-path-buckets.js'; + +const project = cds.test('serve', '--project', '.', '--in-memory'); + +describe('computePathBuckets', () => { + let db; + + beforeAll(async () => { + db = await cds.connect.to('db'); + const { Events, Missions, Users, Tutorials, Groups, CompletionPaths, CompletionPathItems, GroupPathItems, TaskRecords } = + cds.entities('com.sap.developers.ims'); + + const now = Date.now(); + const start = new Date(now - 86400_000).toISOString(); + const end = new Date(now + 86400_000).toISOString(); + + const evID = 'event-pb-1'; + const misID = 'mission-pb-1'; + await INSERT.into(Events).entries({ ID: evID, legacyId: 900001, name: 'PB Event', startDate: start, endDate: end }); + // mission linked via backlink (event_ID) — the FK path getAppSpaceProgress falls back to + await INSERT.into(Missions).entries({ ID: misID, legacyId: 900002, title: 'PB Mission', event_ID: evID, published: true }); + + // two tracks + await INSERT.into(CompletionPaths).entries( + { ID: 'path-A', legacyId: 900010, name: 'Track Alpha', mission_ID: misID }, + { ID: 'path-B', legacyId: 900011, name: 'Track Beta', mission_ID: misID }, + ); + + await INSERT.into(Tutorials).entries( + { ID: 'tut-1', legacyId: 901, title: 'T1', status: 'ACTIVE' }, + { ID: 'tut-2', legacyId: 902, title: 'T2', status: 'ACTIVE' }, + { ID: 'tut-3', legacyId: 903, title: 'T3', status: 'ACTIVE' }, // reached via GROUP on Track Beta + ); + await INSERT.into(Groups).entries({ ID: 'grp-1', legacyId: 950, title: 'G1' }); + + // Track Alpha: direct TUTORIAL items 901, 902 + // Track Beta: a GROUP item (grp-1) that contains tutorial 903 + await INSERT.into(CompletionPathItems).entries( + { ID: 'cpi-1', legacyId: 9601, path_ID: 'path-A', taskType: 'TUTORIAL', taskLegacyId: 901, itemOrder: 1 }, + { ID: 'cpi-2', legacyId: 9602, path_ID: 'path-A', taskType: 'TUTORIAL', taskLegacyId: 902, itemOrder: 2 }, + { ID: 'cpi-3', legacyId: 9603, path_ID: 'path-B', taskType: 'GROUP', taskLegacyId: 950, group_ID: 'grp-1', itemOrder: 1 }, + ); + await INSERT.into(GroupPathItems).entries( + { ID: 'gpi-1', legacyId: 9701, group_ID: 'grp-1', tutorial_ID: 'tut-3', itemOrder: 1 }, + ); + + await INSERT.into(Users).entries( + { ID: 'u-1', legacyId: 9801 }, { ID: 'u-2', legacyId: 9802 }); + + const mid = new Date(now).toISOString(); + const before = new Date(now - 10 * 86400_000).toISOString(); // outside window + await INSERT.into(TaskRecords).entries( + // Alpha: u1 did 901 + 902 (2 distinct), u2 did 901 → Alpha count = 2 users (u1,u2) + { ID: 'tr-1', user_ID: 'u-1', taskLegacyId: 901, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + { ID: 'tr-2', user_ID: 'u-1', taskLegacyId: 902, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + { ID: 'tr-3', user_ID: 'u-2', taskLegacyId: 901, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + // Beta: u1 did 903 (the GROUP child) → Beta count = 1 + { ID: 'tr-4', user_ID: 'u-1', taskLegacyId: 903, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + // Out of window — excluded + { ID: 'tr-5', user_ID: 'u-2', taskLegacyId: 902, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: before }, + // Not a path tutorial — excluded from any track + { ID: 'tr-6', user_ID: 'u-2', taskLegacyId: 999, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: mid }, + ); + }); + + it('groups completions by CompletionPath track, counting distinct users per path, with GROUP expansion', async () => { + const event = await SELECT.one.from(cds.entities('com.sap.developers.ims').Events).where({ legacyId: 900001 }); + const buckets = await computePathBuckets(event, db); + const byName = Object.fromEntries(buckets.map(b => [b.bucketName, b.count])); + + // Alpha: u1 (901 or 902) + u2 (901) = 2 distinct users + expect(byName['Track Alpha']).toBe(2); + // Beta: u1 via GROUP child 903 = 1 (proves GroupPathItems expansion works) + expect(byName['Track Beta']).toBe(1); + // tutorial 999 (not a path item) contributes to no track + expect(buckets.length).toBe(2); + // sorted desc by count + expect(buckets[0].bucketName).toBe('Track Alpha'); + }); + + it('returns empty when the event has no date window', async () => { + const { Events } = cds.entities('com.sap.developers.ims'); + await INSERT.into(Events).entries({ ID: 'event-pb-nowin', legacyId: 900099, name: 'No Window' }); + const event = await SELECT.one.from(Events).where({ legacyId: 900099 }); + expect(await computePathBuckets(event, db)).toEqual([]); + }); +}); diff --git a/test/lib/event-stream-service.test.js b/test/lib/event-stream-service.test.js index a6d6e72b3..4624083eb 100644 --- a/test/lib/event-stream-service.test.js +++ b/test/lib/event-stream-service.test.js @@ -26,43 +26,55 @@ describe('EventStreamService', () => { expect(res.response?.status ?? res.status).toBe(404); }); - it('getEventBuckets counts completions within the event date window, ignoring event_ID', async () => { - const db = await cds.connect.to('db'); - const { Events, Users, TaskRecords } = cds.entities('com.sap.developers.ims'); + it('getEventBuckets returns per-track buckets grouped by CompletionPath, window-scoped', async () => { + const { Events, Missions, Users, Tutorials, CompletionPaths, CompletionPathItems, TaskRecords } = + cds.entities('com.sap.developers.ims'); - const eventID = cds.utils.uuid(); + const eventID = cds.utils.uuid(), misID = cds.utils.uuid(); await INSERT.into(Events).entries({ - ID: eventID, legacyId: 777001, name: 'Window Test Event', - eventType: 'TECHED', + ID: eventID, legacyId: 777001, name: 'Window Test Event', eventType: 'TECHED', startDate: '2026-10-26T14:00:00Z', endDate: '2026-10-29T16:00:00Z', }); + await INSERT.into(Missions).entries({ ID: misID, legacyId: 777050, title: 'M', event_ID: eventID, published: true }); + await INSERT.into(CompletionPaths).entries( + { ID: 'esp-A', legacyId: 777060, name: 'Alpha', mission_ID: misID }, + { ID: 'esp-B', legacyId: 777061, name: 'Beta', mission_ID: misID }, + ); + await INSERT.into(Tutorials).entries( + { ID: 'est-1', legacyId: 7701, title: 'T1', status: 'ACTIVE' }, + { ID: 'est-2', legacyId: 7702, title: 'T2', status: 'ACTIVE' }, + ); + await INSERT.into(CompletionPathItems).entries( + { ID: 'escpi-1', legacyId: 777070, path_ID: 'esp-A', taskType: 'TUTORIAL', taskLegacyId: 7701, itemOrder: 1 }, + { ID: 'escpi-2', legacyId: 777071, path_ID: 'esp-B', taskType: 'TUTORIAL', taskLegacyId: 7702, itemOrder: 1 }, + ); const u1 = cds.utils.uuid(), u2 = cds.utils.uuid(); - await INSERT.into(Users).entries( - { ID: u1, legacyId: 777101 }, { ID: u2, legacyId: 777102 }); - + await INSERT.into(Users).entries({ ID: u1, legacyId: 777101 }, { ID: u2, legacyId: 777102 }); await INSERT.into(TaskRecords).entries( - // In window, no event_ID — must still count (the bug: event_ID join missed these) - { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 1, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T10:00:00Z' }, - { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 2, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T11:00:00Z' }, - { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 1, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-28T09:00:00Z' }, - // Before window — excluded - { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 3, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-01T09:00:00Z' }, - // After window — excluded - { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 4, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-11-01T09:00:00Z' }, - // In window but wrong taskType — excluded - { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 5, taskType: 'MISSION', status: 'COMPLETED', completionDate: '2026-10-27T12:00:00Z' }, + // Alpha: u1 + u2 complete 7701 (in window) → Alpha = 2 + { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 7701, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T10:00:00Z' }, + { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 7701, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-28T09:00:00Z' }, + // Beta: u1 completes 7702 → Beta = 1 + { ID: cds.utils.uuid(), user_ID: u1, taskLegacyId: 7702, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T11:00:00Z' }, + // Out of window — excluded + { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 7702, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-01T09:00:00Z' }, + // Not a path tutorial — contributes to no track + { ID: cds.utils.uuid(), user_ID: u2, taskLegacyId: 7799, taskType: 'TUTORIAL', status: 'COMPLETED', completionDate: '2026-10-27T12:00:00Z' }, ); const res = await project.get('/rest/event-stream/getEventBuckets(eventLegacyId=777001)'); const body = res.data ?? res; - // u1 = 2 tutorials, u2 = 1 tutorial → buckets: {"1 tutorial":1, "2 tutorials":1} const byName = Object.fromEntries(body.buckets.map(b => [b.bucketName, b.count])); - expect(byName['2 tutorials']).toBe(1); - expect(byName['1 tutorial']).toBe(1); + expect(byName['Alpha']).toBe(2); + expect(byName['Beta']).toBe(1); + expect(body.buckets.length).toBe(2); await DELETE.from(TaskRecords).where({ user_ID: { in: [u1, u2] } }); await DELETE.from(Users).where({ ID: { in: [u1, u2] } }); + await DELETE.from(CompletionPathItems).where({ path_ID: { in: ['esp-A', 'esp-B'] } }); + await DELETE.from(CompletionPaths).where({ ID: { in: ['esp-A', 'esp-B'] } }); + await DELETE.from(Missions).where({ ID: misID }); await DELETE.from(Events).where({ ID: eventID }); });