From f66e9857ea49bda8158bc4a3648ac6fa53952ee2 Mon Sep 17 00:00:00 2001 From: Vrushank Patel Date: Mon, 23 Feb 2026 15:48:38 +0530 Subject: [PATCH] feat(tests): add real-flow e2e suites for remaining shield domains --- README.md | 84 ++++ package.json | 5 + src/clients/shieldApiClient.js | 8 + src/utils/flowHarness.js | 77 ++++ .../accounting-treasury-flows.e2e.test.js | 266 ++++++++++++ .../communication-flows.e2e.test.js | 258 ++++++++++++ ...onfig-document-analytics-flows.e2e.test.js | 381 ++++++++++++++++++ .../staff-payroll-flows.e2e.test.js | 260 ++++++++++++ .../utility-marketplace-flows.e2e.test.js | 309 ++++++++++++++ 9 files changed, 1648 insertions(+) create mode 100644 src/utils/flowHarness.js create mode 100644 tests/accounting/accounting-treasury-flows.e2e.test.js create mode 100644 tests/communication/communication-flows.e2e.test.js create mode 100644 tests/config-document-analytics/config-document-analytics-flows.e2e.test.js create mode 100644 tests/staff-payroll/staff-payroll-flows.e2e.test.js create mode 100644 tests/utility-marketplace/utility-marketplace-flows.e2e.test.js diff --git a/README.md b/README.md index f9068a4..5244f8a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,11 @@ This suite currently targets: - society onboarding through root APIs - admin authentication and protected endpoint access - OpenAPI-driven billing/payment smoke coverage +- accounting and treasury operation flows +- staff attendance and payroll processing flows +- utility monitoring and marketplace transaction flows +- communication module lifecycle flows +- config, document repository, and analytics governance flows - admin refresh token rotation (`/auth/refresh` and `/auth/refresh-token`) - session invalidation on logout - session invalidation on password change @@ -48,11 +53,14 @@ ShieldGuard/ │ └── utils/ │ ├── containerDiagnostics.js │ ├── dataFactory.js +│ ├── flowHarness.js │ ├── openApiContract.js │ ├── polling.js │ ├── rootAuth.js │ └── rootCredential.js └── tests/ + ├── accounting/ + │ └── accounting-treasury-flows.e2e.test.js ├── amenities-meeting/ │ └── amenities-meeting-flows.e2e.test.js ├── asset-complaint/ @@ -68,6 +76,14 @@ ShieldGuard/ │ └── tenant-onboarding.e2e.test.js ├── helpdesk-emergency/ │ └── helpdesk-emergency-flows.e2e.test.js + ├── communication/ + │ └── communication-flows.e2e.test.js + ├── config-document-analytics/ + │ └── config-document-analytics-flows.e2e.test.js + ├── staff-payroll/ + │ └── staff-payroll-flows.e2e.test.js + ├── utility-marketplace/ + │ └── utility-marketplace-flows.e2e.test.js └── visitor/ └── visitor-gatepass-flows.e2e.test.js ``` @@ -169,6 +185,36 @@ Run only helpdesk and emergency lifecycle checks: npm run test:e2e:helpdesk-emergency ``` +Run only accounting and treasury lifecycle checks: + +```bash +npm run test:e2e:accounting +``` + +Run only staff and payroll lifecycle checks: + +```bash +npm run test:e2e:staff-payroll +``` + +Run only utility and marketplace lifecycle checks: + +```bash +npm run test:e2e:utility-marketplace +``` + +Run only communication lifecycle checks: + +```bash +npm run test:e2e:communication +``` + +Run only config, document, and analytics lifecycle checks: + +```bash +npm run test:e2e:config-document-analytics +``` + ## CI Pipeline GitHub Actions workflow: `.github/workflows/ci.yml` @@ -302,6 +348,44 @@ Detailed interpretation is documented in `docs/DIAGNOSTICS_GUIDE.md`. - `drives emergency alert and safety inspection workflows with rejection checks` - Covers emergency contact creation, safety equipment/inspection creation, SOS raise/respond/resolve lifecycle, and invalid equipment rejection path. +### `accounting-treasury-flows.e2e.test.js` + +- `drives account heads, funds, ledgers, expenses, and vendor payments lifecycle` + - Covers account head creation, fund categories, budgets, ledger entries, expense approval, vendor payment states, and financial reports. + - Verifies both modern (`/ledger-entries`) and legacy (`/ledger`) accounting surfaces in one flow. +- `rejects unauthenticated accounting writes and invalid resource lookups` + - Verifies protected write endpoints reject unauthenticated traffic. + - Verifies not-found semantics for invalid resource IDs. + +### `staff-payroll-flows.e2e.test.js` + +- `drives staff attendance to payroll generation, processing, and approval lifecycle` + - Covers staff onboarding, payroll components, salary structures, attendance check-in/out, payroll generation, processing, approval, and payslip retrieval. +- `drives staff leave approval and exports with access-control checks` + - Covers leave request to approval flow and leave balance checks. + - Verifies role-protected CSV export access behavior. + +### `utility-marketplace-flows.e2e.test.js` + +- `tracks complete utility lifecycle across water, electricity, and generator logs` + - Covers tank setup, water logs/charting, meter and reading logs, and diesel generator runtime summaries. +- `drives marketplace listing, inquiry, and carpool flow with ownership checks` + - Covers listing creation, inquiry flow, ownership-enforced sell transition, view incrementing, and carpool route discovery. + +### `communication-flows.e2e.test.js` + +- `drives announcement lifecycle with attachment, publish, read receipt, and statistics` + - Covers announcement creation, attachments, publish flow, resident read receipts, and admin statistics access. +- `drives polls, newsletters, notifications, and preference updates lifecycle` + - Covers poll vote lifecycle, newsletter publish/archive-by-year, notification read semantics, and preference updates. + +### `config-document-analytics-flows.e2e.test.js` + +- `drives tenant config, module settings, and document repository lifecycle` + - Covers tenant config upsert/bulk update, module toggles, billing formula config, and document category/document lifecycle. +- `drives report templates, scheduled reports, dashboards, and analytics endpoints` + - Covers report template execution, scheduled report send-now, dashboard defaulting, and core analytics endpoint verification with seeded operational data. + ## Notes - If root credential in `root-bootstrap-credential.txt` is stale, set `SHIELD_ROOT_PASSWORD` in `.env`. diff --git a/package.json b/package.json index 0b92da6..ad3f5bb 100644 --- a/package.json +++ b/package.json @@ -14,9 +14,14 @@ "test:e2e:auth-otp-lockout": "jest --runInBand --config jest.config.cjs tests/auth/otp-lockout.e2e.test.js", "test:e2e:amenities-meeting": "jest --runInBand --config jest.config.cjs tests/amenities-meeting/amenities-meeting-flows.e2e.test.js", "test:e2e:helpdesk-emergency": "jest --runInBand --config jest.config.cjs tests/helpdesk-emergency/helpdesk-emergency-flows.e2e.test.js", + "test:e2e:accounting": "jest --runInBand --config jest.config.cjs tests/accounting/accounting-treasury-flows.e2e.test.js", "test:e2e:billing": "jest --runInBand --config jest.config.cjs tests/billing/billing-payments-contract-smoke.e2e.test.js", "test:e2e:asset-complaint": "jest --runInBand --config jest.config.cjs tests/asset-complaint/asset-complaint-workflows.e2e.test.js", "test:e2e:visitor": "jest --runInBand --config jest.config.cjs tests/visitor/visitor-gatepass-flows.e2e.test.js", + "test:e2e:staff-payroll": "jest --runInBand --config jest.config.cjs tests/staff-payroll/staff-payroll-flows.e2e.test.js", + "test:e2e:utility-marketplace": "jest --runInBand --config jest.config.cjs tests/utility-marketplace/utility-marketplace-flows.e2e.test.js", + "test:e2e:communication": "jest --runInBand --config jest.config.cjs tests/communication/communication-flows.e2e.test.js", + "test:e2e:config-document-analytics": "jest --runInBand --config jest.config.cjs tests/config-document-analytics/config-document-analytics-flows.e2e.test.js", "test:e2e:watch": "jest --watch --config jest.config.cjs", "shield:start": "node scripts/shield-runtime.cjs start", "shield:stop": "node scripts/shield-runtime.cjs stop", diff --git a/src/clients/shieldApiClient.js b/src/clients/shieldApiClient.js index 915f09d..8c782f3 100644 --- a/src/clients/shieldApiClient.js +++ b/src/clients/shieldApiClient.js @@ -38,6 +38,14 @@ class ShieldApiClient { return request.send(body || {}); } + + delete(path, accessToken) { + let request = this.client.delete(path).set('Accept', 'application/json'); + if (accessToken) { + request = request.set('Authorization', `Bearer ${accessToken}`); + } + return request; + } } module.exports = { diff --git a/src/utils/flowHarness.js b/src/utils/flowHarness.js new file mode 100644 index 0000000..bb814f0 --- /dev/null +++ b/src/utils/flowHarness.js @@ -0,0 +1,77 @@ +const { createStrongPassword, randomSuffix } = require('./dataFactory') +const { loginWithEmail, onboardSocietyWithAdmin } = require('./onboarding') + +function ensureExpectedStatus(response, expectedStatuses, method, path) { + if (expectedStatuses.includes(response.status)) { + return + } + + throw new Error( + `${method} ${path} expected ${expectedStatuses.join(' or ')}, got ${response.status} (${response.body?.message || 'no message'})` + ) +} + +async function resolveAdminSession(suite, context, scenarioTag) { + try { + if (suite.config.adminEmail && suite.config.adminPassword) { + context.adminSession = await loginWithEmail(suite.api, suite.config.adminEmail, suite.config.adminPassword) + return + } + + const onboarding = await onboardSocietyWithAdmin(suite.api, suite.config) + if (onboarding.onboardingBlocked) { + context.setupBlockedReason = + `${onboarding.onboardingBlockedReason || `${scenarioTag} onboarding unavailable.`} ` + + `Set SHIELD_ADMIN_EMAIL and SHIELD_ADMIN_PASSWORD in ShieldGuard/.env for ${scenarioTag}.` + return + } + + context.adminSession = onboarding.adminSession + } catch (error) { + const message = error?.message || `${scenarioTag} setup failed` + context.setupBlockedReason = + `${message}. Set SHIELD_ROOT_PASSWORD or provide SHIELD_ADMIN_EMAIL and SHIELD_ADMIN_PASSWORD in ShieldGuard/.env.` + } +} + +function skipIfSetupBlocked(context) { + if (!context.setupBlockedReason) { + return false + } + + expect(context.setupBlockedReason).toMatch(/SHIELD_(ADMIN_EMAIL|ADMIN_PASSWORD|ROOT_PASSWORD)/) + return true +} + +async function createUser(suite, accessToken, unitId, role, namePrefix) { + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const password = createStrongPassword(role) + const response = await suite.api.post( + '/api/v1/users', + { + unitId, + name: `${namePrefix} ${suffix}`, + email: `${role.toLowerCase()}.${namePrefix.toLowerCase()}.${suffix}@shieldguard.test`, + phone: `+9166${suffix.slice(-8)}`, + password, + role + }, + accessToken + ) + ensureExpectedStatus(response, [200], 'POST', '/api/v1/users') + + return { + user: response.body.data, + credentials: { + email: response.body.data.email, + password + } + } +} + +module.exports = { + ensureExpectedStatus, + resolveAdminSession, + skipIfSetupBlocked, + createUser +} diff --git a/tests/accounting/accounting-treasury-flows.e2e.test.js b/tests/accounting/accounting-treasury-flows.e2e.test.js new file mode 100644 index 0000000..1f407c7 --- /dev/null +++ b/tests/accounting/accounting-treasury-flows.e2e.test.js @@ -0,0 +1,266 @@ +const { randomSuffix } = require('../../src/utils/dataFactory') +const { AbstractApiTest } = require('../../src/core/abstractApiTest') +const { + ensureExpectedStatus, + resolveAdminSession, + skipIfSetupBlocked +} = require('../../src/utils/flowHarness') + +describe('Accounting and treasury real-world flow scenarios', () => { + const suite = new AbstractApiTest() + const context = { + setupBlockedReason: null + } + + beforeAll(async () => { + await suite.setup() + await resolveAdminSession(suite, context, 'SG-0012 accounting flow') + }) + + afterAll(async () => { + await suite.teardown() + }) + + it('drives account heads, funds, ledgers, expenses, and vendor payments lifecycle', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const currentYear = new Date().getUTCFullYear() + const financialYear = `${currentYear}-${currentYear + 1}` + + const accountHeadExpenseResponse = await suite.api.post( + '/api/v1/account-heads', + { + headName: `Maintenance Expense ${suffix}`, + headType: 'EXPENSE' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(accountHeadExpenseResponse, [200], 'POST', '/api/v1/account-heads') + const expenseHeadId = accountHeadExpenseResponse.body.data.id + + const accountHeadIncomeResponse = await suite.api.post( + '/api/v1/account-heads', + { + headName: `Maintenance Income ${suffix}`, + headType: 'INCOME' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(accountHeadIncomeResponse, [200], 'POST', '/api/v1/account-heads') + const incomeHeadId = accountHeadIncomeResponse.body.data.id + + const hierarchyResponse = await suite.api.get('/api/v1/account-heads/hierarchy', context.adminSession.accessToken) + ensureExpectedStatus(hierarchyResponse, [200], 'GET', '/api/v1/account-heads/hierarchy') + expect((hierarchyResponse.body.data || []).length).toBeGreaterThanOrEqual(2) + + const fundCategoryResponse = await suite.api.post( + '/api/v1/fund-categories', + { + categoryName: `Reserve Fund ${suffix}`, + description: 'Emergency reserve corpus', + currentBalance: 50000 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(fundCategoryResponse, [200], 'POST', '/api/v1/fund-categories') + const fundCategoryId = fundCategoryResponse.body.data.id + + const vendorResponse = await suite.api.post( + '/api/v1/vendors', + { + vendorName: `Vendor ${suffix}`, + contactPerson: 'Raj Vendor', + phone: '+919988776655', + email: `vendor.${suffix}@shieldguard.test`, + vendorType: 'ELECTRICAL' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(vendorResponse, [200], 'POST', '/api/v1/vendors') + const vendorId = vendorResponse.body.data.id + + const budgetResponse = await suite.api.post( + '/api/v1/budgets', + { + financialYear, + accountHeadId: expenseHeadId, + budgetedAmount: 200000 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(budgetResponse, [200], 'POST', '/api/v1/budgets') + expect(budgetResponse.body.data.financialYear).toBe(financialYear) + + const ledgerEntryResponse = await suite.api.post( + '/api/v1/ledger-entries', + { + entryDate: `${currentYear}-02-17`, + accountHeadId: incomeHeadId, + fundCategoryId, + transactionType: 'CREDIT', + amount: 100000, + description: 'Monthly maintenance collections', + referenceType: 'INVOICE' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(ledgerEntryResponse, [200], 'POST', '/api/v1/ledger-entries') + expect(ledgerEntryResponse.body.data.accountHeadId).toBe(incomeHeadId) + + const ledgerByAccountResponse = await suite.api.get( + `/api/v1/ledger-entries/account/${incomeHeadId}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(ledgerByAccountResponse, [200], 'GET', '/api/v1/ledger-entries/account/{accountHeadId}') + expect((ledgerByAccountResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const legacyLedgerIncomeResponse = await suite.api.post( + '/api/v1/ledger', + { + type: 'INCOME', + category: 'MAINTENANCE', + amount: 3000, + reference: `INC-${suffix}`, + description: 'Legacy ledger income', + entryDate: `${currentYear}-02-20` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(legacyLedgerIncomeResponse, [200], 'POST', '/api/v1/ledger') + + const legacyLedgerExpenseResponse = await suite.api.post( + '/api/v1/ledger', + { + type: 'EXPENSE', + category: 'REPAIR', + amount: 1200, + reference: `EXP-${suffix}`, + description: 'Legacy ledger expense', + entryDate: `${currentYear}-02-20` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(legacyLedgerExpenseResponse, [200], 'POST', '/api/v1/ledger') + + const legacySummaryResponse = await suite.api.get('/api/v1/ledger/summary', context.adminSession.accessToken) + ensureExpectedStatus(legacySummaryResponse, [200], 'GET', '/api/v1/ledger/summary') + expect(legacySummaryResponse.body.data.totalIncome).toBeGreaterThanOrEqual(3000) + expect(legacySummaryResponse.body.data.totalExpense).toBeGreaterThanOrEqual(1200) + + const expenseResponse = await suite.api.post( + '/api/v1/expenses', + { + accountHeadId: expenseHeadId, + fundCategoryId, + vendorId, + expenseDate: `${currentYear}-02-17`, + amount: 12000, + description: 'Lift repair and maintenance' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(expenseResponse, [200], 'POST', '/api/v1/expenses') + const expenseId = expenseResponse.body.data.id + expect(expenseResponse.body.data.paymentStatus).toBe('PENDING') + + const pendingExpensesResponse = await suite.api.get('/api/v1/expenses/pending-approval', context.adminSession.accessToken) + ensureExpectedStatus(pendingExpensesResponse, [200], 'GET', '/api/v1/expenses/pending-approval') + const pendingExpenseIds = (pendingExpensesResponse.body.data?.content || []).map((entry) => entry.id) + expect(pendingExpenseIds).toContain(expenseId) + + const approveExpenseResponse = await suite.api.post( + `/api/v1/expenses/${expenseId}/approve`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(approveExpenseResponse, [200], 'POST', '/api/v1/expenses/{id}/approve') + expect(approveExpenseResponse.body.data.paymentStatus).toBe('PAID') + + const vendorPaymentCompletedResponse = await suite.api.post( + '/api/v1/vendor-payments', + { + vendorId, + expenseId, + paymentDate: `${currentYear}-02-18`, + amount: 12000, + paymentMethod: 'NEFT', + transactionReference: `NEFT-${suffix}`, + status: 'COMPLETED' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(vendorPaymentCompletedResponse, [200], 'POST', '/api/v1/vendor-payments') + expect(vendorPaymentCompletedResponse.body.data.status).toBe('COMPLETED') + + const vendorPaymentPendingResponse = await suite.api.post( + '/api/v1/vendor-payments', + { + vendorId, + paymentDate: `${currentYear}-02-19`, + amount: 2000, + paymentMethod: 'CHEQUE', + status: 'PENDING' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(vendorPaymentPendingResponse, [200], 'POST', '/api/v1/vendor-payments') + expect(vendorPaymentPendingResponse.body.data.status).toBe('PENDING') + + const pendingVendorPaymentsResponse = await suite.api.get('/api/v1/vendor-payments/pending', context.adminSession.accessToken) + ensureExpectedStatus(pendingVendorPaymentsResponse, [200], 'GET', '/api/v1/vendor-payments/pending') + const pendingVendorPaymentIds = (pendingVendorPaymentsResponse.body.data?.content || []).map((entry) => entry.id) + expect(pendingVendorPaymentIds).toContain(vendorPaymentPendingResponse.body.data.id) + + const budgetVsActualResponse = await suite.api.get( + `/api/v1/budgets/vs-actual?financialYear=${encodeURIComponent(financialYear)}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(budgetVsActualResponse, [200], 'GET', '/api/v1/budgets/vs-actual') + expect((budgetVsActualResponse.body.data || []).length).toBeGreaterThanOrEqual(1) + + const incomeStatementResponse = await suite.api.get('/api/v1/reports/income-statement', context.adminSession.accessToken) + ensureExpectedStatus(incomeStatementResponse, [200], 'GET', '/api/v1/reports/income-statement') + + const balanceSheetResponse = await suite.api.get('/api/v1/reports/balance-sheet', context.adminSession.accessToken) + ensureExpectedStatus(balanceSheetResponse, [200], 'GET', '/api/v1/reports/balance-sheet') + + const cashFlowResponse = await suite.api.get('/api/v1/reports/cash-flow', context.adminSession.accessToken) + ensureExpectedStatus(cashFlowResponse, [200], 'GET', '/api/v1/reports/cash-flow') + + const trialBalanceResponse = await suite.api.get( + `/api/v1/reports/trial-balance?financialYear=${encodeURIComponent(financialYear)}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(trialBalanceResponse, [200], 'GET', '/api/v1/reports/trial-balance') + + const fundSummaryResponse = await suite.api.get('/api/v1/reports/fund-summary', context.adminSession.accessToken) + ensureExpectedStatus(fundSummaryResponse, [200], 'GET', '/api/v1/reports/fund-summary') + + const caExportResponse = await suite.api.get( + `/api/v1/reports/export/ca-format?financialYear=${encodeURIComponent(financialYear)}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(caExportResponse, [200], 'GET', '/api/v1/reports/export/ca-format') + expect(caExportResponse.body.data).toBeTruthy() + }) + + it('rejects unauthenticated accounting writes and invalid resource lookups', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const unauthCreateResponse = await suite.api.post('/api/v1/account-heads', { + headName: `Unauthorized Head ${suffix}`, + headType: 'EXPENSE' + }) + ensureExpectedStatus(unauthCreateResponse, [401, 403], 'POST', '/api/v1/account-heads') + + const missingResourceId = '11111111-1111-1111-1111-111111111111' + const missingVendorResponse = await suite.api.get(`/api/v1/vendors/${missingResourceId}`, context.adminSession.accessToken) + ensureExpectedStatus(missingVendorResponse, [404], 'GET', '/api/v1/vendors/{id}') + }) +}) diff --git a/tests/communication/communication-flows.e2e.test.js b/tests/communication/communication-flows.e2e.test.js new file mode 100644 index 0000000..019fa28 --- /dev/null +++ b/tests/communication/communication-flows.e2e.test.js @@ -0,0 +1,258 @@ +const { randomSuffix } = require('../../src/utils/dataFactory') +const { AbstractApiTest } = require('../../src/core/abstractApiTest') +const { createUnit, loginWithEmail } = require('../../src/utils/onboarding') +const { + ensureExpectedStatus, + resolveAdminSession, + skipIfSetupBlocked, + createUser +} = require('../../src/utils/flowHarness') + +function futureIso(daysAhead) { + return new Date(Date.now() + daysAhead * 24 * 60 * 60 * 1000).toISOString() +} + +describe('Communication real-world flow scenarios', () => { + const suite = new AbstractApiTest() + const context = { + setupBlockedReason: null + } + + beforeAll(async () => { + await suite.setup() + await resolveAdminSession(suite, context, 'SG-0012 communication flow') + + if (context.setupBlockedReason) { + return + } + + context.unit = await createUnit(suite.api, context.adminSession.accessToken, { + block: 'COM', + unitNumber: `CM-${randomSuffix().slice(-4)}` + }) + + const resident = await createUser( + suite, + context.adminSession.accessToken, + context.unit.id, + 'TENANT', + 'CommunicationResident' + ) + context.residentSession = await loginWithEmail(suite.api, resident.credentials.email, resident.credentials.password) + context.residentUser = resident.user + }) + + afterAll(async () => { + await suite.teardown() + }) + + it('drives announcement lifecycle with attachment, publish, read receipt, and statistics', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + + const announcementResponse = await suite.api.post( + '/api/v1/announcements', + { + title: `Community Notice ${suffix}`, + content: 'Water line maintenance on Saturday morning.', + category: 'MAINTENANCE', + priority: 'HIGH', + emergency: false, + expiresAt: futureIso(7), + targetAudience: 'ALL' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(announcementResponse, [200], 'POST', '/api/v1/announcements') + const announcementId = announcementResponse.body.data.id + + const attachmentResponse = await suite.api.post( + `/api/v1/announcements/${announcementId}/attachments`, + { + fileName: `notice-${suffix}.pdf`, + fileUrl: `https://files.shieldguard.test/notice-${suffix}.pdf`, + fileSize: 1024, + contentType: 'application/pdf' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(attachmentResponse, [200], 'POST', '/api/v1/announcements/{id}/attachments') + const attachmentId = attachmentResponse.body.data.id + + const attachmentsListResponse = await suite.api.get( + `/api/v1/announcements/${announcementId}/attachments`, + context.residentSession.accessToken + ) + ensureExpectedStatus(attachmentsListResponse, [200], 'GET', '/api/v1/announcements/{id}/attachments') + expect((attachmentsListResponse.body.data || []).map((entry) => entry.id)).toContain(attachmentId) + + const publishResponse = await suite.api.post( + `/api/v1/announcements/${announcementId}/publish`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(publishResponse, [200], 'POST', '/api/v1/announcements/{id}/publish') + expect(publishResponse.body.data.announcement.status).toBe('PUBLISHED') + + const activeAnnouncementsResponse = await suite.api.get( + '/api/v1/announcements/active?page=0&size=10', + context.residentSession.accessToken + ) + ensureExpectedStatus(activeAnnouncementsResponse, [200], 'GET', '/api/v1/announcements/active') + const activeAnnouncementIds = (activeAnnouncementsResponse.body.data?.content || []).map((entry) => entry.id) + expect(activeAnnouncementIds).toContain(announcementId) + + const markReadResponse = await suite.api.post( + `/api/v1/announcements/${announcementId}/mark-read`, + {}, + context.residentSession.accessToken + ) + ensureExpectedStatus(markReadResponse, [200], 'POST', '/api/v1/announcements/{id}/mark-read') + + const readReceiptsResponse = await suite.api.get( + `/api/v1/announcements/${announcementId}/read-receipts?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(readReceiptsResponse, [200], 'GET', '/api/v1/announcements/{id}/read-receipts') + expect((readReceiptsResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const statsResponse = await suite.api.get( + `/api/v1/announcements/${announcementId}/statistics`, + context.adminSession.accessToken + ) + ensureExpectedStatus(statsResponse, [200], 'GET', '/api/v1/announcements/{id}/statistics') + expect(statsResponse.body.data.totalReads).toBeGreaterThanOrEqual(1) + + const deleteAttachmentResponse = await suite.api.delete( + `/api/v1/announcements/attachments/${attachmentId}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(deleteAttachmentResponse, [200], 'DELETE', '/api/v1/announcements/attachments/{attachmentId}') + }) + + it('drives polls, newsletters, notifications, and preference updates lifecycle', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const currentYear = new Date().getUTCFullYear() + + const pollResponse = await suite.api.post( + '/api/v1/polls', + { + title: `Sunday Event ${suffix}`, + description: 'Should we host a residents event this weekend?', + multipleChoice: false, + expiresAt: futureIso(5), + options: ['Yes', 'No'] + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(pollResponse, [200], 'POST', '/api/v1/polls') + const pollId = pollResponse.body.data.id + + const activatePollResponse = await suite.api.post(`/api/v1/polls/${pollId}/activate`, {}, context.adminSession.accessToken) + ensureExpectedStatus(activatePollResponse, [200], 'POST', '/api/v1/polls/{id}/activate') + expect(activatePollResponse.body.data.status).toBe('ACTIVE') + + const pollDetailsResponse = await suite.api.get(`/api/v1/polls/${pollId}`, context.residentSession.accessToken) + ensureExpectedStatus(pollDetailsResponse, [200], 'GET', '/api/v1/polls/{id}') + const firstOptionId = pollDetailsResponse.body.data.options[0].id + + const voteResponse = await suite.api.post( + `/api/v1/polls/${pollId}/vote`, + { optionId: firstOptionId }, + context.residentSession.accessToken + ) + ensureExpectedStatus(voteResponse, [200], 'POST', '/api/v1/polls/{id}/vote') + + const pollResultsResponse = await suite.api.get(`/api/v1/polls/${pollId}/results`, context.residentSession.accessToken) + ensureExpectedStatus(pollResultsResponse, [200], 'GET', '/api/v1/polls/{id}/results') + expect(pollResultsResponse.body.data.totalVotes).toBeGreaterThanOrEqual(1) + + const newsletterResponse = await suite.api.post( + '/api/v1/newsletters', + { + title: `Monthly Digest ${suffix}`, + content: 'Highlights of this month.', + summary: 'Month in review', + fileUrl: `https://files.shieldguard.test/newsletter-${suffix}.pdf`, + year: currentYear, + month: 2 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(newsletterResponse, [200], 'POST', '/api/v1/newsletters') + const newsletterId = newsletterResponse.body.data.id + + const publishNewsletterResponse = await suite.api.post( + `/api/v1/newsletters/${newsletterId}/publish`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(publishNewsletterResponse, [200], 'POST', '/api/v1/newsletters/{id}/publish') + expect(publishNewsletterResponse.body.data.status).toBe('PUBLISHED') + + const newslettersByYearResponse = await suite.api.get( + `/api/v1/newsletters/year/${currentYear}?page=0&size=10`, + context.residentSession.accessToken + ) + ensureExpectedStatus(newslettersByYearResponse, [200], 'GET', '/api/v1/newsletters/year/{year}') + expect((newslettersByYearResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const sendNotificationResponse = await suite.api.post( + '/api/v1/notifications/send', + { + recipients: [context.residentUser.email], + subject: `Reminder ${suffix}`, + body: 'Please check your monthly notice board.' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(sendNotificationResponse, [200], 'POST', '/api/v1/notifications/send') + + const notificationsListResponse = await suite.api.get('/api/v1/notifications?page=0&size=20', context.residentSession.accessToken) + ensureExpectedStatus(notificationsListResponse, [200], 'GET', '/api/v1/notifications') + expect((notificationsListResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + const notificationId = notificationsListResponse.body.data.content[0].id + + const unreadBeforeResponse = await suite.api.get('/api/v1/notifications/unread-count', context.residentSession.accessToken) + ensureExpectedStatus(unreadBeforeResponse, [200], 'GET', '/api/v1/notifications/unread-count') + expect(unreadBeforeResponse.body.data).toBeGreaterThanOrEqual(1) + + const markReadResponse = await suite.api.post( + `/api/v1/notifications/${notificationId}/mark-read`, + {}, + context.residentSession.accessToken + ) + ensureExpectedStatus(markReadResponse, [200], 'POST', '/api/v1/notifications/{id}/mark-read') + + const markAllReadResponse = await suite.api.post('/api/v1/notifications/mark-all-read', {}, context.residentSession.accessToken) + ensureExpectedStatus(markAllReadResponse, [200], 'POST', '/api/v1/notifications/mark-all-read') + + const unreadAfterResponse = await suite.api.get('/api/v1/notifications/unread-count', context.residentSession.accessToken) + ensureExpectedStatus(unreadAfterResponse, [200], 'GET', '/api/v1/notifications/unread-count') + expect(unreadAfterResponse.body.data).toBe(0) + + const preferenceResponse = await suite.api.put( + '/api/v1/notification-preferences', + { emailEnabled: true }, + context.residentSession.accessToken + ) + ensureExpectedStatus(preferenceResponse, [200], 'PUT', '/api/v1/notification-preferences') + + const unauthAnnouncementCreateResponse = await suite.api.post('/api/v1/announcements', { + title: `Unauthorized ${suffix}`, + content: 'No auth must fail', + category: 'GENERAL', + priority: 'LOW', + emergency: false, + targetAudience: 'ALL' + }) + ensureExpectedStatus(unauthAnnouncementCreateResponse, [401, 403], 'POST', '/api/v1/announcements') + }) +}) diff --git a/tests/config-document-analytics/config-document-analytics-flows.e2e.test.js b/tests/config-document-analytics/config-document-analytics-flows.e2e.test.js new file mode 100644 index 0000000..1f3cc83 --- /dev/null +++ b/tests/config-document-analytics/config-document-analytics-flows.e2e.test.js @@ -0,0 +1,381 @@ +const { randomSuffix } = require('../../src/utils/dataFactory') +const { AbstractApiTest } = require('../../src/core/abstractApiTest') +const { createUnit } = require('../../src/utils/onboarding') +const { + ensureExpectedStatus, + resolveAdminSession, + skipIfSetupBlocked +} = require('../../src/utils/flowHarness') + +describe('Config, document, and analytics real-world flow scenarios', () => { + const suite = new AbstractApiTest() + const context = { + setupBlockedReason: null + } + + beforeAll(async () => { + await suite.setup() + await resolveAdminSession(suite, context, 'SG-0012 config-document-analytics flow') + + if (context.setupBlockedReason) { + return + } + + context.unit = await createUnit(suite.api, context.adminSession.accessToken, { + block: 'CFG', + unitNumber: `CF-${randomSuffix().slice(-4)}` + }) + }) + + afterAll(async () => { + await suite.teardown() + }) + + it('drives tenant config, module settings, and document repository lifecycle', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const configKey = `visitor.daily.limit.${suffix}` + + const upsertConfigResponse = await suite.api.put( + `/api/v1/config/${configKey}`, + { + value: '15', + category: 'security' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(upsertConfigResponse, [200], 'PUT', '/api/v1/config/{key}') + expect(upsertConfigResponse.body.data.key).toBe(configKey) + + const getConfigResponse = await suite.api.get(`/api/v1/config/${configKey}`, context.adminSession.accessToken) + ensureExpectedStatus(getConfigResponse, [200], 'GET', '/api/v1/config/{key}') + expect(getConfigResponse.body.data.value).toBe('15') + + const bulkConfigResponse = await suite.api.post( + '/api/v1/config/bulk-update', + { + entries: [ + { + key: `amenity.max.bookings.${suffix}`, + value: '3', + category: 'amenities' + }, + { + key: `parking.visitor.allowed.${suffix}`, + value: 'true', + category: 'security' + } + ] + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(bulkConfigResponse, [200], 'POST', '/api/v1/config/bulk-update') + expect((bulkConfigResponse.body.data || []).length).toBe(2) + + const configByCategoryResponse = await suite.api.get( + '/api/v1/config/category/security?page=0&size=20', + context.adminSession.accessToken + ) + ensureExpectedStatus(configByCategoryResponse, [200], 'GET', '/api/v1/config/category/{category}') + expect((configByCategoryResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(2) + + const moduleToggleOffResponse = await suite.api.put( + '/api/v1/settings/modules/marketplace/toggle', + { + enabled: false + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(moduleToggleOffResponse, [200], 'PUT', '/api/v1/settings/modules/{module}/toggle') + expect(moduleToggleOffResponse.body.data.enabled).toBe(false) + + const listModulesResponse = await suite.api.get('/api/v1/settings/modules', context.adminSession.accessToken) + ensureExpectedStatus(listModulesResponse, [200], 'GET', '/api/v1/settings/modules') + expect((listModulesResponse.body.data || []).length).toBeGreaterThan(0) + + const updateBillingFormulaResponse = await suite.api.put( + '/api/v1/settings/billing-formula', + { + value: { + method: 'HYBRID', + fixedShare: 0.4 + } + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(updateBillingFormulaResponse, [200], 'PUT', '/api/v1/settings/billing-formula') + expect(updateBillingFormulaResponse.body.data.value.method).toBe('HYBRID') + + const getBillingFormulaResponse = await suite.api.get('/api/v1/settings/billing-formula', context.adminSession.accessToken) + ensureExpectedStatus(getBillingFormulaResponse, [200], 'GET', '/api/v1/settings/billing-formula') + expect(getBillingFormulaResponse.body.data.value.method).toBe('HYBRID') + + const documentCategoryResponse = await suite.api.post( + '/api/v1/document-categories', + { + categoryName: `Bylaws ${suffix}`, + description: 'Society bylaws and policy docs', + parentCategoryId: null + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(documentCategoryResponse, [200], 'POST', '/api/v1/document-categories') + const categoryId = documentCategoryResponse.body.data.id + + const documentResponse = await suite.api.post( + '/api/v1/documents', + { + documentName: `Bylaws-${suffix}.pdf`, + categoryId, + documentType: 'PDF', + fileUrl: `https://files.shieldguard.test/docs/bylaws-${suffix}.pdf`, + fileSize: 102400, + description: 'Society bylaws document', + versionLabel: 'v1', + publicAccess: true, + expiryDate: null, + tags: 'bylaws,policy' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(documentResponse, [200], 'POST', '/api/v1/documents') + const documentId = documentResponse.body.data.id + + const getDocumentResponse = await suite.api.get(`/api/v1/documents/${documentId}`, context.adminSession.accessToken) + ensureExpectedStatus(getDocumentResponse, [200], 'GET', '/api/v1/documents/{id}') + expect(getDocumentResponse.body.data.id).toBe(documentId) + + const downloadDocumentResponse = await suite.api.get( + `/api/v1/documents/${documentId}/download`, + context.adminSession.accessToken + ) + ensureExpectedStatus(downloadDocumentResponse, [200], 'GET', '/api/v1/documents/{id}/download') + expect(downloadDocumentResponse.body.data.downloadUrl).toContain('/api/v1/files/') + + const documentByCategoryResponse = await suite.api.get( + `/api/v1/documents/category/${categoryId}?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(documentByCategoryResponse, [200], 'GET', '/api/v1/documents/category/{categoryId}') + const documentIds = (documentByCategoryResponse.body.data?.content || []).map((entry) => entry.id) + expect(documentIds).toContain(documentId) + + const documentSearchResponse = await suite.api.get( + `/api/v1/documents/search?q=${encodeURIComponent('Bylaws')}&page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(documentSearchResponse, [200], 'GET', '/api/v1/documents/search') + + const documentTagsResponse = await suite.api.get( + '/api/v1/documents/tags/bylaws?page=0&size=10', + context.adminSession.accessToken + ) + ensureExpectedStatus(documentTagsResponse, [200], 'GET', '/api/v1/documents/tags/{tag}') + + const documentAccessLogsResponse = await suite.api.get( + `/api/v1/documents/${documentId}/access-logs?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(documentAccessLogsResponse, [200], 'GET', '/api/v1/documents/{id}/access-logs') + expect((documentAccessLogsResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const unauthConfigResponse = await suite.api.get('/api/v1/config') + ensureExpectedStatus(unauthConfigResponse, [401, 403], 'GET', '/api/v1/config') + }) + + it('drives report templates, scheduled reports, dashboards, and analytics endpoints', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const currentYear = new Date().getUTCFullYear() + + const billResponse = await suite.api.post( + '/api/v1/billing/generate', + { + unitId: context.unit.id, + month: 2, + year: currentYear, + amount: 1000, + dueDate: `${currentYear}-02-28`, + lateFee: 50 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(billResponse, [200], 'POST', '/api/v1/billing/generate') + const billId = billResponse.body.data.id + + const paymentResponse = await suite.api.post( + '/api/v1/payments', + { + billId, + amount: 700, + mode: 'UPI', + transactionRef: `TXN-${suffix}` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(paymentResponse, [200], 'POST', '/api/v1/payments') + + const ledgerExpenseResponse = await suite.api.post( + '/api/v1/ledger', + { + type: 'EXPENSE', + category: 'MAINTENANCE', + amount: 300, + reference: `LED-${suffix}`, + description: 'Pipe replacement', + entryDate: `${currentYear}-02-17` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(ledgerExpenseResponse, [200], 'POST', '/api/v1/ledger') + + const visitorPassResponse = await suite.api.post( + '/api/v1/visitors/pass', + { + unitId: context.unit.id, + visitorName: `Courier ${suffix}`, + vehicleNumber: `MH01AB${suffix.slice(-4)}`, + validFrom: `${currentYear}-02-17T09:00:00Z`, + validTo: `${currentYear}-02-17T12:00:00Z` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(visitorPassResponse, [200], 'POST', '/api/v1/visitors/pass') + + const staffResponse = await suite.api.post( + '/api/v1/staff', + { + employeeId: `AN-${suffix.slice(-5)}`, + firstName: 'Rohit', + lastName: 'Guard', + phone: `+9177${suffix.slice(-8)}`, + email: `analytics.staff.${suffix}@shieldguard.test`, + designation: 'SECURITY_GUARD', + dateOfJoining: `${currentYear}-01-01`, + employmentType: 'FULL_TIME', + basicSalary: 18000, + active: true + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(staffResponse, [200], 'POST', '/api/v1/staff') + const staffId = staffResponse.body.data.id + + const attendanceCheckInResponse = await suite.api.post( + '/api/v1/staff-attendance/check-in', + { + staffId, + attendanceDate: `${currentYear}-02-17` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(attendanceCheckInResponse, [200], 'POST', '/api/v1/staff-attendance/check-in') + + const reportTemplateResponse = await suite.api.post( + '/api/v1/report-templates', + { + templateName: `Collection KPI ${suffix}`, + reportType: 'COLLECTION_EFFICIENCY', + description: 'Tracks billed versus collected amount', + queryTemplate: '', + parametersJson: '{}', + systemTemplate: false + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(reportTemplateResponse, [200], 'POST', '/api/v1/report-templates') + const templateId = reportTemplateResponse.body.data.id + + const executeTemplateResponse = await suite.api.post( + `/api/v1/report-templates/${templateId}/execute`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(executeTemplateResponse, [200], 'POST', '/api/v1/report-templates/{id}/execute') + expect(executeTemplateResponse.body.data.reportType).toBe('COLLECTION_EFFICIENCY') + + const scheduledReportResponse = await suite.api.post( + '/api/v1/scheduled-reports', + { + templateId, + reportName: `Nightly KPI ${suffix}`, + frequency: 'DAILY', + recipients: 'committee@shieldguard.test', + active: true + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(scheduledReportResponse, [200], 'POST', '/api/v1/scheduled-reports') + const scheduledReportId = scheduledReportResponse.body.data.id + + const sendNowResponse = await suite.api.post( + `/api/v1/scheduled-reports/${scheduledReportId}/send-now`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(sendNowResponse, [200], 'POST', '/api/v1/scheduled-reports/{id}/send-now') + expect(sendNowResponse.body.data.lastGeneratedAt).toBeTruthy() + + const dashboardResponse = await suite.api.post( + '/api/v1/analytics-dashboards', + { + dashboardName: `Committee Dashboard ${suffix}`, + dashboardType: 'COMMITTEE', + widgetsJson: '{"widgets":["collection","expenses"]}', + defaultDashboard: true + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(dashboardResponse, [200], 'POST', '/api/v1/analytics-dashboards') + const dashboardId = dashboardResponse.body.data.id + + const setDefaultResponse = await suite.api.post( + `/api/v1/analytics-dashboards/${dashboardId}/set-default`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(setDefaultResponse, [200], 'POST', '/api/v1/analytics-dashboards/{id}/set-default') + expect(setDefaultResponse.body.data.defaultDashboard).toBe(true) + + const collectionEfficiencyResponse = await suite.api.get( + '/api/v1/analytics/collection-efficiency', + context.adminSession.accessToken + ) + ensureExpectedStatus(collectionEfficiencyResponse, [200], 'GET', '/api/v1/analytics/collection-efficiency') + expect(collectionEfficiencyResponse.body.data.billedAmount).toBeGreaterThanOrEqual(1000) + + const expenseDistributionResponse = await suite.api.get( + '/api/v1/analytics/expense-distribution', + context.adminSession.accessToken + ) + ensureExpectedStatus(expenseDistributionResponse, [200], 'GET', '/api/v1/analytics/expense-distribution') + expect((expenseDistributionResponse.body.data || []).length).toBeGreaterThanOrEqual(1) + + const staffSummaryResponse = await suite.api.get( + '/api/v1/analytics/staff-attendance-summary', + context.adminSession.accessToken + ) + ensureExpectedStatus(staffSummaryResponse, [200], 'GET', '/api/v1/analytics/staff-attendance-summary') + expect(staffSummaryResponse.body.data.totalStaff).toBeGreaterThanOrEqual(1) + + const visitorTrendsResponse = await suite.api.get('/api/v1/analytics/visitor-trends', context.adminSession.accessToken) + ensureExpectedStatus(visitorTrendsResponse, [200], 'GET', '/api/v1/analytics/visitor-trends') + expect((visitorTrendsResponse.body.data || []).length).toBeGreaterThanOrEqual(1) + + const unauthTemplateCreateResponse = await suite.api.post('/api/v1/report-templates', { + templateName: `Unauthorized ${suffix}`, + reportType: 'COLLECTION_EFFICIENCY', + description: 'Should fail without auth', + queryTemplate: '', + parametersJson: '{}', + systemTemplate: false + }) + ensureExpectedStatus(unauthTemplateCreateResponse, [401, 403], 'POST', '/api/v1/report-templates') + }) +}) diff --git a/tests/staff-payroll/staff-payroll-flows.e2e.test.js b/tests/staff-payroll/staff-payroll-flows.e2e.test.js new file mode 100644 index 0000000..ce58b5f --- /dev/null +++ b/tests/staff-payroll/staff-payroll-flows.e2e.test.js @@ -0,0 +1,260 @@ +const { randomSuffix } = require('../../src/utils/dataFactory') +const { AbstractApiTest } = require('../../src/core/abstractApiTest') +const { + ensureExpectedStatus, + resolveAdminSession, + skipIfSetupBlocked +} = require('../../src/utils/flowHarness') + +describe('Staff and payroll real-world flow scenarios', () => { + const suite = new AbstractApiTest() + const context = { + setupBlockedReason: null + } + + beforeAll(async () => { + await suite.setup() + await resolveAdminSession(suite, context, 'SG-0012 staff-payroll flow') + }) + + afterAll(async () => { + await suite.teardown() + }) + + it('drives staff attendance to payroll generation, processing, and approval lifecycle', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const currentYear = new Date().getUTCFullYear() + const month = 2 + + const createStaffResponse = await suite.api.post( + '/api/v1/staff', + { + employeeId: `STF-${suffix.slice(-5)}`, + firstName: 'Aarav', + lastName: 'Shah', + phone: `+9199${suffix.slice(-8)}`, + email: `staff.${suffix}@shieldguard.test`, + designation: 'MANAGER', + dateOfJoining: `${currentYear}-01-01`, + employmentType: 'FULL_TIME', + basicSalary: 25000, + active: true + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(createStaffResponse, [200], 'POST', '/api/v1/staff') + const staffId = createStaffResponse.body.data.id + + const earningComponentResponse = await suite.api.post( + '/api/v1/payroll-components', + { + componentName: `Basic ${suffix}`, + componentType: 'EARNING', + taxable: true + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(earningComponentResponse, [200], 'POST', '/api/v1/payroll-components') + const earningComponentId = earningComponentResponse.body.data.id + + const deductionComponentResponse = await suite.api.post( + '/api/v1/payroll-components', + { + componentName: `PF ${suffix}`, + componentType: 'DEDUCTION', + taxable: false + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(deductionComponentResponse, [200], 'POST', '/api/v1/payroll-components') + const deductionComponentId = deductionComponentResponse.body.data.id + + const salaryStructureEarningResponse = await suite.api.post( + `/api/v1/staff/${staffId}/salary-structure`, + { + payrollComponentId: earningComponentId, + amount: 30000, + active: true, + effectiveFrom: `${currentYear}-01-01` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(salaryStructureEarningResponse, [200], 'POST', '/api/v1/staff/{id}/salary-structure') + + const salaryStructureDeductionResponse = await suite.api.post( + `/api/v1/staff/${staffId}/salary-structure`, + { + payrollComponentId: deductionComponentId, + amount: 1000, + active: true, + effectiveFrom: `${currentYear}-01-01` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(salaryStructureDeductionResponse, [200], 'POST', '/api/v1/staff/{id}/salary-structure') + + const salaryStructureListResponse = await suite.api.get( + `/api/v1/staff/${staffId}/salary-structure?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(salaryStructureListResponse, [200], 'GET', '/api/v1/staff/{id}/salary-structure') + expect((salaryStructureListResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(2) + + for (const date of [`${currentYear}-${String(month).padStart(2, '0')}-10`, `${currentYear}-${String(month).padStart(2, '0')}-11`]) { + const checkInResponse = await suite.api.post( + '/api/v1/staff-attendance/check-in', + { + staffId, + attendanceDate: date + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(checkInResponse, [200], 'POST', '/api/v1/staff-attendance/check-in') + + const checkOutResponse = await suite.api.post( + '/api/v1/staff-attendance/check-out', + { + staffId, + attendanceDate: date + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(checkOutResponse, [200], 'POST', '/api/v1/staff-attendance/check-out') + } + + const attendanceSummaryResponse = await suite.api.get( + `/api/v1/staff-attendance/summary?from=${currentYear}-${String(month).padStart(2, '0')}-01&to=${currentYear}-${String(month).padStart(2, '0')}-28`, + context.adminSession.accessToken + ) + ensureExpectedStatus(attendanceSummaryResponse, [200], 'GET', '/api/v1/staff-attendance/summary') + expect(attendanceSummaryResponse.body.data.totalRecords).toBeGreaterThanOrEqual(2) + + const payrollGenerateResponse = await suite.api.post( + '/api/v1/payroll/generate', + { + staffId, + month, + year: currentYear, + workingDays: 2, + totalDeductions: 200 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(payrollGenerateResponse, [200], 'POST', '/api/v1/payroll/generate') + const payrollId = payrollGenerateResponse.body.data.id + expect(payrollGenerateResponse.body.data.status).toBe('DRAFT') + + const payrollProcessResponse = await suite.api.post( + '/api/v1/payroll/process', + { + payrollId, + paymentMethod: 'BANK_TRANSFER', + paymentReference: `PAY-${suffix}`, + paymentDate: `${currentYear}-${String(month).padStart(2, '0')}-28`, + payslipUrl: `https://files.shieldguard.test/payslip/${payrollId}.pdf` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(payrollProcessResponse, [200], 'POST', '/api/v1/payroll/process') + expect(payrollProcessResponse.body.data.status).toBe('PROCESSED') + + const payrollApproveResponse = await suite.api.post( + `/api/v1/payroll/${payrollId}/approve`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(payrollApproveResponse, [200], 'POST', '/api/v1/payroll/{id}/approve') + expect(payrollApproveResponse.body.data.status).toBe('PAID') + + const payrollPayslipResponse = await suite.api.get(`/api/v1/payroll/${payrollId}/payslip`, context.adminSession.accessToken) + ensureExpectedStatus(payrollPayslipResponse, [200], 'GET', '/api/v1/payroll/{id}/payslip') + expect(payrollPayslipResponse.body.data.netSalary).toBeGreaterThan(0) + + const payrollByStaffResponse = await suite.api.get( + `/api/v1/payroll/staff/${staffId}?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(payrollByStaffResponse, [200], 'GET', '/api/v1/payroll/staff/{staffId}') + const payrollIds = (payrollByStaffResponse.body.data?.content || []).map((entry) => entry.id) + expect(payrollIds).toContain(payrollId) + + const payrollSummaryResponse = await suite.api.get( + `/api/v1/payroll/summary?month=${month}&year=${currentYear}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(payrollSummaryResponse, [200], 'GET', '/api/v1/payroll/summary') + expect(payrollSummaryResponse.body.data.totalPayrolls).toBeGreaterThanOrEqual(1) + }) + + it('drives staff leave approval and exports with access-control checks', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const currentYear = new Date().getUTCFullYear() + + const createStaffResponse = await suite.api.post( + '/api/v1/staff', + { + employeeId: `STL-${suffix.slice(-5)}`, + firstName: 'Riya', + lastName: 'Patel', + phone: `+9188${suffix.slice(-8)}`, + email: `staff.leave.${suffix}@shieldguard.test`, + designation: 'SECURITY_GUARD', + dateOfJoining: `${currentYear}-01-15`, + employmentType: 'FULL_TIME', + basicSalary: 18000, + active: true + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(createStaffResponse, [200], 'POST', '/api/v1/staff') + const staffId = createStaffResponse.body.data.id + + const createLeaveResponse = await suite.api.post( + '/api/v1/staff-leaves', + { + staffId, + leaveType: 'CASUAL', + fromDate: `${currentYear}-03-01`, + toDate: `${currentYear}-03-02`, + numberOfDays: 2, + reason: 'Personal work' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(createLeaveResponse, [200], 'POST', '/api/v1/staff-leaves') + expect(createLeaveResponse.body.data.status).toBe('PENDING') + const leaveId = createLeaveResponse.body.data.id + + const pendingLeavesResponse = await suite.api.get('/api/v1/staff-leaves/pending-approval?page=0&size=10', context.adminSession.accessToken) + ensureExpectedStatus(pendingLeavesResponse, [200], 'GET', '/api/v1/staff-leaves/pending-approval') + const pendingLeaveIds = (pendingLeavesResponse.body.data?.content || []).map((entry) => entry.id) + expect(pendingLeaveIds).toContain(leaveId) + + const approveLeaveResponse = await suite.api.post( + `/api/v1/staff-leaves/${leaveId}/approve`, + {}, + context.adminSession.accessToken + ) + ensureExpectedStatus(approveLeaveResponse, [200], 'POST', '/api/v1/staff-leaves/{id}/approve') + expect(approveLeaveResponse.body.data.status).toBe('APPROVED') + + const leaveBalanceResponse = await suite.api.get(`/api/v1/staff-leaves/balance/${staffId}`, context.adminSession.accessToken) + ensureExpectedStatus(leaveBalanceResponse, [200], 'GET', '/api/v1/staff-leaves/balance/{staffId}') + expect(leaveBalanceResponse.body.data.approvedDays).toBeGreaterThanOrEqual(2) + + const exportStaffResponse = await suite.api.get('/api/v1/staff/export', context.adminSession.accessToken) + ensureExpectedStatus(exportStaffResponse, [200], 'GET', '/api/v1/staff/export') + expect(exportStaffResponse.text).toContain('employee_id') + + const unauthExportResponse = await suite.api.get('/api/v1/staff/export') + ensureExpectedStatus(unauthExportResponse, [401, 403], 'GET', '/api/v1/staff/export') + }) +}) diff --git a/tests/utility-marketplace/utility-marketplace-flows.e2e.test.js b/tests/utility-marketplace/utility-marketplace-flows.e2e.test.js new file mode 100644 index 0000000..865cbd4 --- /dev/null +++ b/tests/utility-marketplace/utility-marketplace-flows.e2e.test.js @@ -0,0 +1,309 @@ +const { randomSuffix } = require('../../src/utils/dataFactory') +const { AbstractApiTest } = require('../../src/core/abstractApiTest') +const { createUnit, loginWithEmail } = require('../../src/utils/onboarding') +const { + ensureExpectedStatus, + resolveAdminSession, + skipIfSetupBlocked, + createUser +} = require('../../src/utils/flowHarness') + +describe('Utility and marketplace real-world flow scenarios', () => { + const suite = new AbstractApiTest() + const context = { + setupBlockedReason: null + } + + beforeAll(async () => { + await suite.setup() + await resolveAdminSession(suite, context, 'SG-0012 utility-marketplace flow') + + if (context.setupBlockedReason) { + return + } + + context.unit = await createUnit(suite.api, context.adminSession.accessToken, { + block: 'UTL', + unitNumber: `UT-${randomSuffix().slice(-4)}` + }) + + const seller = await createUser( + suite, + context.adminSession.accessToken, + context.unit.id, + 'TENANT', + 'MarketplaceSeller' + ) + const buyer = await createUser( + suite, + context.adminSession.accessToken, + context.unit.id, + 'OWNER', + 'MarketplaceBuyer' + ) + + context.sellerSession = await loginWithEmail(suite.api, seller.credentials.email, seller.credentials.password) + context.buyerSession = await loginWithEmail(suite.api, buyer.credentials.email, buyer.credentials.password) + }) + + afterAll(async () => { + await suite.teardown() + }) + + it('tracks complete utility lifecycle across water, electricity, and generator logs', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + const currentYear = new Date().getUTCFullYear() + const month = '02' + + const waterTankResponse = await suite.api.post( + '/api/v1/water-tanks', + { + tankName: `OH Tank ${suffix}`, + tankType: 'OVERHEAD', + capacity: 50000, + location: 'Block A Terrace' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(waterTankResponse, [200], 'POST', '/api/v1/water-tanks') + const tankId = waterTankResponse.body.data.id + + const waterLogResponse = await suite.api.post( + '/api/v1/water-level-logs', + { + tankId, + levelPercentage: 80.5, + volume: 40250, + readingTime: `${currentYear}-${month}-17T10:00:00Z` + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(waterLogResponse, [200], 'POST', '/api/v1/water-level-logs') + expect(waterLogResponse.body.data.tankId).toBe(tankId) + + const waterLogsByTankResponse = await suite.api.get( + `/api/v1/water-level-logs/tank/${tankId}?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(waterLogsByTankResponse, [200], 'GET', '/api/v1/water-level-logs/tank/{tankId}') + expect((waterLogsByTankResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const currentWaterLogResponse = await suite.api.get( + `/api/v1/water-level-logs/current?tankId=${tankId}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(currentWaterLogResponse, [200], 'GET', '/api/v1/water-level-logs/current') + expect(currentWaterLogResponse.body.data.tankId).toBe(tankId) + + const waterChartResponse = await suite.api.get( + `/api/v1/water-level-logs/chart-data?from=${encodeURIComponent(`${currentYear}-${month}-17T09:00:00Z`)}&to=${encodeURIComponent(`${currentYear}-${month}-17T12:00:00Z`)}&tankId=${tankId}&maxPoints=20`, + context.adminSession.accessToken + ) + ensureExpectedStatus(waterChartResponse, [200], 'GET', '/api/v1/water-level-logs/chart-data') + + const electricityMeterResponse = await suite.api.post( + '/api/v1/electricity-meters', + { + meterNumber: `MTR-${suffix}`, + meterType: 'MAIN', + location: 'Transformer Room', + unitId: context.unit.id + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(electricityMeterResponse, [200], 'POST', '/api/v1/electricity-meters') + const meterId = electricityMeterResponse.body.data.id + + const electricityReadingResponse = await suite.api.post( + '/api/v1/electricity-readings', + { + meterId, + readingDate: `${currentYear}-${month}-17`, + readingValue: 10500, + unitsConsumed: 220, + cost: 1760 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(electricityReadingResponse, [200], 'POST', '/api/v1/electricity-readings') + expect(electricityReadingResponse.body.data.meterId).toBe(meterId) + + const electricityReadingsByMeterResponse = await suite.api.get( + `/api/v1/electricity-readings/meter/${meterId}?page=0&size=10`, + context.adminSession.accessToken + ) + ensureExpectedStatus(electricityReadingsByMeterResponse, [200], 'GET', '/api/v1/electricity-readings/meter/{meterId}') + expect((electricityReadingsByMeterResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const electricityReportResponse = await suite.api.get( + `/api/v1/electricity-readings/consumption-report?from=${currentYear}-${month}-01&to=${currentYear}-${month}-28&meterId=${meterId}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(electricityReportResponse, [200], 'GET', '/api/v1/electricity-readings/consumption-report') + expect(electricityReportResponse.body.data.totalReadings).toBeGreaterThanOrEqual(1) + + const generatorResponse = await suite.api.post( + '/api/v1/diesel-generators', + { + generatorName: `DG-${suffix}`, + capacityKva: 125.5, + location: 'Generator Room' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(generatorResponse, [200], 'POST', '/api/v1/diesel-generators') + const generatorId = generatorResponse.body.data.id + + const generatorLogResponse = await suite.api.post( + '/api/v1/generator-logs', + { + generatorId, + logDate: `${currentYear}-${month}-17`, + startTime: `${currentYear}-${month}-17T09:30:00Z`, + stopTime: `${currentYear}-${month}-17T10:30:00Z`, + runtimeHours: 1.0, + dieselConsumed: 3.5, + dieselCost: 350, + meterReadingBefore: 12000, + meterReadingAfter: 12025, + unitsGenerated: 40 + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(generatorLogResponse, [200], 'POST', '/api/v1/generator-logs') + expect(generatorLogResponse.body.data.generatorId).toBe(generatorId) + + const generatorSummaryResponse = await suite.api.get( + `/api/v1/generator-logs/summary?from=${currentYear}-${month}-01&to=${currentYear}-${month}-28&generatorId=${generatorId}`, + context.adminSession.accessToken + ) + ensureExpectedStatus(generatorSummaryResponse, [200], 'GET', '/api/v1/generator-logs/summary') + expect(generatorSummaryResponse.body.data.totalLogs).toBeGreaterThanOrEqual(1) + }) + + it('drives marketplace listing, inquiry, and carpool flow with ownership checks', async () => { + if (skipIfSetupBlocked(context)) { + return + } + + const suffix = randomSuffix().replace(/[^0-9]/g, '') + + const categoryResponse = await suite.api.post( + '/api/v1/marketplace-categories', + { + categoryName: `Furniture ${suffix}`, + description: 'Home furniture items' + }, + context.adminSession.accessToken + ) + ensureExpectedStatus(categoryResponse, [200], 'POST', '/api/v1/marketplace-categories') + const categoryId = categoryResponse.body.data.id + + const listingResponse = await suite.api.post( + '/api/v1/marketplace-listings', + { + categoryId, + listingType: 'SELL', + title: `Dining Table ${suffix}`, + description: 'Six seater dining table in good condition', + price: 15000, + negotiable: true, + images: 'https://img.shieldguard.test/table.jpg', + unitId: context.unit.id + }, + context.sellerSession.accessToken + ) + ensureExpectedStatus(listingResponse, [200], 'POST', '/api/v1/marketplace-listings') + const listingId = listingResponse.body.data.id + expect(listingResponse.body.data.status).toBe('ACTIVE') + + const listingByTypeResponse = await suite.api.get( + '/api/v1/marketplace-listings/type/SELL?page=0&size=10', + context.buyerSession.accessToken + ) + ensureExpectedStatus(listingByTypeResponse, [200], 'GET', '/api/v1/marketplace-listings/type/{type}') + const listingIds = (listingByTypeResponse.body.data?.content || []).map((entry) => entry.id) + expect(listingIds).toContain(listingId) + + const listingSearchResponse = await suite.api.get( + `/api/v1/marketplace-listings/search?q=${encodeURIComponent('Dining')}&page=0&size=10`, + context.buyerSession.accessToken + ) + ensureExpectedStatus(listingSearchResponse, [200], 'GET', '/api/v1/marketplace-listings/search') + + const inquiryResponse = await suite.api.post( + `/api/v1/marketplace-listings/${listingId}/inquiries`, + { + message: 'Is it available for pickup this weekend?' + }, + context.buyerSession.accessToken + ) + ensureExpectedStatus(inquiryResponse, [200], 'POST', '/api/v1/marketplace-listings/{id}/inquiries') + expect(inquiryResponse.body.data.listingId).toBe(listingId) + + const unauthSoldResponse = await suite.api.post( + `/api/v1/marketplace-listings/${listingId}/mark-sold`, + {}, + context.buyerSession.accessToken + ) + ensureExpectedStatus(unauthSoldResponse, [401, 403], 'POST', '/api/v1/marketplace-listings/{id}/mark-sold') + + const markSoldResponse = await suite.api.post( + `/api/v1/marketplace-listings/${listingId}/mark-sold`, + {}, + context.sellerSession.accessToken + ) + ensureExpectedStatus(markSoldResponse, [200], 'POST', '/api/v1/marketplace-listings/{id}/mark-sold') + expect(markSoldResponse.body.data.status).toBe('SOLD') + + const incrementViewsResponse = await suite.api.post( + `/api/v1/marketplace-listings/${listingId}/increment-views`, + {}, + context.buyerSession.accessToken + ) + ensureExpectedStatus(incrementViewsResponse, [200], 'POST', '/api/v1/marketplace-listings/{id}/increment-views') + expect(incrementViewsResponse.body.data.viewsCount).toBeGreaterThanOrEqual(1) + + const inquiriesForBuyerResponse = await suite.api.get( + '/api/v1/marketplace-inquiries/my-inquiries?page=0&size=10', + context.buyerSession.accessToken + ) + ensureExpectedStatus(inquiriesForBuyerResponse, [200], 'GET', '/api/v1/marketplace-inquiries/my-inquiries') + expect((inquiriesForBuyerResponse.body.data?.content || []).length).toBeGreaterThanOrEqual(1) + + const carpoolResponse = await suite.api.post( + '/api/v1/carpool-listings', + { + routeFrom: 'Borivali', + routeTo: 'BKC', + departureTime: '08:30:00', + availableSeats: 3, + daysOfWeek: 'Mon,Tue,Wed', + vehicleType: 'CAR', + contactPreference: 'PHONE', + active: true + }, + context.sellerSession.accessToken + ) + ensureExpectedStatus(carpoolResponse, [200], 'POST', '/api/v1/carpool-listings') + const carpoolId = carpoolResponse.body.data.id + + const carpoolRouteResponse = await suite.api.get( + '/api/v1/carpool-listings/route?from=Borivali&to=BKC&page=0&size=10', + context.buyerSession.accessToken + ) + ensureExpectedStatus(carpoolRouteResponse, [200], 'GET', '/api/v1/carpool-listings/route') + const carpoolIds = (carpoolRouteResponse.body.data?.content || []).map((entry) => entry.id) + expect(carpoolIds).toContain(carpoolId) + + const unauthCreateCategoryResponse = await suite.api.post('/api/v1/marketplace-categories', { + categoryName: `Unauthorized Category ${suffix}`, + description: 'Should fail without auth' + }) + ensureExpectedStatus(unauthCreateCategoryResponse, [401, 403], 'POST', '/api/v1/marketplace-categories') + }) +})