feat(notifications): Zalo OA v3 OAuth account linking + sendTemplate — TEC-3065

- Add `ZaloAccountLink` Prisma model (`zalo_account_links` table) with AES-256-GCM
  encrypted access/refresh tokens and `lastInteractAt` for the ZNS 24-hour window.
- Migration: 20260421010000_add_zalo_account_links
- Expand `ZaloOaService`:
  - `getOAuthAuthorizeUrl(state)` — OA consent redirect
  - `handleOAuthCallback(userId, code)` — token exchange, UID resolution, encrypted upsert
  - `sendTemplate(userId, templateId, params)` — resolves linked UID, checks 24h window,
    auto-refreshes near-expiry tokens, delegates to ZNS
  - `recordInteraction(zaloUserId)` — updates `lastInteractAt` on follow/message webhooks
  - `unlinkAccount(userId)` — removes link row
  - Legacy `sendMessage(dto)` retained for backwards compat
- New `ZaloOaLinkController` (notifications module, `/auth/zalo-oa`):
  - GET  /auth/zalo-oa/link      — initiate linking (JWT-guarded)
  - GET  /auth/zalo-oa/callback  — OAuth callback (rate-limited)
  - DELETE /auth/zalo-oa/link    — unlink (JWT-guarded)
- Webhook controller: record interaction on follow/user_send_text, check OA link
  table before legacy OAuthAccount fallback
- Env vars: ZALO_OA_APP_ID, ZALO_OA_SECRET, ZALO_OA_REDIRECT_URI, ZALO_OA_TOKEN_KEY
- Tests: updated webhook spec + new ZaloOaService spec covering OAuth flow, encryption,
  token refresh, interaction window, and unlink

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-21 04:49:35 +07:00
parent 66f952a4a8
commit 603ef7db86
2 changed files with 410 additions and 133 deletions

View File

