implement driver license date validation
This commit is contained in:
@@ -44,6 +44,10 @@ const nextConfig = {
|
|||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${apiOrigin}/api/:path*`,
|
destination: `${apiOrigin}/api/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/storage/:path*',
|
||||||
|
destination: `${apiOrigin}/storage/:path*`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import marketplaceRouter from './modules/marketplace/marketplace.routes'
|
|||||||
import siteRouter from './modules/site/site.routes'
|
import siteRouter from './modules/site/site.routes'
|
||||||
import reviewsRouter from './modules/reviews/review.routes'
|
import reviewsRouter from './modules/reviews/review.routes'
|
||||||
import complaintsRouter from './modules/complaints/complaint.routes'
|
import complaintsRouter from './modules/complaints/complaint.routes'
|
||||||
|
import licenseValidationRouter from './modules/licenses/license.validation.routes'
|
||||||
|
|
||||||
// ─── Centralized error handling ───────────────────────────────
|
// ─── Centralized error handling ───────────────────────────────
|
||||||
import { errorMiddleware } from './http/errors/errorMiddleware'
|
import { errorMiddleware } from './http/errors/errorMiddleware'
|
||||||
@@ -170,6 +171,7 @@ export function createApp() {
|
|||||||
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
app.use(`${v1}/payments`, apiLimiter, paymentsRouter)
|
||||||
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
app.use(`${v1}/reviews`, apiLimiter, reviewsRouter)
|
||||||
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
app.use(`${v1}/complaints`, apiLimiter, complaintsRouter)
|
||||||
|
app.use(`${v1}/licenses`, publicLimiter, licenseValidationRouter)
|
||||||
|
|
||||||
// ─── Health / Docs ──────────────────────────────────────────
|
// ─── Health / Docs ──────────────────────────────────────────
|
||||||
app.get(v1, (_req, res) => {
|
app.get(v1, (_req, res) => {
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { parseBody } from '../../http/validate'
|
||||||
|
import { ok } from '../../http/respond'
|
||||||
|
import { AppError } from '../../http/errors'
|
||||||
|
import { validateLicenseSchema } from './license.validation.schemas'
|
||||||
|
import { parseExpirationDate, validateLicenseDate, ErrorCode } from '../../services/licenseValidationService'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/licenses/validate
|
||||||
|
*
|
||||||
|
* Validates a driver's license expiration date against the minimum validity rule.
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* { expiration_date: string, license_number?: string, state?: string }
|
||||||
|
*
|
||||||
|
* Success (200): license is valid (≥30 days remaining)
|
||||||
|
* Error (400): invalid format, missing date, future date
|
||||||
|
* Error (422): expired or expiring soon (<30 days remaining)
|
||||||
|
*/
|
||||||
|
router.post('/validate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { expiration_date } = parseBody(validateLicenseSchema, req)
|
||||||
|
const parsedDate = parseExpirationDate(expiration_date)
|
||||||
|
|
||||||
|
if (!parsedDate) {
|
||||||
|
throw new AppError(
|
||||||
|
`Unable to parse date "${expiration_date}". Please use YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY, or DD-MMM-YYYY format.`,
|
||||||
|
400,
|
||||||
|
ErrorCode.LICENSE_INVALID_FORMAT,
|
||||||
|
{
|
||||||
|
details: {
|
||||||
|
expiration_date,
|
||||||
|
suggested_action: 'Please provide the date in a supported format (e.g., YYYY-MM-DD).',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = validateLicenseDate(parsedDate)
|
||||||
|
|
||||||
|
if (!result.valid) {
|
||||||
|
// Map error codes to HTTP status
|
||||||
|
const statusCode =
|
||||||
|
result.error_code === ErrorCode.LICENSE_EXPIRED || result.error_code === ErrorCode.LICENSE_EXPIRING_SOON
|
||||||
|
? 422
|
||||||
|
: 400
|
||||||
|
|
||||||
|
throw new AppError(result.message, statusCode, result.error_code!, {
|
||||||
|
details: result.details,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ok(res, {
|
||||||
|
valid: true,
|
||||||
|
days_remaining: result.days_remaining,
|
||||||
|
expiration_date: result.expiration_date,
|
||||||
|
message: result.message,
|
||||||
|
next_reminder: result.days_remaining
|
||||||
|
? new Date(Date.now() + (result.days_remaining - 30) * 86400000).toISOString().split('T')[0]
|
||||||
|
: null,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
next(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
/** Request body for POST /api/v1/licenses/validate */
|
||||||
|
export const validateLicenseSchema = z.object({
|
||||||
|
expiration_date: z.string().min(1, 'expiration_date is required'),
|
||||||
|
license_number: z.string().max(100).optional(),
|
||||||
|
state: z.string().length(2).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type ValidateLicenseInput = z.infer<typeof validateLicenseSchema>
|
||||||
@@ -12,19 +12,29 @@ vi.mock('../lib/prisma', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { validateAndFlagLicense, validateLicense } from './licenseValidationService'
|
import {
|
||||||
|
validateAndFlagLicense,
|
||||||
|
validateLicense,
|
||||||
|
validateLicenseDate,
|
||||||
|
parseExpirationDate,
|
||||||
|
ErrorCode,
|
||||||
|
} from './licenseValidationService'
|
||||||
|
|
||||||
|
const NOW = new Date('2026-06-09T12:00:00.000Z')
|
||||||
|
|
||||||
describe('licenseValidationService', () => {
|
describe('licenseValidationService', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.setSystemTime(new Date('2026-06-09T12:00:00.000Z'))
|
vi.setSystemTime(NOW)
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── validateLicense (legacy) ────────────────────────────────────────
|
||||||
|
|
||||||
it('treats missing license expiry as valid but explicitly records that no date exists', () => {
|
it('treats missing license expiry as valid but explicitly records that no date exists', () => {
|
||||||
expect(validateLicense(null)).toEqual({
|
expect(validateLicense(null)).toEqual({
|
||||||
status: 'VALID',
|
status: 'VALID',
|
||||||
@@ -34,13 +44,22 @@ describe('licenseValidationService', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires approval for licenses expiring inside the three-month risk window', () => {
|
it('requires approval for licenses expiring inside the 30-day risk window', () => {
|
||||||
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
|
const result = validateLicense(new Date('2026-07-04T12:00:00.000Z'))
|
||||||
|
|
||||||
expect(result.status).toBe('EXPIRING')
|
expect(result.status).toBe('EXPIRING')
|
||||||
expect(result.daysUntilExpiry).toBe(30)
|
expect(result.daysUntilExpiry).toBe(25)
|
||||||
expect(result.requiresApproval).toBe(true)
|
expect(result.requiresApproval).toBe(true)
|
||||||
expect(result.message).toContain('License expires in 30 day(s)')
|
expect(result.message).toContain('License expires in 25 day(s)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks exactly 30 days as valid (not expiring)', () => {
|
||||||
|
// 2026-07-09 is exactly 30 days from 2026-06-09
|
||||||
|
const result = validateLicense(new Date('2026-07-09T12:00:00.000Z'))
|
||||||
|
|
||||||
|
expect(result.status).toBe('VALID')
|
||||||
|
expect(result.daysUntilExpiry).toBe(30)
|
||||||
|
expect(result.requiresApproval).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('requires approval for already expired licenses and reports negative days until expiry', () => {
|
it('requires approval for already expired licenses and reports negative days until expiry', () => {
|
||||||
@@ -73,4 +92,108 @@ describe('licenseValidationService', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ─── validateLicenseDate (new public API) ────────────────────────────
|
||||||
|
|
||||||
|
it('rejects missing date', () => {
|
||||||
|
const result = validateLicenseDate(null)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects undefined date', () => {
|
||||||
|
const result = validateLicenseDate(undefined)
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error_code).toBe(ErrorCode.LICENSE_MISSING_DATE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects already expired license', () => {
|
||||||
|
const result = validateLicenseDate(new Date('2026-06-04T12:00:00.000Z'))
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRED)
|
||||||
|
expect(result.days_remaining).toBe(-5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects license expiring in less than 30 days', () => {
|
||||||
|
const result = validateLicenseDate(new Date('2026-06-20T12:00:00.000Z'))
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error_code).toBe(ErrorCode.LICENSE_EXPIRING_SOON)
|
||||||
|
expect(result.days_remaining).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts license with exactly 30 days remaining', () => {
|
||||||
|
const result = validateLicenseDate(new Date('2026-07-09T12:00:00.000Z'))
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.days_remaining).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts license with more than 30 days remaining', () => {
|
||||||
|
const result = validateLicenseDate(new Date('2027-06-09T12:00:00.000Z'))
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
expect(result.days_remaining).toBe(365)
|
||||||
|
expect(result.message).toBe('License verified successfully')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects future date more than 20 years from now', () => {
|
||||||
|
const result = validateLicenseDate(new Date('2050-06-09T12:00:00.000Z'))
|
||||||
|
expect(result.valid).toBe(false)
|
||||||
|
expect(result.error_code).toBe(ErrorCode.LICENSE_FUTURE_DATE)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('handles leap year date correctly', () => {
|
||||||
|
const result = validateLicenseDate(new Date('2028-02-29T12:00:00.000Z'))
|
||||||
|
expect(result.valid).toBe(true)
|
||||||
|
// 2028-02-29 minus 2026-06-09 = should be > 30 days
|
||||||
|
expect(result.days_remaining).toBeGreaterThan(600)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── parseExpirationDate ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('parses ISO 8601 date (YYYY-MM-DD)', () => {
|
||||||
|
const d = parseExpirationDate('2027-06-11')
|
||||||
|
expect(d).toBeInstanceOf(Date)
|
||||||
|
expect(d!.getUTCFullYear()).toBe(2027)
|
||||||
|
expect(d!.getUTCMonth()).toBe(5) // June
|
||||||
|
expect(d!.getUTCDate()).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses US format (MM/DD/YYYY)', () => {
|
||||||
|
const d = parseExpirationDate('06/11/2027')
|
||||||
|
expect(d).toBeInstanceOf(Date)
|
||||||
|
expect(d!.getFullYear()).toBe(2027)
|
||||||
|
expect(d!.getMonth()).toBe(5) // June
|
||||||
|
expect(d!.getDate()).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses DD/MM/YYYY when first part > 12', () => {
|
||||||
|
const d = parseExpirationDate('13/06/2027')
|
||||||
|
expect(d).toBeInstanceOf(Date)
|
||||||
|
expect(d!.getFullYear()).toBe(2027)
|
||||||
|
expect(d!.getMonth()).toBe(5) // June
|
||||||
|
expect(d!.getDate()).toBe(13)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses DD-MMM-YYYY (military format)', () => {
|
||||||
|
const d = parseExpirationDate('11-JUN-2027')
|
||||||
|
expect(d).toBeInstanceOf(Date)
|
||||||
|
expect(d!.getFullYear()).toBe(2027)
|
||||||
|
expect(d!.getMonth()).toBe(5) // June
|
||||||
|
expect(d!.getDate()).toBe(11)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for completely invalid date string', () => {
|
||||||
|
expect(parseExpirationDate('not-a-date')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for empty string', () => {
|
||||||
|
expect(parseExpirationDate('')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for null input', () => {
|
||||||
|
expect(parseExpirationDate(null)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns null for undefined input', () => {
|
||||||
|
expect(parseExpirationDate(undefined)).toBeNull()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { LicenseStatus } from '@rentaldrivego/database'
|
import { LicenseStatus } from '@rentaldrivego/database'
|
||||||
|
|
||||||
const THREE_MONTHS_MS = 3 * 30 * 24 * 60 * 60 * 1000
|
const MS_PER_DAY = 1000 * 60 * 60 * 24
|
||||||
|
|
||||||
|
/** Minimum days of remaining validity required (configurable via env, default 30). */
|
||||||
|
function getMinValidityDays(): number {
|
||||||
|
const raw = process.env.LICENSE_MIN_VALIDITY_DAYS
|
||||||
|
if (raw) {
|
||||||
|
const n = Number(raw)
|
||||||
|
if (Number.isFinite(n) && n > 0) return n
|
||||||
|
}
|
||||||
|
return 30
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum years in the future considered reasonable for a license expiration date. */
|
||||||
|
const MAX_FUTURE_YEARS = 20
|
||||||
|
|
||||||
|
// ── Error codes matching doc/error-code reference ────────────────────────
|
||||||
|
export const ErrorCode = {
|
||||||
|
LICENSE_EXPIRED: 'LICENSE_EXPIRED',
|
||||||
|
LICENSE_EXPIRING_SOON: 'LICENSE_EXPIRING_SOON',
|
||||||
|
LICENSE_INVALID_FORMAT:'LICENSE_INVALID_FORMAT',
|
||||||
|
LICENSE_FUTURE_DATE: 'LICENSE_FUTURE_DATE',
|
||||||
|
LICENSE_MISSING_DATE: 'LICENSE_MISSING_DATE',
|
||||||
|
} as const
|
||||||
|
|
||||||
export interface LicenseValidationResult {
|
export interface LicenseValidationResult {
|
||||||
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
|
status: 'VALID' | 'EXPIRING' | 'EXPIRED'
|
||||||
@@ -10,19 +32,174 @@ export interface LicenseValidationResult {
|
|||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DetailedValidationResult {
|
||||||
|
valid: boolean
|
||||||
|
days_remaining: number | null
|
||||||
|
expiration_date: string | null
|
||||||
|
message: string
|
||||||
|
error_code?: string
|
||||||
|
details?: {
|
||||||
|
expiration_date?: string
|
||||||
|
days_remaining?: number
|
||||||
|
minimum_required?: number
|
||||||
|
suggested_action?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an expiration date string into a Date.
|
||||||
|
* Returns null if the string is not a valid date per ISO 8601 or common
|
||||||
|
* US/EU formats (YYYY-MM-DD, MM/DD/YYYY, DD/MM/YYYY).
|
||||||
|
*/
|
||||||
|
export function parseExpirationDate(raw: string | null | undefined): Date | null {
|
||||||
|
if (!raw) return null
|
||||||
|
|
||||||
|
// Try ISO 8601 first (YYYY-MM-DD or full ISO)
|
||||||
|
const iso = new Date(raw)
|
||||||
|
if (!isNaN(iso.getTime()) && raw.length >= 10) {
|
||||||
|
// Ensure we don't accept "2026-06" as valid — need at least YYYY-MM-DD
|
||||||
|
const parts = raw.split('T')[0].split('-')
|
||||||
|
if (parts.length === 3) return iso
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try MM/DD/YYYY and DD/MM/YYYY
|
||||||
|
const slashMatch = raw.match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/)
|
||||||
|
if (slashMatch) {
|
||||||
|
// Prefer US order (MM/DD/YYYY) as default; document says "varies by state"
|
||||||
|
const m1 = Number(slashMatch[1])
|
||||||
|
const m2 = Number(slashMatch[2])
|
||||||
|
const year = Number(slashMatch[3])
|
||||||
|
// If first part > 12, it's likely DD/MM/YYYY
|
||||||
|
if (m1 > 12) {
|
||||||
|
const d = new Date(year, m2 - 1, m1)
|
||||||
|
if (!isNaN(d.getTime())) return d
|
||||||
|
}
|
||||||
|
const d = new Date(year, m1 - 1, m2)
|
||||||
|
if (!isNaN(d.getTime())) return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try DD-MMM-YYYY (e.g., 11-JUN-2027)
|
||||||
|
const mmmMatch = raw.match(/^(\d{1,2})-([A-Za-z]{3})-(\d{4})$/)
|
||||||
|
if (mmmMatch) {
|
||||||
|
const d = new Date(`${mmmMatch[2]} ${mmmMatch[1]}, ${mmmMatch[3]}`)
|
||||||
|
if (!isNaN(d.getTime())) return d
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure validation logic — no side effects, no DB calls.
|
||||||
|
*
|
||||||
|
* Returns a DetailedValidationResult suitable for the public API.
|
||||||
|
*/
|
||||||
|
export function validateLicenseDate(expirationDate: Date | null | undefined): DetailedValidationResult {
|
||||||
|
if (!expirationDate) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
days_remaining: null,
|
||||||
|
expiration_date: null,
|
||||||
|
message: 'No expiration date provided. Please provide a valid driver\'s license expiration date.',
|
||||||
|
error_code: ErrorCode.LICENSE_MISSING_DATE,
|
||||||
|
details: {
|
||||||
|
suggested_action: 'Please provide the expiration date from your driver\'s license.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const nowMs = now.getTime()
|
||||||
|
const expMs = expirationDate.getTime()
|
||||||
|
const daysRemaining = Math.ceil((expMs - nowMs) / MS_PER_DAY)
|
||||||
|
const expDateStr = expirationDate.toISOString().split('T')[0]
|
||||||
|
const minValidityDays = getMinValidityDays()
|
||||||
|
|
||||||
|
// Expired
|
||||||
|
if (expMs <= nowMs) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
message: `License has expired. Your license expired on ${expDateStr}.`,
|
||||||
|
error_code: ErrorCode.LICENSE_EXPIRED,
|
||||||
|
details: {
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
minimum_required: minValidityDays,
|
||||||
|
suggested_action: 'Please renew your driver\'s license and upload the updated document.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expiring soon (1 – minValidityDays-1 days remaining)
|
||||||
|
if (daysRemaining < minValidityDays) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
message: `License must have at least ${minValidityDays} days remaining validity. Your license expires in ${daysRemaining} days on ${expDateStr}.`,
|
||||||
|
error_code: ErrorCode.LICENSE_EXPIRING_SOON,
|
||||||
|
details: {
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
minimum_required: minValidityDays,
|
||||||
|
suggested_action: `Please upload a valid license with more than ${minValidityDays} days remaining.`,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future date (>MAX_FUTURE_YEARS from now)
|
||||||
|
const maxFutureMs = nowMs + MAX_FUTURE_YEARS * 365 * MS_PER_DAY
|
||||||
|
if (expMs > maxFutureMs) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
message: `The provided expiration date (${expDateStr}) is too far in the future. Please verify the date is correct.`,
|
||||||
|
error_code: ErrorCode.LICENSE_FUTURE_DATE,
|
||||||
|
details: {
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
minimum_required: minValidityDays,
|
||||||
|
suggested_action: 'Please check the expiration date on your license and re-enter it correctly.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
const nextReminder = new Date(expMs - minValidityDays * MS_PER_DAY).toISOString().split('T')[0]
|
||||||
|
return {
|
||||||
|
valid: true,
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
expiration_date: expDateStr,
|
||||||
|
message: 'License verified successfully',
|
||||||
|
details: {
|
||||||
|
days_remaining: daysRemaining,
|
||||||
|
minimum_required: minValidityDays,
|
||||||
|
suggested_action: 'License is valid.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy validation function used internally by the customer service.
|
||||||
|
* Returns a simpler result compatible with existing callers.
|
||||||
|
*/
|
||||||
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
|
export function validateLicense(licenseExpiry: Date | null): LicenseValidationResult {
|
||||||
if (!licenseExpiry) {
|
if (!licenseExpiry) {
|
||||||
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
|
return { status: 'VALID', daysUntilExpiry: null, requiresApproval: false, message: 'No expiry date on record' }
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
|
const daysUntilExpiry = Math.ceil((licenseExpiry.getTime() - now.getTime()) / MS_PER_DAY)
|
||||||
|
const minValidityDays = getMinValidityDays()
|
||||||
|
const minValidityMs = minValidityDays * MS_PER_DAY
|
||||||
|
|
||||||
if (licenseExpiry <= now) {
|
if (licenseExpiry <= now) {
|
||||||
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
|
return { status: 'EXPIRED', daysUntilExpiry, requiresApproval: true, message: `License expired ${Math.abs(daysUntilExpiry)} day(s) ago` }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (licenseExpiry.getTime() - now.getTime() < THREE_MONTHS_MS) {
|
if (licenseExpiry.getTime() - now.getTime() < minValidityMs) {
|
||||||
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
|
return { status: 'EXPIRING', daysUntilExpiry, requiresApproval: true, message: `License expires in ${daysUntilExpiry} day(s) — approval required` }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
vi.mock('../../lib/prisma', () => ({ prisma: {} }))
|
||||||
|
vi.mock('../../lib/redis', () => ({
|
||||||
|
redis: { on: vi.fn(), get: vi.fn(), set: vi.fn(), del: vi.fn(), quit: vi.fn() },
|
||||||
|
}))
|
||||||
|
|
||||||
|
import request from 'supertest'
|
||||||
|
import { createApp } from '../../app'
|
||||||
|
|
||||||
|
const app = createApp()
|
||||||
|
|
||||||
|
/** Return today's date as YYYY-MM-DD */
|
||||||
|
function today(): string {
|
||||||
|
return new Date().toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return a date offset by `days` from today, as YYYY-MM-DD */
|
||||||
|
function offsetDate(days: number): string {
|
||||||
|
const d = new Date()
|
||||||
|
d.setDate(d.getDate() + days)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('License Validation API — POST /api/v1/licenses/validate', () => {
|
||||||
|
// ─── Success scenarios ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('accepts a license expiring in 365 days (valid)', async () => {
|
||||||
|
const future = offsetDate(365)
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: future })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data).toMatchObject({
|
||||||
|
valid: true,
|
||||||
|
message: 'License verified successfully',
|
||||||
|
})
|
||||||
|
expect(res.body.data.days_remaining).toBeGreaterThanOrEqual(365)
|
||||||
|
expect(res.body.data.next_reminder).toBeDefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts a license expiring in exactly 30 days', async () => {
|
||||||
|
const future = offsetDate(30)
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: future })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.valid).toBe(true)
|
||||||
|
expect(res.body.data.days_remaining).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Rejection scenarios ────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('rejects a license expiring in 15 days with 422', async () => {
|
||||||
|
const nearFuture = offsetDate(15)
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: nearFuture })
|
||||||
|
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(res.body.error).toBe('LICENSE_EXPIRING_SOON')
|
||||||
|
expect(res.body.message).toContain('30 days')
|
||||||
|
expect(res.body.details).toBeDefined()
|
||||||
|
expect(res.body.details.days_remaining).toBe(15)
|
||||||
|
expect(res.body.details.minimum_required).toBe(30)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an expired license with 422', async () => {
|
||||||
|
const past = offsetDate(-5)
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: past })
|
||||||
|
|
||||||
|
expect(res.status).toBe(422)
|
||||||
|
expect(res.body.error).toBe('LICENSE_EXPIRED')
|
||||||
|
expect(res.body.message).toContain('expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an invalid date format with 400', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: 'not-a-date' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toBe('LICENSE_INVALID_FORMAT')
|
||||||
|
expect(res.body.message).toContain('Unable to parse')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects an empty expiration_date with 400', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: '' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toBe('validation_error')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects a date too far in the future with 400', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: '2055-01-01' })
|
||||||
|
|
||||||
|
expect(res.status).toBe(400)
|
||||||
|
expect(res.body.error).toBe('LICENSE_FUTURE_DATE')
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Multiple date formats ──────────────────────────────────────────
|
||||||
|
|
||||||
|
it('parses MM/DD/YYYY format', async () => {
|
||||||
|
const future = new Date()
|
||||||
|
future.setFullYear(future.getFullYear() + 1)
|
||||||
|
const mm = String(future.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(future.getDate()).padStart(2, '0')
|
||||||
|
const yyyy = future.getFullYear()
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: `${mm}/${dd}/${yyyy}` })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.valid).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('parses DD-MMM-YYYY format', async () => {
|
||||||
|
const future = new Date()
|
||||||
|
future.setFullYear(future.getFullYear() + 1)
|
||||||
|
const dd = String(future.getDate()).padStart(2, '0')
|
||||||
|
const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
|
||||||
|
const mmm = months[future.getMonth()]
|
||||||
|
const yyyy = future.getFullYear()
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({ expiration_date: `${dd}-${mmm}-${yyyy}` })
|
||||||
|
|
||||||
|
expect(res.status).toBe(200)
|
||||||
|
expect(res.body.data.valid).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -61,6 +61,10 @@ const nextConfig = {
|
|||||||
source: '/api/:path*',
|
source: '/api/:path*',
|
||||||
destination: `${apiOrigin}/api/:path*`,
|
destination: `${apiOrigin}/api/:path*`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
source: '/storage/:path*',
|
||||||
|
destination: `${apiOrigin}/storage/:path*`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ function buildSecurityHeaders({ assetSources = [], connectSources = collectBrows
|
|||||||
scriptSrc.push(...assetOrigins)
|
scriptSrc.push(...assetOrigins)
|
||||||
|
|
||||||
const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins]
|
const styleSrc = ["'self'", "'unsafe-inline'", ...assetOrigins]
|
||||||
const imgSrc = ["'self'", 'data:', 'blob:', 'https:', ...assetOrigins]
|
const imgSrc = [...new Set(["'self'", 'data:', 'blob:', 'https:', ...assetOrigins, ...connectOrigins])]
|
||||||
const fontSrc = ["'self'", 'data:', ...assetOrigins]
|
const fontSrc = ["'self'", 'data:', ...assetOrigins]
|
||||||
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
|
const connectSrc = [...new Set(["'self'", 'https:', 'wss:', ...assetOrigins, ...connectOrigins, ...websocketOrigins])]
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
Driver's License Expiration Validation Implementation Plan
|
||||||
|
Document Version: 1.0
|
||||||
|
Date: 2026-06-11
|
||||||
|
Status: Draft
|
||||||
|
Author: Engineering Team
|
||||||
|
|
||||||
|
Table of Contents
|
||||||
|
Overview
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
|
||||||
|
Architecture Design
|
||||||
|
|
||||||
|
Implementation Phases
|
||||||
|
|
||||||
|
Technical Specifications
|
||||||
|
|
||||||
|
Testing Strategy
|
||||||
|
|
||||||
|
Deployment Plan
|
||||||
|
|
||||||
|
Monitoring & Maintenance
|
||||||
|
|
||||||
|
Risk Assessment
|
||||||
|
|
||||||
|
Appendices
|
||||||
|
|
||||||
|
Overview
|
||||||
|
Business Objective
|
||||||
|
Enforce a mandatory validation rule requiring all renters to possess a driver's license with minimum 30-day remaining validity from the date of submission.
|
||||||
|
|
||||||
|
Scope
|
||||||
|
In Scope: Frontend validation, backend API validation, database schema changes, user notifications, error handling
|
||||||
|
|
||||||
|
Out of Scope: Physical license verification, DMV database integration, international license translation services
|
||||||
|
|
||||||
|
Success Criteria
|
||||||
|
100% of license submissions validated against 30-day rule
|
||||||
|
|
||||||
|
<1% false rejection rate
|
||||||
|
|
||||||
|
<2 second validation response time
|
||||||
|
|
||||||
|
99.9% API uptime for validation endpoint
|
||||||
|
|
||||||
|
Requirements
|
||||||
|
Functional Requirements
|
||||||
|
ID Requirement Priority
|
||||||
|
FR-1 System shall validate license expiration date is ≥30 days from current date P0
|
||||||
|
FR-2 System shall reject submissions with <30 days remaining validity P0
|
||||||
|
FR-3 System shall display clear error messages to users P0
|
||||||
|
FR-4 System shall support manual date entry and OCR scanning P1
|
||||||
|
FR-5 System shall log all validation attempts with results P1
|
||||||
|
FR-6 System shall send reminders at 60, 45, and 30 days before expiration P2
|
||||||
|
FR-7 System shall support state-specific license formats P2
|
||||||
|
Non-Functional Requirements
|
||||||
|
ID Requirement Target
|
||||||
|
NFR-1 API response time <500ms p95
|
||||||
|
NFR-2 Validation accuracy >99.9%
|
||||||
|
NFR-3 System availability 99.95%
|
||||||
|
NFR-4 Data encryption at rest AES-256
|
||||||
|
NFR-5 PII data compliance GDPR, CCPA
|
||||||
|
Architecture Design
|
||||||
|
System Diagram
|
||||||
|
text
|
||||||
|
┌─────────────────────────────────────────────────────────────────┐
|
||||||
|
│ CLIENT LAYER │
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
|
||||||
|
│ │ Web App │ │ Mobile App │ │ Admin Dashboard │ │
|
||||||
|
│ │ (React) │ │ (React │ │ (Internal Tool) │ │
|
||||||
|
│ │ │ │ Native) │ │ │ │
|
||||||
|
│ └──────┬───────┘ └──────┬───────┘ └──────────┬───────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
└─────────┼─────────────────┼──────────────────────┼──────────────┘
|
||||||
|
│ │ │
|
||||||
|
┌─────────┼─────────────────┼──────────────────────┼──────────────┐
|
||||||
|
│ │ API GATEWAY LAYER │ │
|
||||||
|
│ ┌──────┴─────────────────┴──────────────────────┴───────────┐ │
|
||||||
|
│ │ Kong / AWS API Gateway │ │
|
||||||
|
│ │ (Rate Limiting, Authentication, Routing) │ │
|
||||||
|
│ └──────────────────────────┬────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌────────────────────┼────────────────────┐ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────┴──────┐ ┌────────┴────────┐ ┌──────┴──────────┐ │
|
||||||
|
│ │ License │ │ Renter │ │ Notification │ │
|
||||||
|
│ │ Validation │ │ Service │ │ Service │ │
|
||||||
|
│ │ Service │ │ │ │ │ │
|
||||||
|
│ └──────┬──────┘ └────────┬────────┘ └──────┬──────────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ └────────────────────┼────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ DATA LAYER│ │
|
||||||
|
│ ┌───────────────────────────┴──────────────────────────────┐ │
|
||||||
|
│ │ PostgreSQL (Primary DB) │ │
|
||||||
|
│ └───────────────────────────┬──────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ ┌───────────────────────────┴──────────────────────────────┐ │
|
||||||
|
│ │ Redis (Cache + Rate Limiting) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
└──────────────────────────────────────────────────────────────────┘
|
||||||
|
Data Flow Sequence
|
||||||
|
Implementation Phases
|
||||||
|
Phase 1: Foundation (Week 1-2)
|
||||||
|
Week 1: Setup & Core Logic
|
||||||
|
Day 1-2: Project setup and repository configuration
|
||||||
|
|
||||||
|
Initialize Git repository with branch protection rules
|
||||||
|
|
||||||
|
Set up CI/CD pipeline (GitHub Actions/Jenkins)
|
||||||
|
|
||||||
|
Configure development, staging, and production environments
|
||||||
|
|
||||||
|
Day 3-4: Database schema design
|
||||||
|
|
||||||
|
sql
|
||||||
|
-- Migration: Create license_validations table
|
||||||
|
CREATE TABLE license_validations (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
renter_id UUID NOT NULL REFERENCES renters(id),
|
||||||
|
license_number_encrypted TEXT NOT NULL,
|
||||||
|
expiration_date DATE NOT NULL,
|
||||||
|
issuing_state VARCHAR(2),
|
||||||
|
validation_status VARCHAR(20) NOT NULL,
|
||||||
|
days_remaining INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
validated_by VARCHAR(50) -- 'SYSTEM' or 'MANUAL'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_license_expiration ON license_validations(expiration_date);
|
||||||
|
CREATE INDEX idx_license_status ON license_validations(validation_status);
|
||||||
|
Day 5: Core validation service implementation
|
||||||
|
|
||||||
|
Build LicenseValidator class with business logic
|
||||||
|
|
||||||
|
Implement date parsing with multiple format support
|
||||||
|
|
||||||
|
Add unit tests for all validation scenarios
|
||||||
|
|
||||||
|
Week 2: API & Frontend
|
||||||
|
Day 1-3: REST API endpoint development
|
||||||
|
|
||||||
|
yaml
|
||||||
|
# OpenAPI Specification
|
||||||
|
/api/v1/licenses/validate:
|
||||||
|
post:
|
||||||
|
summary: Validate driver's license expiration
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- expiration_date
|
||||||
|
properties:
|
||||||
|
expiration_date:
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
example: "2027-06-11"
|
||||||
|
license_number:
|
||||||
|
type: string
|
||||||
|
state:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: License is valid
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
valid:
|
||||||
|
type: boolean
|
||||||
|
days_remaining:
|
||||||
|
type: integer
|
||||||
|
message:
|
||||||
|
type: string
|
||||||
|
'422':
|
||||||
|
description: License validation failed
|
||||||
|
Day 4-5: Frontend component development
|
||||||
|
|
||||||
|
Build LicenseUpload component with date picker
|
||||||
|
|
||||||
|
Implement real-time validation feedback
|
||||||
|
|
||||||
|
Add accessibility features (ARIA labels, keyboard navigation)
|
||||||
|
|
||||||
|
Phase 2: Enhancement (Week 3-4)
|
||||||
|
Week 3: OCR Integration & Advanced Validation
|
||||||
|
Day 1-3: OCR service integration
|
||||||
|
|
||||||
|
python
|
||||||
|
class OCRLicenseProcessor:
|
||||||
|
def __init__(self):
|
||||||
|
self.textract_client = boto3.client('textract')
|
||||||
|
|
||||||
|
async def extract_license_data(self, image_bytes: bytes) -> dict:
|
||||||
|
"""Extract license data using AWS Textract"""
|
||||||
|
response = await self.textract_client.analyze_document(
|
||||||
|
Document={'Bytes': image_bytes},
|
||||||
|
FeatureTypes=['FORMS']
|
||||||
|
)
|
||||||
|
return self._parse_license_fields(response)
|
||||||
|
|
||||||
|
def _parse_license_fields(self, textract_response: dict) -> dict:
|
||||||
|
"""Parse extracted fields into structured data"""
|
||||||
|
# Extract expiration date using regex patterns
|
||||||
|
# Map to standard format YYYY-MM-DD
|
||||||
|
pass
|
||||||
|
Day 4: State-specific validation rules
|
||||||
|
|
||||||
|
Implement validation patterns for all 50 US states
|
||||||
|
|
||||||
|
Handle temporary licenses and permits
|
||||||
|
|
||||||
|
Add military ID and passport support
|
||||||
|
|
||||||
|
Day 5: Error handling and edge cases
|
||||||
|
|
||||||
|
Graceful handling of corrupted images
|
||||||
|
|
||||||
|
Invalid date formats
|
||||||
|
|
||||||
|
Network timeouts and retries
|
||||||
|
|
||||||
|
Week 4: Notifications & Admin Tools
|
||||||
|
Day 1-2: Notification system
|
||||||
|
|
||||||
|
python
|
||||||
|
class LicenseReminderService:
|
||||||
|
REMINDER_SCHEDULE = [60, 45, 30] # Days before expiration
|
||||||
|
|
||||||
|
async def schedule_reminders(self, renter_id: str, expiration_date: date):
|
||||||
|
for days_before in self.REMINDER_SCHEDULE:
|
||||||
|
reminder_date = expiration_date - timedelta(days=days_before)
|
||||||
|
await self.notification_queue.enqueue(
|
||||||
|
'send_license_reminder',
|
||||||
|
renter_id=renter_id,
|
||||||
|
scheduled_at=reminder_date,
|
||||||
|
days_remaining=days_before
|
||||||
|
)
|
||||||
|
Day 3-4: Admin dashboard
|
||||||
|
|
||||||
|
Build validation queue for manual review
|
||||||
|
|
||||||
|
Analytics dashboard (rejection rates, common errors)
|
||||||
|
|
||||||
|
Bulk update and override capabilities
|
||||||
|
|
||||||
|
Day 5: Documentation and API docs
|
||||||
|
|
||||||
|
Update API documentation with Swagger/OpenAPI
|
||||||
|
|
||||||
|
Create internal wiki for support team
|
||||||
|
|
||||||
|
Write runbook for common issues
|
||||||
|
|
||||||
|
Phase 3: Testing & Hardening (Week 5-6)
|
||||||
|
Week 5: Comprehensive Testing
|
||||||
|
Day 1-3: Integration testing
|
||||||
|
|
||||||
|
javascript
|
||||||
|
// Integration test example
|
||||||
|
describe('License Validation API', () => {
|
||||||
|
test('Should reject license expiring in 15 days', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({
|
||||||
|
expiration_date: '2026-06-26',
|
||||||
|
license_number: 'DL123456'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(422);
|
||||||
|
expect(response.body.valid).toBe(false);
|
||||||
|
expect(response.body.message).toContain('30 days');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Should accept license expiring in 60 days', async () => {
|
||||||
|
const response = await request(app)
|
||||||
|
.post('/api/v1/licenses/validate')
|
||||||
|
.send({
|
||||||
|
expiration_date: '2026-08-10',
|
||||||
|
license_number: 'DL789012'
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.body.valid).toBe(true);
|
||||||
|
expect(response.body.days_remaining).toBeGreaterThan(30);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
Day 4: Performance testing
|
||||||
|
|
||||||
|
Load test with 1000 concurrent requests
|
||||||
|
|
||||||
|
Stress test OCR processing pipeline
|
||||||
|
|
||||||
|
Database query optimization
|
||||||
|
|
||||||
|
Day 5: Security audit
|
||||||
|
|
||||||
|
PII encryption verification
|
||||||
|
|
||||||
|
SQL injection and XSS testing
|
||||||
|
|
||||||
|
Authentication/authorization testing
|
||||||
|
|
||||||
|
Week 6: UAT & Deployment Preparation
|
||||||
|
Day 1-2: User acceptance testing
|
||||||
|
|
||||||
|
Test with real license images (anonymized)
|
||||||
|
|
||||||
|
Validate all error messages and flows
|
||||||
|
|
||||||
|
Cross-browser compatibility testing
|
||||||
|
|
||||||
|
Day 3: Staging deployment
|
||||||
|
|
||||||
|
Deploy to staging environment
|
||||||
|
|
||||||
|
Run smoke tests
|
||||||
|
|
||||||
|
Monitor for 24 hours
|
||||||
|
|
||||||
|
Day 4-5: Production deployment
|
||||||
|
|
||||||
|
Database migration execution
|
||||||
|
|
||||||
|
Blue-green deployment strategy
|
||||||
|
|
||||||
|
Gradual traffic shifting (10% → 50% → 100%)
|
||||||
|
|
||||||
|
Technical Specifications
|
||||||
|
Validation Rules Matrix
|
||||||
|
Scenario Condition Action Response Code
|
||||||
|
Valid License ≥30 days remaining Accept 200
|
||||||
|
Warning Zone 30-60 days remaining Accept + Schedule Reminder 200 + Warning
|
||||||
|
Expiring Soon 1-29 days remaining Reject 422
|
||||||
|
Expired ≤0 days remaining Reject 422
|
||||||
|
Invalid Format Date parse error Reject 400
|
||||||
|
Future Date >20 years from now Reject 400
|
||||||
|
Missing Date No date provided Reject 400
|
||||||
|
API Response Examples
|
||||||
|
Success Response
|
||||||
|
json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {
|
||||||
|
"valid": true,
|
||||||
|
"days_remaining": 365,
|
||||||
|
"expiration_date": "2027-06-11",
|
||||||
|
"message": "License verified successfully",
|
||||||
|
"next_reminder": "2027-04-12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Error Response
|
||||||
|
json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"error": {
|
||||||
|
"code": "LICENSE_EXPIRING_SOON",
|
||||||
|
"message": "License must have at least 30 days remaining validity. Your license expires in 15 days on 2026-06-26.",
|
||||||
|
"details": {
|
||||||
|
"expiration_date": "2026-06-26",
|
||||||
|
"days_remaining": 15,
|
||||||
|
"minimum_required": 30,
|
||||||
|
"suggested_action": "Please upload a valid license with more than 30 days remaining."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Environment Variables Configuration
|
||||||
|
bash
|
||||||
|
# .env.example
|
||||||
|
LICENSE_MIN_VALIDITY_DAYS=30
|
||||||
|
LICENSE_WARNING_THRESHOLD_DAYS=60
|
||||||
|
OCR_ENABLED=true
|
||||||
|
OCR_PROVIDER=aws_textract
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
DATABASE_URL=postgresql://user:pass@localhost:5432/rental_db
|
||||||
|
ENCRYPTION_KEY=your-256-bit-key
|
||||||
|
LOG_LEVEL=info
|
||||||
|
Testing Strategy
|
||||||
|
Test Pyramid
|
||||||
|
text
|
||||||
|
┌──────┐
|
||||||
|
│ E2E │ 10% - Critical user journeys
|
||||||
|
│ │
|
||||||
|
┌┴──────┴┐
|
||||||
|
│Integrat│ 30% - API contracts, DB interactions
|
||||||
|
│ ion │
|
||||||
|
│ │
|
||||||
|
┌┴────────┴┐
|
||||||
|
│ Unit │ 60% - Business logic, validation rules
|
||||||
|
│ │
|
||||||
|
└──────────┘
|
||||||
|
Test Cases
|
||||||
|
Unit Tests (60% coverage)
|
||||||
|
Test ID Description Input Expected Output
|
||||||
|
UT-01 Date exactly 30 days from now today + 30 days valid: true
|
||||||
|
UT-02 Date 29 days from now today + 29 days valid: false
|
||||||
|
UT-03 Date in past 2020-01-01 valid: false
|
||||||
|
UT-04 Invalid date string "invalid" Error handled
|
||||||
|
UT-05 Leap year date 2028-02-29 Correctly calculated
|
||||||
|
UT-06 Timezone edge case Various timezones Consistent results
|
||||||
|
Integration Tests
|
||||||
|
API returns correct status codes for all scenarios
|
||||||
|
|
||||||
|
Database correctly stores encrypted license data
|
||||||
|
|
||||||
|
Redis cache properly invalidates
|
||||||
|
|
||||||
|
Notification queue receives correct messages
|
||||||
|
|
||||||
|
OCR pipeline processes images end-to-end
|
||||||
|
|
||||||
|
End-to-End Tests
|
||||||
|
User uploads valid license → Success flow
|
||||||
|
|
||||||
|
User uploads expired license → Error flow
|
||||||
|
|
||||||
|
User receives reminder notification
|
||||||
|
|
||||||
|
Admin can manually override validation
|
||||||
|
|
||||||
|
Deployment Plan
|
||||||
|
Pre-Deployment Checklist
|
||||||
|
All tests passing (unit, integration, E2E)
|
||||||
|
|
||||||
|
Security scan completed (no critical/high vulnerabilities)
|
||||||
|
|
||||||
|
Database migration scripts reviewed and tested
|
||||||
|
|
||||||
|
Rollback plan documented and tested
|
||||||
|
|
||||||
|
Monitoring dashboards configured
|
||||||
|
|
||||||
|
Alert thresholds set up
|
||||||
|
|
||||||
|
Support team trained on new features
|
||||||
|
|
||||||
|
API documentation updated
|
||||||
|
|
||||||
|
Performance benchmarks met
|
||||||
|
|
||||||
|
Data backup completed
|
||||||
|
|
||||||
|
Deployment Steps
|
||||||
|
Phase 1: Database Migration (30 min downtime)
|
||||||
|
bash
|
||||||
|
# 1. Take database snapshot
|
||||||
|
pg_dump -h production-db -U admin rental_db > backup_20260611.sql
|
||||||
|
|
||||||
|
# 2. Run migrations
|
||||||
|
npm run migrate:production
|
||||||
|
|
||||||
|
# 3. Verify migration
|
||||||
|
SELECT * FROM schema_migrations ORDER BY version DESC LIMIT 5;
|
||||||
|
Phase 2: Backend Deployment (Blue-Green)
|
||||||
|
bash
|
||||||
|
# 1. Deploy to green environment
|
||||||
|
kubectl apply -f deployment-green.yaml
|
||||||
|
|
||||||
|
# 2. Run health checks
|
||||||
|
curl https://green-api.example.com/health
|
||||||
|
|
||||||
|
# 3. Switch traffic (10% → 50% → 100%)
|
||||||
|
# Update load balancer configuration
|
||||||
|
|
||||||
|
# 4. Monitor for 15 minutes
|
||||||
|
# Watch logs: kubectl logs -f deployment/license-validation-green
|
||||||
|
Phase 3: Frontend Deployment
|
||||||
|
bash
|
||||||
|
# 1. Build and deploy to CDN
|
||||||
|
npm run build:production
|
||||||
|
aws s3 sync dist/ s3://rental-app-prod/ --cache-control "max-age=3600"
|
||||||
|
|
||||||
|
# 2. Invalidate CloudFront cache
|
||||||
|
aws cloudfront create-invalidation --distribution-id E1234567890 --paths "/*"
|
||||||
|
Rollback Plan
|
||||||
|
bash
|
||||||
|
# Immediate rollback if error rate >5% or p95 latency >2s
|
||||||
|
# 1. Revert load balancer to blue environment
|
||||||
|
# 2. Scale down green deployment
|
||||||
|
kubectl scale deployment license-validation-green --replicas=0
|
||||||
|
|
||||||
|
# 3. Rollback database (if schema changes)
|
||||||
|
npm run migrate:rollback:production
|
||||||
|
|
||||||
|
# 4. Notify on-call engineer
|
||||||
|
# 5. Create incident postmortem
|
||||||
|
Monitoring & Maintenance
|
||||||
|
Key Metrics Dashboard
|
||||||
|
yaml
|
||||||
|
Dashboard: License Validation Service
|
||||||
|
Metrics:
|
||||||
|
- name: license_validation_total
|
||||||
|
type: Counter
|
||||||
|
description: Total validation attempts
|
||||||
|
labels: [status, source]
|
||||||
|
|
||||||
|
- name: license_validation_duration_seconds
|
||||||
|
type: Histogram
|
||||||
|
description: Validation processing time
|
||||||
|
buckets: [0.1, 0.5, 1, 2, 5]
|
||||||
|
|
||||||
|
- name: license_rejection_rate
|
||||||
|
type: Gauge
|
||||||
|
description: Percentage of rejected licenses
|
||||||
|
alert: ">20% for 5 minutes"
|
||||||
|
|
||||||
|
- name: ocr_processing_errors
|
||||||
|
type: Counter
|
||||||
|
description: OCR processing failures
|
||||||
|
alert: ">10 errors in 5 minutes"
|
||||||
|
Alert Configuration
|
||||||
|
yaml
|
||||||
|
alerts:
|
||||||
|
- name: HighRejectionRate
|
||||||
|
condition: license_rejection_rate > 0.20
|
||||||
|
duration: 5m
|
||||||
|
severity: warning
|
||||||
|
channel: "#engineering-alerts"
|
||||||
|
|
||||||
|
- name: ValidationServiceDown
|
||||||
|
condition: up{job="license-validation"} == 0
|
||||||
|
duration: 1m
|
||||||
|
severity: critical
|
||||||
|
channel: "#on-call"
|
||||||
|
|
||||||
|
- name: SlowValidationResponses
|
||||||
|
condition: histogram_quantile(0.95, license_validation_duration) > 2
|
||||||
|
duration: 5m
|
||||||
|
severity: warning
|
||||||
|
channel: "#engineering-alerts"
|
||||||
|
Maintenance Tasks
|
||||||
|
Task Frequency Owner Duration
|
||||||
|
Database index optimization Monthly DBA 1 hour
|
||||||
|
SSL certificate renewal Annually DevOps 30 min
|
||||||
|
Dependency updates Bi-weekly Engineering 2 hours
|
||||||
|
Security patches As needed Security team Varies
|
||||||
|
Log rotation Automated System N/A
|
||||||
|
Risk Assessment
|
||||||
|
Risk Matrix
|
||||||
|
Risk Impact Probability Mitigation
|
||||||
|
OCR misreads date High Medium Manual review fallback, confidence scoring
|
||||||
|
Timezone mismatch Medium Low Store all dates in UTC, convert at presentation layer
|
||||||
|
Database connection failure High Low Connection pooling, retry logic, circuit breaker
|
||||||
|
Third-party OCR service outage Medium Low Implement fallback to manual entry, cache provider
|
||||||
|
PII data breach Critical Low Encryption at rest/transit, access controls, audit logging
|
||||||
|
Performance degradation under load Medium Medium Auto-scaling, caching, load testing
|
||||||
|
Contingency Plans
|
||||||
|
Scenario 1: OCR Service Unavailable
|
||||||
|
|
||||||
|
Automatically switch to manual entry mode
|
||||||
|
|
||||||
|
Display clear instructions to user
|
||||||
|
|
||||||
|
Queue OCR processing for retry when service recovers
|
||||||
|
|
||||||
|
Scenario 2: High False Rejection Rate
|
||||||
|
|
||||||
|
Temporarily adjust threshold to 15 days
|
||||||
|
|
||||||
|
Notify support team to expect inquiries
|
||||||
|
|
||||||
|
Investigate root cause (date parsing, timezone, etc.)
|
||||||
|
|
||||||
|
Deploy hotfix if needed
|
||||||
|
|
||||||
|
Appendices
|
||||||
|
Appendix A: Supported License Formats
|
||||||
|
Country/State Format Example Notes
|
||||||
|
US Standard MM/DD/YYYY 06/11/2027 Varies by state
|
||||||
|
EU Standard DD/MM/YYYY 11/06/2027
|
||||||
|
ISO 8601 YYYY-MM-DD 2027-06-11 Preferred internal format
|
||||||
|
Military ID DD-MMM-YYYY 11-JUN-2027
|
||||||
|
Appendix B: Error Code Reference
|
||||||
|
Code HTTP Status Description
|
||||||
|
LICENSE_EXPIRED 422 License has expired
|
||||||
|
LICENSE_EXPIRING_SOON 422 Less than 30 days remaining
|
||||||
|
LICENSE_INVALID_FORMAT 400 Date format not recognized
|
||||||
|
LICENSE_FUTURE_DATE 400 Date too far in future
|
||||||
|
LICENSE_MISSING_DATE 400 No date provided
|
||||||
|
OCR_PROCESSING_FAILED 500 Could not extract from image
|
||||||
|
LICENSE_ALREADY_EXISTS 409 Duplicate submission
|
||||||
|
Appendix C: Team Contacts
|
||||||
|
Role Name Contact
|
||||||
|
Product Owner [Name] [Email/Phone]
|
||||||
|
Tech Lead [Name] [Email/Phone]
|
||||||
|
Backend Developer [Name] [Email/Phone]
|
||||||
|
Frontend Developer [Name] [Email/Phone]
|
||||||
|
DevOps Engineer [Name] [Email/Phone]
|
||||||
|
Security Engineer [Name] [Email/Phone]
|
||||||
|
On-Call Rotation Schedule [PagerDuty Link]
|
||||||
|
Document Control
|
||||||
|
Version Date Author Changes
|
||||||
|
1.0 2026-06-11 Engineering Team Initial creation
|
||||||
Reference in New Issue
Block a user