Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-09-26 12:57:21 +08:00
parent 9255533215
commit 8bf4c04f0b
9 changed files with 197 additions and 52 deletions
@@ -8,8 +8,9 @@
***********************************************************************************************************************************************************************
* Change History:
* DATE AUTHOR LOG DESCRIPTION
* Oct. 6, 2025 rgrgogu 001 Initial creation - STAR Phase 1
* Oct 06,2025 rgrgogu 001 Initial creation - STAR Phase 1
* May 23,2026 rgrgogu 002 Added group_code to createGroup / updateGroup; added generateGroupCode helper
* Sept 24,2026 Kenneth Obsequio 003 Limit group_code format for long text.
***********************************************************************************************************************************************************************/
const sequelize = require('../../config/db.config');
const { Op, Sequelize } = require('sequelize');
@@ -32,8 +33,39 @@ const { dropDefaultGroupMembership, reconcileDefaultGroup } = require('../../uti
* e.g. "SALES-A3F1", "ONBOARD-Q1-9C2D"
* Retries up to 5 times in the unlikely event of a collision.
*/
const LIMITWORDS = new Set(['OF', 'THE', 'AND', 'FOR', 'TO', 'IN', 'A', 'AN']);
const buildSlugGroupCode = (name, maxLen = 4) => {
const words = name
.toUpperCase()
.trim()
.split(/\s+/)
.map(w => w.replace(/[^A-Z0-9]/g, ''))
.filter(w => w.length > 0 && !LIMITWORDS.has(w));
if (words.length === 0) return 'GROUP'.slice(0, maxLen); // ensure fallback also respects cap
// Single word -> just truncate it (e.g. "Sales" -> "SALE")
if (words.length === 1) {
return words[0].slice(0, maxLen);
}
// Multiple words -> take first letter of each
const acronym = words.map(w => w[0]).join('');
// Guard against 1-letter acronyms (e.g. two 1-word-after-filtering edge cases)
return acronym.length >= 2 ? acronym.slice(0, maxLen) : words[0].slice(0, maxLen);
}
/**
* Generates a unique group_code in the format: <SLUG>-<4-char hex>
* e.g. "GA-F2CA", "SALES-9C2D"
* Retries up to 5 times in the unlikely event of a collision.
*/
const generateGroupCode = async (name) => {
const slug = name.toUpperCase().trim().replace(/\s+/g, '-').replace(/[^A-Z0-9\-]/g, '').slice(0, 20);
const slug = buildSlugGroupCode(name);
for (let i = 0; i < 5; i++) {
const suffix = Math.random().toString(16).slice(2, 6).toUpperCase();
const code = `${slug}-${suffix}`;
@@ -43,6 +75,9 @@ const generateGroupCode = async (name) => {
throw new Error('Could not generate a unique group code after 5 attempts.');
};
// ─── Exports for unit testing ──────────────────────────────────────────────
exports.__test__ = { buildSlugGroupCode, generateGroupCode };
// ─── GET ALL ──────────────────────────────────────────────────────────────────
exports.getGroups = async (req, res) => {
try {
@@ -0,0 +1,100 @@
// tests/controllers/user_groups.controller.test.js
jest.mock('../../models/users/user_groups.mdl', () => ({
mdl_UserGroups: { findOne: jest.fn() },
mdl_UserGroupMembers: {},
}));
const { mdl_UserGroups } = require('../../models/users/user_groups.mdl');
const { __test__ } = require('../../controllers/admin/user_groups.controller');
const { buildSlugGroupCode, generateGroupCode } = __test__;
describe('buildSlugGroupCode', () => {
test('multi-word name -> acronym from first letters', () => {
const result = buildSlugGroupCode('Group of Auditors');
expect(result).toBe('GA');
});
test('filters out stopwords before building acronym', () => {
const result = buildSlugGroupCode('The Sales and Marketing Team');
expect(result).toBe('SMT');
});
test('single word -> truncated as-is', () => {
const result = buildSlugGroupCode('Sales');
expect(result).toBe('SALE');
});
test('respects maxLen', () => {
const result = buildSlugGroupCode('Internal Audit Team Extended', 3);
expect(result).toBe('IAT');
});
test('strips non-alphanumeric characters per word', () => {
const result = buildSlugGroupCode('R&D Ops');
expect(result).toBe('RO');
});
test('falls back to GROUP when name is only stopwords/empty after filtering', () => {
const result = buildSlugGroupCode('The Of And');
expect(result).toBe('GROU');
});
test('falls back to first word when acronym would be 1 letter', () => {
const result = buildSlugGroupCode('A Ops');
expect(result).toBe('OPS');
});
test('is case-insensitive on input', () => {
const result = buildSlugGroupCode('group of auditors');
expect(result).toBe('GA');
});
});
describe('generateGroupCode', () => {
beforeEach(() => {
mdl_UserGroups.findOne.mockReset();
});
test('returns SLUG-XXXX when code is unique on first try', async () => {
mdl_UserGroups.findOne.mockResolvedValueOnce(null);
const code = await generateGroupCode('Group of Auditors');
console.log(`[TEST][GROUP CODE] Generated unique code: "${code}" (1 attempt)`);
expect(code).toMatch(/^GA-[0-9A-F]{4}$/);
expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(1);
});
test('retries on collision until a unique code is found', async () => {
mdl_UserGroups.findOne
.mockResolvedValueOnce({ group_code: 'GA-AAAA' })
.mockResolvedValueOnce({ group_code: 'GA-BBBB' })
.mockResolvedValueOnce(null);
const code = await generateGroupCode('Group of Auditors');
expect(code).toMatch(/^GA-[0-9A-F]{4}$/);
expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(3);
});
test('throws after 5 failed attempts', async () => {
mdl_UserGroups.findOne.mockResolvedValue({ group_code: 'GA-AAAA' });
await expect(generateGroupCode('Group of Auditors')).rejects.toThrow(
'Could not generate a unique group code after 5 attempts.'
);
expect(mdl_UserGroups.findOne).toHaveBeenCalledTimes(5);
});
test('generated code stays within max slug length', async () => {
mdl_UserGroups.findOne.mockResolvedValueOnce(null);
const code = await generateGroupCode('Internal Audit Team For The Whole Organization Wide');
const [slug] = code.split('-');
console.log(`[TEST][GROUP CODE] Long name -> "${code}" (slug length: ${slug.length})`);
expect(slug.length).toBeLessThanOrEqual(4); // 4, not 8
expect(code.length).toBeLessThanOrEqual(9); // total: XXXX-XXXX
});
});
@@ -131,8 +131,8 @@ describe('createOrder()', () => {
await provider.createOrder({ amount: 5, referenceId: 'ref-1' });
const [, body] = axios.post.mock.calls[1];
expect(body.application_context.return_url).toBe('https://app.new-starr.test/plans/checkout');
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/plans/checkout?cancelled=true');
expect(body.application_context.return_url).toBe('https://app.new-starr.test/subscriptions/checkout');
expect(body.application_context.cancel_url).toBe('https://app.new-starr.test/subscriptions/checkout?cancelled=true');
});
test('honors explicit return/cancel urls when provided', async () => {
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link
@@ -38,6 +38,7 @@ export function DashboardSection({
iconMap = {},
linkMap = {},
chartLinkMap = {},
chartHeight = 240,
className = "",
}) {
return (
@@ -57,6 +58,8 @@ export function DashboardSection({
label={b.label}
data={b.data}
onBarClick={chartLinkMap[b.key]}
height={b.height ?? chartHeight}
yAxisWidth={b.yAxisWidth}
/>
) : (
<PieBreakdown
@@ -64,6 +67,7 @@ export function DashboardSection({
label={b.label}
data={b.data}
onSliceClick={chartLinkMap[b.key]}
height={b.height ?? chartHeight}
/>
)
)}
@@ -41,6 +41,7 @@ export function TableDashboard({
breakdowns = [],
statMap = {},
tableRefsRef, // ← remove activeFilters prop entirely
chartHeight = 240,
className = "",
}) {
// ─── Always read live from ref ────────────────────────────────────────────
@@ -124,6 +125,7 @@ export function TableDashboard({
label={b.label}
data={b.data}
onBarClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
height={b.height ?? chartHeight}
/>
) : (
<PieBreakdown
@@ -131,6 +133,7 @@ export function TableDashboard({
label={b.label}
data={b.data}
onSliceClick={b.filterId ? (entry) => handleChartClick(b, entry) : undefined}
height={b.height ?? chartHeight}
/>
)
)}
@@ -155,6 +155,7 @@ export default function UsersTable() {
breakdowns={dashboardBreakdowns}
statMap={USER_STAT_MAP}
tableRefsRef={tableRefsRef}
chartHeight={320}
/>
)}
@@ -50,6 +50,7 @@ export default function ViewUser() {
fetchUserAchievements(userId);
fetchUserActivity(userId, { page: 1, limit: 10 });
fetchUserBans(userId);
window.scrollTo(0, 0);
}, [userId]);
const loadActivityPage = (p) => {
+1 -1
View File
@@ -9,6 +9,6 @@
"test": "pnpm --filter api test"
},
"devDependencies": {
"concurrently": "^9.2.1"
"concurrently": "^9.2.4"
}
}