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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user