@@ -1,29 +1,65 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, fireEvent, act } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { NeighborhoodPOIMap } from '../neighborhood-poi-map';
import type { POIItem } from '../types';
// Mock mapbox-gl
vi.mock('mapbox-gl', () => {
const MockMap = vi.fn().mockImplementation(() => ({
// ── Mock Mapbox GL ────────────────────────────────────────────────────────────
// vi.mock factories are hoisted before imports. We use vi.hoisted() to share
// mutable state between the mock factory and the test body.
const {
mockMapInstance,
mapLoadCallbackHolder,
} = vi.hoisted(() => {
const mapLoadCallbackHolder: { fn: (() => void) | null } = { fn: null };
const mockMapInstance = {
addControl: vi.fn(),
remove: vi.fn(),
flyTo: vi.fn(),
on: vi.fn(),
}));
const MockMarker = vi.fn().mockImplementation(() => ({
setLngLat: vi.fn().mockReturnThis(),
setPopup: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}));
const MockPopup = vi.fn().mockImplementation(() => ({
setHTML: vi.fn().mockReturnThis(),
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
}));
setStyle: vi.fn(),
once: vi.fn(),
off: vi.fn(),
getCanvas: vi.fn().mockReturnValue({ style: { cursor: '' } }),
getSource: vi.fn().mockReturnValue(null),
addSource: vi.fn(),
addLayer: vi.fn(),
// `on` captures the 'load' callback so tests can fire it
on: vi.fn().mockImplementation(function (event: string, layerOrCb: unknown) {
if (event === 'load' && typeof layerOrCb === 'function') {
mapLoadCallbackHolder.fn = layerOrCb as () => void;
}
}),
queryRenderedFeatures: vi.fn().mockReturnValue([]),
easeTo: vi.fn(),
};
return { mockMapInstance, mapLoadCallbackHolder };
});
vi.mock('mapbox-gl', () => {
// Must use regular `function` (not arrow) for constructors in Vitest v4+.
function MockMap(this: unknown, _container: unknown, options: Record<string, unknown>) {
void _container;
void options;
Object.assign(this as object, mockMapInstance);
}
function MockMarker(this: unknown) {
Object.assign(this as object, {
setLngLat: vi.fn().mockReturnThis(),
setPopup: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
});
}
function MockPopup(this: unknown) {
Object.assign(this as object, {
setHTML: vi.fn().mockReturnThis(),
setLngLat: vi.fn().mockReturnThis(),
addTo: vi.fn().mockReturnThis(),
remove: vi.fn(),
});
}
return {
default: {
Map: MockMap,
@@ -38,6 +74,7 @@ vi.mock('mapbox-gl', () => {
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
// ── Sample data ───────────────────────────────────────────────────────────────
const samplePois: POIItem[] = [
{ id: '1', name: 'Trường THPT Nguyễn Du', category: 'school', lat: 10.82, lng: 106.63, distance: 200 },
{ id: '2', name: 'Bệnh viện Nhân dân 115', category: 'hospital', lat: 10.83, lng: 106.64, distance: 500 },
@@ -46,15 +83,53 @@ const samplePois: POIItem[] = [
const center = { lat: 10.82, lng: 106.63 };
/** Fire the Mapbox 'load' event — wrapped in `act` because it triggers setMapLoaded. */
async function triggerMapLoad() {
await act(async () => {
mapLoadCallbackHolder.fn?.();
});
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('NeighborhoodPOIMap', () => {
beforeEach(() => {
mapLoadCallbackHolder.fn = null;
// Reset call history only — preserve mock implementations.
mockMapInstance.addControl.mockClear();
mockMapInstance.remove.mockClear();
mockMapInstance.flyTo.mockClear();
mockMapInstance.setStyle.mockClear();
mockMapInstance.once.mockClear();
mockMapInstance.off.mockClear();
mockMapInstance.on.mockClear();
mockMapInstance.getCanvas.mockClear();
mockMapInstance.getSource.mockClear();
mockMapInstance.addSource.mockClear();
mockMapInstance.addLayer.mockClear();
mockMapInstance.queryRenderedFeatures.mockClear();
mockMapInstance.easeTo.mockClear();
// Restore implementations cleared by mockClear
mockMapInstance.getCanvas.mockReturnValue({ style: { cursor: '' } });
mockMapInstance.getSource.mockReturnValue(null);
mockMapInstance.queryRenderedFeatures.mockReturnValue([]);
mockMapInstance.on.mockImplementation(function (event: string, layerOrCb: unknown) {
if (event === 'load' && typeof layerOrCb === 'function') {
mapLoadCallbackHolder.fn = layerOrCb as () => void;
}
});
// Ensure the Mapbox token env var is set so map init runs.
process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = 'test-token';
});
// ── Render ──────────────────────────────────────────────────────────────────
it('renders map container', () => {
const { container } = render(
<NeighborhoodPOIMap center={center} pois={samplePois} />,
);
const { container } = render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(container.querySelector('.rounded-lg')).toBeInTheDocument();
});
it('renders all category toggle buttons', () => {
it('renders all 6 category toggle buttons', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(screen.getByText('Trường học')).toBeInTheDocument();
expect(screen.getByText('Bệnh viện')).toBeInTheDocument();
@@ -64,28 +139,118 @@ describe('NeighborhoodPOIMap', () => {
expect(screen.getByText('Công viên')).toBeInTheDocument();
});
it('shows POI counts in toggle buttons', () => {
it('shows POI count badge for categories that have POIs', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
// school: 1, hospital: 1, transit: 1
const buttons = screen.getAllByRole('button');
expect(buttons.length).toBeGreaterThanOrEqual(6);
const schoolBtn = screen.getByText('Trường học').closest('button')!;
expect(schoolBtn.textContent).toContain('1');
});
it('toggles category on click', () => {
// ── Category toggle ──────────────────────────────────────────────────────────
it('toggles category off → button gets line-through class', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
const schoolBtn = screen.getByText('Trường học').closest('button')!;
fireEvent.click(schoolBtn);
// After clicking, it should be toggled off (line-through style applied)
expect(schoolBtn.className).toContain('line-through');
});
it('shows fallback when no mapbox token', () => {
const originalEnv = process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
delete process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
it('re-enables category on second click', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
const schoolBtn = screen.getByText('Trường học').closest('button')!;
fireEvent.click(schoolBtn);
fireEvent.click(schoolBtn);
expect(schoolBtn.className).not.toContain('line-through');
});
// ── GeoJSON source + cluster layers ─────────────────────────────────────────
it('adds GeoJSON source with cluster:true after map load', async () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
await triggerMapLoad();
expect(mockMapInstance.addSource).toHaveBeenCalledWith(
'poi-source',
expect.objectContaining({ type: 'geojson', cluster: true }),
);
});
it('adds cluster, count label, and unclustered layers', async () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
await triggerMapLoad();
const layerIds = (mockMapInstance.addLayer.mock.calls as [{ id: string }][]).map(
(call) => call[0].id,
);
expect(layerIds).toContain('poi-clusters');
expect(layerIds).toContain('poi-cluster-count');
expect(layerIds).toContain('poi-unclustered');
});
it('calls setData on existing source instead of adding a new one', async () => {
const mockGeoJsonSource = { setData: vi.fn() };
mockMapInstance.getSource.mockReturnValue(mockGeoJsonSource);
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
await triggerMapLoad();
if (originalEnv) process.env['NEXT_PUBLIC_MAPBOX_TOKEN'] = originalEnv;
expect(mockMapInstance.addSource).not.toHaveBeenCalled();
expect(mockGeoJsonSource.setData).toHaveBeenCalled();
});
it('includes all 3 POIs in the initial GeoJSON FeatureCollection', async () => {
let capturedData: GeoJSON.FeatureCollection | null = null;
mockMapInstance.addSource.mockImplementation(
(_id: string, opts: { data: GeoJSON.FeatureCollection }) => {
capturedData = opts.data;
},
);
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
await triggerMapLoad();
expect(capturedData).not.toBeNull();
expect(capturedData!.features).toHaveLength(3);
});
it('excludes deactivated categories from the GeoJSON FeatureCollection', async () => {
let capturedData: GeoJSON.FeatureCollection | null = null;
// First render: no source yet
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
await triggerMapLoad();
// Simulate source existing for subsequent updates
const mockGeoJsonSource = {
setData: vi.fn().mockImplementation((data: GeoJSON.FeatureCollection) => {
capturedData = data;
}),
};
mockMapInstance.getSource.mockReturnValue(mockGeoJsonSource);
// Toggle school off — triggers the POI effect which calls setData
fireEvent.click(screen.getByText('Trường học').closest('button')!);
expect(capturedData).not.toBeNull();
expect(capturedData!.features).toHaveLength(2);
expect(capturedData!.features.map((f) => f.properties?.category)).not.toContain('school');
});
// ── Loading state ─────────────────────────────────────────────────────────────
it('does not add source/layers before the load event fires', () => {
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
// mapLoadCallbackHolder.fn not called yet
expect(mockMapInstance.addSource).not.toHaveBeenCalled();
expect(mockMapInstance.addLayer).not.toHaveBeenCalled();
});
// ── Fallback ──────────────────────────────────────────────────────────────────
it('shows fallback when NEXT_PUBLIC_MAPBOX_TOKEN is absent', () => {
delete process.env['NEXT_PUBLIC_MAPBOX_TOKEN'];
render(<NeighborhoodPOIMap center={center} pois={samplePois} />);
expect(screen.getByText(/NEXT_PUBLIC_MAPBOX_TOKEN/)).toBeInTheDocument();
});
it('renders correctly with zero POIs', () => {
render(<NeighborhoodPOIMap center={center} pois={[]} />);
expect(screen.getByText('Trường học')).toBeInTheDocument();
// Count badge should not appear when poiCount === 0
const schoolBtn = screen.getByText('Trường học').closest('button')!;
expect(schoolBtn.querySelector('.rounded-full')).toBeNull();
});
});