fix(a11y): resolve serious accessibility issues on search page (GOO-110)
- Add aria-hidden="true" to all decorative inline SVGs (bookmark, view-mode, funnel, checkmark) - Convert save-search popover to proper dialog: role="dialog", aria-modal, focus trap, Escape key, focus return to trigger - Add aria-pressed on list/map/split view-mode toggle buttons - Add aria-expanded + aria-controls on mobile filter toggle button - Add role="status" + aria-label="Đang tải..." on Suspense fallback Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
129
apps/api/src/modules/read-models/README.md
Normal file
129
apps/api/src/modules/read-models/README.md
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
# `read-models` module
|
||||||
|
|
||||||
|
Phase 0 skeleton for the CQRS read-model expansion described in
|
||||||
|
[RFC-003](../../../docs/adr/0003-cqrs-read-models.md) (the ADR itself
|
||||||
|
lands with [GOO-193](/GOO/issues/GOO-193); until then RFC-003 lives on
|
||||||
|
[GOO-94](/GOO/issues/GOO-94)).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
read-models/
|
||||||
|
domain/
|
||||||
|
projection-context.ts # ProjectionContext, ProjectableEvent
|
||||||
|
projection-offset-store.ts # IProjectionOffsetStore port + DI symbol
|
||||||
|
read-repository.ts # IReadRepository convention marker
|
||||||
|
application/
|
||||||
|
projectors/
|
||||||
|
projector.base.ts # Projector<E> base class
|
||||||
|
repositories/ # I<Name>ReadRepository interfaces (Phase 2/3)
|
||||||
|
infrastructure/
|
||||||
|
refresh/ # mat-view refresh cron (Phase 1)
|
||||||
|
reconciliation/ # nightly drift checker (Phase 2+)
|
||||||
|
testing/
|
||||||
|
in-memory-projection-offset-store.ts # unit-test harness
|
||||||
|
read-models.module.ts
|
||||||
|
index.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
This mirrors the layout RFC-003 §5 specifies; intentionally **no
|
||||||
|
`presentation/`** because read models are infrastructure for other
|
||||||
|
modules' query handlers, not their own HTTP surface.
|
||||||
|
|
||||||
|
## The projector contract
|
||||||
|
|
||||||
|
Every read-model projector extends `Projector<E extends DomainEvent>`
|
||||||
|
and implements:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
@EventsHandler(MyDomainEvent)
|
||||||
|
export class MyProjector extends Projector<MyDomainEvent> {
|
||||||
|
readonly handlerName = 'my-projector.v1';
|
||||||
|
|
||||||
|
protected async apply(event: MyDomainEvent, ctx: ProjectionContext) {
|
||||||
|
// write to your read model
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// glue (one of):
|
||||||
|
@EventsHandler(MyDomainEvent)
|
||||||
|
export class MyProjectorGlue implements IEventHandler<MyDomainEvent> {
|
||||||
|
constructor(private readonly projector: MyProjector) {}
|
||||||
|
handle(event: MyDomainEvent) { return this.projector.dispatch(event); }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Subclasses MUST:
|
||||||
|
|
||||||
|
- set `handlerName` to a **stable string** (rename = re-projection — be deliberate);
|
||||||
|
- implement `apply(event, ctx)` and treat `ctx.eventId` as the unit of idempotency.
|
||||||
|
|
||||||
|
Subclasses MUST NOT:
|
||||||
|
|
||||||
|
- call `apply` directly — always go through `dispatch(event)`;
|
||||||
|
- write to write-model tables — read models are read-only from the API
|
||||||
|
surface, only mutated by their owning projector or refresh job;
|
||||||
|
- implement their own deduplication — the base class already does it via
|
||||||
|
`IProjectionOffsetStore`.
|
||||||
|
|
||||||
|
## The offset / idempotency contract
|
||||||
|
|
||||||
|
RFC-003 §0 mandates `(eventId, handlerName)` idempotency:
|
||||||
|
|
||||||
|
> The `(eventId, handler)` offset table is non-negotiable. Land it in
|
||||||
|
> Phase 0 with a unit-test harness so every Phase 2/3 projector inherits it.
|
||||||
|
|
||||||
|
This module ships the **port** (`IProjectionOffsetStore`,
|
||||||
|
`PROJECTION_OFFSET_STORE`) and an in-memory implementation for tests.
|
||||||
|
The Prisma-backed implementation — including the
|
||||||
|
`projection_offset(event_id, handler_name, applied_at, payload_hash)`
|
||||||
|
migration and the transactional wrapper — lands with
|
||||||
|
[GOO-187](/GOO/issues/GOO-187).
|
||||||
|
|
||||||
|
The base class enforces the contract by calling `recordIfAbsent` BEFORE
|
||||||
|
`apply`. Re-deliveries observe `applied: false` and are skipped. The
|
||||||
|
offset row is intentionally **not rolled back on `apply` failure** in
|
||||||
|
Phase 0 — this is the conservative choice (RFC-003 §7) and is healed by
|
||||||
|
the nightly reconciliation job that lands in Phase 2.
|
||||||
|
|
||||||
|
`eventId` is currently derived from
|
||||||
|
`${aggregateId}:${occurredAt.getTime()}:${eventName}` because the
|
||||||
|
existing `DomainEvent` interface (`apps/api/src/modules/shared/domain/domain-event.ts`)
|
||||||
|
does not yet carry a stable id. Override `deriveEventId` on your
|
||||||
|
projector if your event type provides one. The id contract itself is
|
||||||
|
finalised in [GOO-187](/GOO/issues/GOO-187); Phase 2/3 projectors should
|
||||||
|
not bake assumptions about its format.
|
||||||
|
|
||||||
|
## The repository convention
|
||||||
|
|
||||||
|
For each read model:
|
||||||
|
|
||||||
|
1. Define `I<Name>ReadRepository` (extending `IReadRepository`) under
|
||||||
|
`application/repositories/`.
|
||||||
|
2. Export a paired injection symbol `<NAME>_READ_REPOSITORY`.
|
||||||
|
3. Implement `Prisma<Name>ReadRepository` under
|
||||||
|
`infrastructure/repositories/` and bind it in `ReadModelsModule`.
|
||||||
|
4. Re-export the symbol from the module's `index.ts` so query handlers in
|
||||||
|
other modules can `@Inject(LISTING_CARD_READ_REPOSITORY)` without
|
||||||
|
reaching into the read-models module's internals.
|
||||||
|
|
||||||
|
Read repositories are READ-ONLY from the perspective of the rest of the
|
||||||
|
API. The only writers are the projector that owns the read model (Option
|
||||||
|
C) or the materialized-view refresh job (Option B).
|
||||||
|
|
||||||
|
## What Phase 0 is NOT
|
||||||
|
|
||||||
|
- No `projection_offset` migration — owned by [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
- No projectors registered.
|
||||||
|
- No materialized views or refresh job — Phase 1.
|
||||||
|
- No reconciliation job — Phase 2.
|
||||||
|
- No `X-Data-Freshness-Seconds` helper — separate Phase 0 ticket.
|
||||||
|
- No kill-switch / chaos test — separate Phase 0 ticket.
|
||||||
|
|
||||||
|
The skeleton exists so the next batch of PRs is purely additive.
|
||||||
|
|
||||||
|
## Coordination
|
||||||
|
|
||||||
|
- Parent: [GOO-94](/GOO/issues/GOO-94)
|
||||||
|
- Sibling (offset table + idempotency harness): [GOO-187](/GOO/issues/GOO-187)
|
||||||
|
- ADR (write-up): [GOO-193](/GOO/issues/GOO-193)
|
||||||
1
apps/api/src/modules/read-models/application/index.ts
Normal file
1
apps/api/src/modules/read-models/application/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './projectors';
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { Projector } from './projector.base';
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import {
|
||||||
|
PROJECTION_OFFSET_STORE,
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectableEvent,
|
||||||
|
type ProjectionContext,
|
||||||
|
} from '../../domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class every read-model projector inherits from.
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Owns the typed `apply(event, ctx)` hook subclasses implement.
|
||||||
|
* - Delegates the `(eventId, handlerName)` idempotency check to
|
||||||
|
* {@link IProjectionOffsetStore} (port from `domain/`,
|
||||||
|
* Prisma implementation from [GOO-187](/GOO/issues/GOO-187)).
|
||||||
|
* - Emits a structured log line with projector lag for observability
|
||||||
|
* (`X-Data-Freshness-Seconds` SLO, RFC-003 §0).
|
||||||
|
*
|
||||||
|
* Subclasses do NOT call `apply` directly — they invoke {@link dispatch}
|
||||||
|
* from their `@EventsHandler` / `@OnEvent` glue. `dispatch` enforces the
|
||||||
|
* "at-least-once → effectively-once" contract.
|
||||||
|
*
|
||||||
|
* Subclasses MUST:
|
||||||
|
* - Set `handlerName` (stable identifier — used as the offset key half).
|
||||||
|
* - Implement `apply(event, ctx)`.
|
||||||
|
*
|
||||||
|
* Subclasses MAY:
|
||||||
|
* - Override `deriveEventId(event)` if their event type carries a
|
||||||
|
* stable id field. Default derivation is
|
||||||
|
* `${aggregateId}:${occurredAt.getTime()}:${eventName}` — sufficient
|
||||||
|
* for current domain events but NOT for events fanned out via
|
||||||
|
* external transports (revisit when CDC lands, RFC-003 Option D).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export abstract class Projector<E extends ProjectableEvent> {
|
||||||
|
/** Stable handler identifier — second half of the offset key. */
|
||||||
|
abstract readonly handlerName: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(PROJECTION_OFFSET_STORE)
|
||||||
|
protected readonly offsets: IProjectionOffsetStore,
|
||||||
|
protected readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implement the actual projection. MUST be deterministic given
|
||||||
|
* `(event, ctx)` and MUST be safe to short-circuit if `ctx` indicates
|
||||||
|
* a re-delivery (the base class already enforces this — subclasses
|
||||||
|
* should not re-check).
|
||||||
|
*/
|
||||||
|
protected abstract apply(event: E, ctx: ProjectionContext): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional hook so subclasses can override how `eventId` is derived
|
||||||
|
* from a domain event. Override this if your event type carries a
|
||||||
|
* stable id (e.g. UUID minted by the producer).
|
||||||
|
*/
|
||||||
|
protected deriveEventId(event: E): string {
|
||||||
|
return `${event.aggregateId}:${event.occurredAt.getTime()}:${event.eventName}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entry point invoked by the projector's framework glue
|
||||||
|
* (`@EventsHandler` / `@OnEvent`). Wraps `apply` with the offset
|
||||||
|
* idempotency check and emits a lag log line on success.
|
||||||
|
*/
|
||||||
|
async dispatch(event: E): Promise<void> {
|
||||||
|
const observedAt = new Date();
|
||||||
|
const ctx: ProjectionContext = {
|
||||||
|
eventId: this.deriveEventId(event),
|
||||||
|
handlerName: this.handlerName,
|
||||||
|
observedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { applied } = await this.offsets.recordIfAbsent({
|
||||||
|
eventId: ctx.eventId,
|
||||||
|
handlerName: ctx.handlerName,
|
||||||
|
appliedAt: observedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!applied) {
|
||||||
|
// Re-delivery — already projected. No-op by contract.
|
||||||
|
this.logger.debug(
|
||||||
|
`Projector ${this.handlerName} skipped duplicate event ${ctx.eventId}`,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.apply(event, ctx);
|
||||||
|
const lagMs = observedAt.getTime() - event.occurredAt.getTime();
|
||||||
|
this.logger.debug(
|
||||||
|
`Projector ${this.handlerName} applied event ${ctx.eventId} (lag=${lagMs}ms)`,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
// Surface the failure with the offset key so reconciliation can
|
||||||
|
// reason about partially-applied state. Note that the offset row
|
||||||
|
// is already inserted — Phase 0 deliberately does NOT roll it back.
|
||||||
|
// RFC-003 §7 covers this with the nightly reconciliation job.
|
||||||
|
this.logger.error(
|
||||||
|
`Projector ${this.handlerName} failed for event ${ctx.eventId}: ${(err as Error).message}`,
|
||||||
|
(err as Error).stack,
|
||||||
|
'Projector',
|
||||||
|
);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Per-read-model repository interfaces live in this folder once Phase 2/3
|
||||||
|
* begin landing concrete read models. Phase 0 ships only the convention
|
||||||
|
* (see `domain/read-repository.ts`):
|
||||||
|
*
|
||||||
|
* - One interface per read model: `I<Name>ReadRepository`.
|
||||||
|
* - Paired injection symbol: `<NAME>_READ_REPOSITORY`.
|
||||||
|
* - Read-only — writes go through projectors or refresh jobs.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
17
apps/api/src/modules/read-models/domain/index.ts
Normal file
17
apps/api/src/modules/read-models/domain/index.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
export {
|
||||||
|
type ProjectionContext,
|
||||||
|
type ProjectableEvent,
|
||||||
|
} from './projection-context';
|
||||||
|
export {
|
||||||
|
PROJECTION_OFFSET_STORE,
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectionOffsetKey,
|
||||||
|
type ProjectionOffsetRecord,
|
||||||
|
type RecordOffsetInput,
|
||||||
|
type RecordOffsetResult,
|
||||||
|
} from './projection-offset-store';
|
||||||
|
export { type IReadRepository } from './read-repository';
|
||||||
|
export {
|
||||||
|
READ_MODEL_KILL_SWITCH,
|
||||||
|
type IReadModelKillSwitch,
|
||||||
|
} from './read-model-kill-switch';
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { DomainEvent } from '@modules/shared';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-event context handed to a projector's `apply` hook.
|
||||||
|
*
|
||||||
|
* Phase 0 keeps this intentionally minimal. Later phases may attach
|
||||||
|
* tracing spans, current offset metadata, or a transaction handle here
|
||||||
|
* — additions MUST stay backward-compatible (additive properties only).
|
||||||
|
*/
|
||||||
|
export interface ProjectionContext {
|
||||||
|
/**
|
||||||
|
* Stable identifier for the event being projected. Used as the primary
|
||||||
|
* key half of `(eventId, handlerName)` in the offset store so re-delivery
|
||||||
|
* is a no-op.
|
||||||
|
*
|
||||||
|
* NOTE: domain events do not yet carry a stable id; until they do
|
||||||
|
* the wrapper that invokes a projector is responsible for deriving one
|
||||||
|
* (typically `${aggregateId}:${occurredAt.getTime()}:${eventName}`).
|
||||||
|
* This contract is fixed in [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
*/
|
||||||
|
readonly eventId: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The projector handler invoking `apply`. Used as the second half of
|
||||||
|
* the `(eventId, handlerName)` offset key — the same event projected
|
||||||
|
* by two different handlers must record two separate offsets.
|
||||||
|
*/
|
||||||
|
readonly handlerName: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the event was observed by the projector dispatcher (NOT when
|
||||||
|
* the event itself occurred — see `event.occurredAt`). Useful for
|
||||||
|
* lag metrics: `observedAt - event.occurredAt`.
|
||||||
|
*/
|
||||||
|
readonly observedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projector-facing view of a domain event. Re-exported here so projector
|
||||||
|
* code does not have to reach across to `@modules/shared` for the base
|
||||||
|
* type — keeps the read-models module's public surface self-contained.
|
||||||
|
*/
|
||||||
|
export type ProjectableEvent = DomainEvent;
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Idempotency contract for projector dispatch.
|
||||||
|
*
|
||||||
|
* RFC-003 §0 (CTO ask): the `(eventId, handlerName)` offset table is
|
||||||
|
* non-negotiable. Phase 0 lands this *port* so that:
|
||||||
|
*
|
||||||
|
* 1. The projector base class can express the contract in code today
|
||||||
|
* (without taking a Prisma dependency at this layer).
|
||||||
|
* 2. [GOO-187](/GOO/issues/GOO-187) lands the Prisma migration
|
||||||
|
* (`projection_offset(event_id, handler_name, applied_at, payload_hash)`)
|
||||||
|
* and the concrete implementation against this same interface.
|
||||||
|
* 3. The unit-test harness in `read-models/testing` can ship an
|
||||||
|
* in-memory implementation without coupling to infra.
|
||||||
|
*
|
||||||
|
* Implementations MUST be safe under concurrent dispatch: the
|
||||||
|
* `recordIfAbsent` call is the linearisation point — exactly one caller
|
||||||
|
* for a given `(eventId, handlerName)` should observe `applied: true`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ProjectionOffsetKey {
|
||||||
|
readonly eventId: string;
|
||||||
|
readonly handlerName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectionOffsetRecord extends ProjectionOffsetKey {
|
||||||
|
/** When this offset was first recorded (i.e. the projection ran). */
|
||||||
|
readonly appliedAt: Date;
|
||||||
|
/**
|
||||||
|
* Optional content-hash of the projected payload. Reconciliation jobs
|
||||||
|
* use this to spot drift between what was projected and what the
|
||||||
|
* write-side now holds.
|
||||||
|
*/
|
||||||
|
readonly payloadHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordOffsetInput extends ProjectionOffsetKey {
|
||||||
|
readonly appliedAt?: Date;
|
||||||
|
readonly payloadHash?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecordOffsetResult {
|
||||||
|
/**
|
||||||
|
* `true` if this call inserted a fresh offset row (the projection
|
||||||
|
* should run); `false` if the offset already existed (re-delivery,
|
||||||
|
* the projection MUST be skipped).
|
||||||
|
*/
|
||||||
|
readonly applied: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProjectionOffsetStore {
|
||||||
|
/**
|
||||||
|
* Atomically insert an offset row if and only if no row exists for
|
||||||
|
* `(eventId, handlerName)`. Implementations typically use
|
||||||
|
* `INSERT ... ON CONFLICT DO NOTHING` or an equivalent unique-constraint
|
||||||
|
* insert and report whether a row was actually written.
|
||||||
|
*/
|
||||||
|
recordIfAbsent(input: RecordOffsetInput): Promise<RecordOffsetResult>;
|
||||||
|
|
||||||
|
/** Lookup helper for reconciliation tooling and tests. */
|
||||||
|
find(key: ProjectionOffsetKey): Promise<ProjectionOffsetRecord | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PROJECTION_OFFSET_STORE = Symbol('PROJECTION_OFFSET_STORE');
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Per-read-model kill switch — RFC-003 §0.
|
||||||
|
*
|
||||||
|
* Contract:
|
||||||
|
* - `isEnabled(name)` returns whether the named read model should
|
||||||
|
* serve queries. When `false`, callers MUST fail open to the
|
||||||
|
* write-model path.
|
||||||
|
* - Implementations MUST be hot-readable (no restart required).
|
||||||
|
* - The check is evaluated per-call so that a flag toggled mid-request
|
||||||
|
* is honoured on the NEXT repository call within the same request.
|
||||||
|
* In-flight calls complete against whichever source they already
|
||||||
|
* started on.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const READ_MODEL_KILL_SWITCH = Symbol('READ_MODEL_KILL_SWITCH');
|
||||||
|
|
||||||
|
export interface IReadModelKillSwitch {
|
||||||
|
/**
|
||||||
|
* Returns `true` when the named read model is active and safe to query.
|
||||||
|
* Returns `true` (fail-open) for unknown / un-configured names so that
|
||||||
|
* an absent config key never blocks the write-model fallback path.
|
||||||
|
*/
|
||||||
|
isEnabled(readModelName: string): boolean;
|
||||||
|
}
|
||||||
28
apps/api/src/modules/read-models/domain/read-repository.ts
Normal file
28
apps/api/src/modules/read-models/domain/read-repository.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Marker interface for read-model repositories.
|
||||||
|
*
|
||||||
|
* Convention (Phase 0):
|
||||||
|
* - One interface per read model: `I<Name>ReadRepository`.
|
||||||
|
* - Paired injection symbol: `<NAME>_READ_REPOSITORY` (Symbol).
|
||||||
|
* - Concrete Prisma-backed class lives under
|
||||||
|
* `infrastructure/repositories/prisma-<name>-read.repository.ts`.
|
||||||
|
* - Read repositories are READ-ONLY. Writes happen exclusively via
|
||||||
|
* projectors (Option C) or scheduled refresh jobs (Option B).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* export const LISTING_CARD_READ_REPOSITORY = Symbol('LISTING_CARD_READ_REPOSITORY');
|
||||||
|
*
|
||||||
|
* export interface IListingCardReadRepository extends IReadRepository {
|
||||||
|
* findById(id: string): Promise<ListingCardReadView | null>;
|
||||||
|
* search(params: ListingCardSearchParams): Promise<PaginatedResult<ListingCardReadView>>;
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* Keeping this as an empty marker (rather than forcing a `findById`
|
||||||
|
* shape) lets Phase 2/3 read repositories pick the access pattern that
|
||||||
|
* fits the read model — point lookup, search, range scan, etc.
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-empty-interface
|
||||||
|
export interface IReadRepository {}
|
||||||
4
apps/api/src/modules/read-models/index.ts
Normal file
4
apps/api/src/modules/read-models/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { ReadModelsModule } from './read-models.module';
|
||||||
|
export * from './domain';
|
||||||
|
export * from './application';
|
||||||
|
export * from './infrastructure';
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { type IReadModelKillSwitch } from '../../domain/read-model-kill-switch';
|
||||||
|
import { ReadModelRepositoryWrapper } from '../read-model-repository-wrapper';
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Shared test doubles */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
interface IFakeRepository {
|
||||||
|
findById(id: string): Promise<{ id: string; source: string }>;
|
||||||
|
search(query: string): Promise<{ results: string[]; source: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReadRepo(): IFakeRepository {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(async (id: string) => ({ id, source: 'read-model' })),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'read-model' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createWriteRepo(): IFakeRepository {
|
||||||
|
return {
|
||||||
|
findById: vi.fn(async (id: string) => ({ id, source: 'write-model' })),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'write-model' })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const silentLogger = {
|
||||||
|
debug: vi.fn(),
|
||||||
|
log: vi.fn(),
|
||||||
|
warn: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
verbose: vi.fn(),
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Mutable kill switch for chaos testing */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
class MutableKillSwitch implements IReadModelKillSwitch {
|
||||||
|
private flags = new Map<string, boolean>();
|
||||||
|
|
||||||
|
setEnabled(name: string, enabled: boolean): void {
|
||||||
|
this.flags.set(name, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
isEnabled(name: string): boolean {
|
||||||
|
return this.flags.get(name) ?? true; // fail-open default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Tests */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
describe('ReadModelRepositoryWrapper — kill switch', () => {
|
||||||
|
let killSwitch: MutableKillSwitch;
|
||||||
|
let readRepo: IFakeRepository;
|
||||||
|
let writeRepo: IFakeRepository;
|
||||||
|
let proxy: IFakeRepository;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
killSwitch = new MutableKillSwitch();
|
||||||
|
readRepo = createReadRepo();
|
||||||
|
writeRepo = createWriteRepo();
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
readRepo,
|
||||||
|
writeRepo,
|
||||||
|
killSwitch,
|
||||||
|
'listing_card',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
proxy = wrapper.getProxy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to read-model when kill switch is ON (enabled)', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const result = await proxy.findById('abc');
|
||||||
|
expect(result.source).toBe('read-model');
|
||||||
|
expect(readRepo.findById).toHaveBeenCalledWith('abc');
|
||||||
|
expect(writeRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes to write-model when kill switch is OFF (disabled)', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const result = await proxy.findById('abc');
|
||||||
|
expect(result.source).toBe('write-model');
|
||||||
|
expect(writeRepo.findById).toHaveBeenCalledWith('abc');
|
||||||
|
expect(readRepo.findById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to enabled (fail-open) for unknown read model names', async () => {
|
||||||
|
// 'listing_card' was never set → defaults to true
|
||||||
|
const freshKillSwitch = new MutableKillSwitch();
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
readRepo,
|
||||||
|
writeRepo,
|
||||||
|
freshKillSwitch,
|
||||||
|
'unknown_model',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
const result = await wrapper.getProxy().findById('xyz');
|
||||||
|
expect(result.source).toBe('read-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------- */
|
||||||
|
/* CHAOS TEST: flag toggle mid-request → fail-open */
|
||||||
|
/* -------------------------------------------------------------- */
|
||||||
|
|
||||||
|
it('chaos: flag toggle mid-request fails open to write-model on NEXT call', async () => {
|
||||||
|
// Start enabled — first call goes to read-model.
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
|
||||||
|
const call1 = proxy.findById('first');
|
||||||
|
|
||||||
|
// Toggle the flag WHILE call1 is in-flight.
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
const result1 = await call1;
|
||||||
|
// call1 already started on read-model — it completes there.
|
||||||
|
expect(result1.source).toBe('read-model');
|
||||||
|
|
||||||
|
// NEXT call should route to write-model (the switch was flipped).
|
||||||
|
const result2 = await proxy.findById('second');
|
||||||
|
expect(result2.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: rapid toggle during sequential calls routes correctly', async () => {
|
||||||
|
// Simulate a chaotic sequence of toggles interleaved with calls.
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const r1 = await proxy.search('q1');
|
||||||
|
expect(r1.source).toBe('read-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const r2 = await proxy.search('q2');
|
||||||
|
expect(r2.source).toBe('write-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
const r3 = await proxy.search('q3');
|
||||||
|
expect(r3.source).toBe('read-model');
|
||||||
|
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
const r4 = await proxy.search('q4');
|
||||||
|
expect(r4.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: concurrent calls with mid-flight toggle each route independently', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', true);
|
||||||
|
|
||||||
|
// Slow read-model that takes time to resolve.
|
||||||
|
const slowReadRepo: IFakeRepository = {
|
||||||
|
findById: vi.fn(
|
||||||
|
(id: string) =>
|
||||||
|
new Promise((resolve) =>
|
||||||
|
setTimeout(() => resolve({ id, source: 'read-model' }), 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
search: vi.fn(async (q: string) => ({ results: [q], source: 'read-model' })),
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = new ReadModelRepositoryWrapper<IFakeRepository>(
|
||||||
|
slowReadRepo,
|
||||||
|
writeRepo,
|
||||||
|
killSwitch,
|
||||||
|
'listing_card',
|
||||||
|
silentLogger,
|
||||||
|
);
|
||||||
|
const slowProxy = wrapper.getProxy();
|
||||||
|
|
||||||
|
// Launch first call (will use read-model, takes 50ms).
|
||||||
|
const p1 = slowProxy.findById('slow');
|
||||||
|
|
||||||
|
// Toggle off before call1 resolves.
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
// Second call should immediately route to write-model.
|
||||||
|
const p2 = slowProxy.findById('fast');
|
||||||
|
|
||||||
|
const [result1, result2] = await Promise.all([p1, p2]);
|
||||||
|
|
||||||
|
// call1 was already dispatched to read-model — completes there.
|
||||||
|
expect(result1.source).toBe('read-model');
|
||||||
|
// call2 was dispatched after toggle — goes to write-model.
|
||||||
|
expect(result2.source).toBe('write-model');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chaos: zero error bubble to caller when switch is off', async () => {
|
||||||
|
killSwitch.setEnabled('listing_card', false);
|
||||||
|
|
||||||
|
// Both methods should work without throwing.
|
||||||
|
await expect(proxy.findById('x')).resolves.toBeDefined();
|
||||||
|
await expect(proxy.search('y')).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ConfigReadModelKillSwitch', () => {
|
||||||
|
// Unit test the config-backed implementation separately.
|
||||||
|
it('reads env var per call (hot-readable)', async () => {
|
||||||
|
const { ConfigReadModelKillSwitch } = await import(
|
||||||
|
'../config-read-model-kill-switch'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: vi.fn((key: string) => {
|
||||||
|
if (key === 'READ_MODEL_LISTING_CARD_ENABLED') return 'false';
|
||||||
|
return undefined;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ks = new ConfigReadModelKillSwitch(mockConfig as any, silentLogger);
|
||||||
|
|
||||||
|
expect(ks.isEnabled('listing_card')).toBe(false);
|
||||||
|
expect(ks.isEnabled('unknown')).toBe(true); // fail-open
|
||||||
|
|
||||||
|
// Simulate hot-reload by changing the mock return.
|
||||||
|
mockConfig.get.mockImplementation((key: string) => {
|
||||||
|
if (key === 'READ_MODEL_LISTING_CARD_ENABLED') return 'true';
|
||||||
|
return undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ks.isEnabled('listing_card')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats "0" as disabled', async () => {
|
||||||
|
const { ConfigReadModelKillSwitch } = await import(
|
||||||
|
'../config-read-model-kill-switch'
|
||||||
|
);
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
get: vi.fn(() => '0'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const ks = new ConfigReadModelKillSwitch(mockConfig as any, silentLogger);
|
||||||
|
expect(ks.isEnabled('any_model')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { type IReadModelKillSwitch } from '../domain/read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config-driven per-read-model kill switch.
|
||||||
|
*
|
||||||
|
* Reads `READ_MODEL_<UPPER_SNAKE_NAME>_ENABLED` from process.env via
|
||||||
|
* NestJS ConfigService on every call (hot-readable — no restart needed).
|
||||||
|
*
|
||||||
|
* Missing keys default to `true` (fail-open: the read model is presumed
|
||||||
|
* healthy unless explicitly killed).
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* `READ_MODEL_LISTING_CARD_ENABLED=false` → listing_card read model disabled
|
||||||
|
* (env var absent) → read model enabled (fail-open)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class ConfigReadModelKillSwitch implements IReadModelKillSwitch {
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
isEnabled(readModelName: string): boolean {
|
||||||
|
const envKey = `READ_MODEL_${readModelName.replace(/-/g, '_').toUpperCase()}_ENABLED`;
|
||||||
|
const raw = this.config.get<string>(envKey);
|
||||||
|
|
||||||
|
// Missing config → fail open (enabled).
|
||||||
|
if (raw === undefined || raw === null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = raw !== 'false' && raw !== '0';
|
||||||
|
|
||||||
|
if (!enabled) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Kill switch OFF for read model "${readModelName}" (${envKey}=${raw})`,
|
||||||
|
'ReadModelKillSwitch',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enabled;
|
||||||
|
}
|
||||||
|
}
|
||||||
4
apps/api/src/modules/read-models/infrastructure/index.ts
Normal file
4
apps/api/src/modules/read-models/infrastructure/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export * from './refresh';
|
||||||
|
export * from './reconciliation';
|
||||||
|
export { ConfigReadModelKillSwitch } from './config-read-model-kill-switch';
|
||||||
|
export { ReadModelRepositoryWrapper } from './read-model-repository-wrapper';
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { LoggerService } from '@modules/shared';
|
||||||
|
import { type IReadModelKillSwitch } from '../domain/read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generic wrapper that sits in front of a read-model repository and
|
||||||
|
* transparently fails open to the write-model repository when the
|
||||||
|
* per-read-model kill switch is OFF.
|
||||||
|
*
|
||||||
|
* Every public method call checks the kill switch. Because the check
|
||||||
|
* happens per-call (not per-request), a flag toggled mid-request is
|
||||||
|
* honoured on the NEXT call — the in-flight call completes against
|
||||||
|
* whichever source it already started on.
|
||||||
|
*
|
||||||
|
* Usage (at module wiring time):
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* const wrapper = new ReadModelRepositoryWrapper(
|
||||||
|
* readRepo,
|
||||||
|
* writeRepo,
|
||||||
|
* killSwitch,
|
||||||
|
* 'listing_card',
|
||||||
|
* logger,
|
||||||
|
* );
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `T` is the repository interface both implementations share (the
|
||||||
|
* intersection of methods callable by consumers).
|
||||||
|
*/
|
||||||
|
export class ReadModelRepositoryWrapper<T extends object> {
|
||||||
|
private readonly proxy: T;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly readImpl: T,
|
||||||
|
private readonly writeImpl: T,
|
||||||
|
private readonly killSwitch: IReadModelKillSwitch,
|
||||||
|
private readonly readModelName: string,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {
|
||||||
|
// Build a Proxy that intercepts every method call and routes it
|
||||||
|
// through the kill switch.
|
||||||
|
this.proxy = new Proxy(readImpl, {
|
||||||
|
get: (_target, prop, _receiver) => {
|
||||||
|
const readVal = (readImpl as Record<string | symbol, unknown>)[prop];
|
||||||
|
const writeVal = (writeImpl as Record<string | symbol, unknown>)[prop];
|
||||||
|
|
||||||
|
// Non-function properties: always return from the active source.
|
||||||
|
if (typeof readVal !== 'function') {
|
||||||
|
return this.killSwitch.isEnabled(this.readModelName) ? readVal : writeVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function: return a wrapper that checks the switch at call time.
|
||||||
|
return (...args: unknown[]) => {
|
||||||
|
if (this.killSwitch.isEnabled(this.readModelName)) {
|
||||||
|
return (readVal as Function).apply(readImpl, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Kill switch routed ${this.readModelName}.${String(prop)} → write-model`,
|
||||||
|
'ReadModelRepositoryWrapper',
|
||||||
|
);
|
||||||
|
if (typeof writeVal !== 'function') {
|
||||||
|
throw new Error(
|
||||||
|
`Write-model fallback for ${this.readModelName} does not implement ${String(prop)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (writeVal as Function).apply(writeImpl, args);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the proxied repository that consumers should depend on.
|
||||||
|
* Inject this as the repository token value.
|
||||||
|
*/
|
||||||
|
getProxy(): T {
|
||||||
|
return this.proxy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Reconciliation infrastructure (RFC-003 §7).
|
||||||
|
*
|
||||||
|
* Phase 0 ships only the placeholder. Phase 2 lands the sampled nightly
|
||||||
|
* (1%) drift checker; the weekly full reconciliation runs follow once
|
||||||
|
* Phase 2 has soaked in production for one cycle.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
/**
|
||||||
|
* Materialized-view refresh infrastructure (RFC-003 Option B).
|
||||||
|
*
|
||||||
|
* Phase 0 ships only the placeholder. Phase 1 lands
|
||||||
|
* `RefreshMaterializedViewJob` and the cron registrations for
|
||||||
|
* `mv_heatmap_district`, `mv_heatmap_ward`, `mv_market_snapshot`,
|
||||||
|
* `mv_district_stats`.
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
34
apps/api/src/modules/read-models/read-models.module.ts
Normal file
34
apps/api/src/modules/read-models/read-models.module.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { CqrsModule } from '@nestjs/cqrs';
|
||||||
|
import { SharedModule } from '@modules/shared';
|
||||||
|
import { READ_MODEL_KILL_SWITCH } from './domain/read-model-kill-switch';
|
||||||
|
import { ConfigReadModelKillSwitch } from './infrastructure/config-read-model-kill-switch';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-models module skeleton — RFC-003 Phase 0.
|
||||||
|
*
|
||||||
|
* Hosts:
|
||||||
|
* - Projector base class (`application/projectors/projector.base.ts`).
|
||||||
|
* - Read-model repository convention (`domain/read-repository.ts`).
|
||||||
|
* - Idempotency port (`domain/projection-offset-store.ts`).
|
||||||
|
* - Per-read-model kill switch (`domain/read-model-kill-switch.ts`).
|
||||||
|
*
|
||||||
|
* No projectors, repositories, or `IProjectionOffsetStore` provider are
|
||||||
|
* registered here yet. The Prisma-backed offset store binding lands with
|
||||||
|
* [GOO-187](/GOO/issues/GOO-187); per-read-model projectors land in
|
||||||
|
* Phase 2/3.
|
||||||
|
*
|
||||||
|
* The module is imported by `AppModule` so its DI container is wired up
|
||||||
|
* even while empty — keeps Phase 2/3 PRs strictly additive.
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
imports: [CqrsModule, SharedModule],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: READ_MODEL_KILL_SWITCH,
|
||||||
|
useClass: ConfigReadModelKillSwitch,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exports: [READ_MODEL_KILL_SWITCH],
|
||||||
|
})
|
||||||
|
export class ReadModelsModule {}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
type IProjectionOffsetStore,
|
||||||
|
type ProjectionOffsetKey,
|
||||||
|
type ProjectionOffsetRecord,
|
||||||
|
type RecordOffsetInput,
|
||||||
|
type RecordOffsetResult,
|
||||||
|
} from '../domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory {@link IProjectionOffsetStore} for unit tests.
|
||||||
|
*
|
||||||
|
* Phase 2/3 projector tests reuse this so they can exercise the
|
||||||
|
* "replay same event N times → single state mutation" contract from
|
||||||
|
* RFC-003 §0 without spinning up Postgres. The Prisma-backed
|
||||||
|
* implementation lives in [GOO-187](/GOO/issues/GOO-187).
|
||||||
|
*/
|
||||||
|
export class InMemoryProjectionOffsetStore implements IProjectionOffsetStore {
|
||||||
|
private readonly rows = new Map<string, ProjectionOffsetRecord>();
|
||||||
|
|
||||||
|
private static key(k: ProjectionOffsetKey): string {
|
||||||
|
return `${k.handlerName}::${k.eventId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async recordIfAbsent(input: RecordOffsetInput): Promise<RecordOffsetResult> {
|
||||||
|
const k = InMemoryProjectionOffsetStore.key(input);
|
||||||
|
if (this.rows.has(k)) {
|
||||||
|
return { applied: false };
|
||||||
|
}
|
||||||
|
this.rows.set(k, {
|
||||||
|
eventId: input.eventId,
|
||||||
|
handlerName: input.handlerName,
|
||||||
|
appliedAt: input.appliedAt ?? new Date(),
|
||||||
|
...(input.payloadHash !== undefined ? { payloadHash: input.payloadHash } : {}),
|
||||||
|
});
|
||||||
|
return { applied: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(key: ProjectionOffsetKey): Promise<ProjectionOffsetRecord | null> {
|
||||||
|
return this.rows.get(InMemoryProjectionOffsetStore.key(key)) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
size(): number {
|
||||||
|
return this.rows.size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test helper. */
|
||||||
|
clear(): void {
|
||||||
|
this.rows.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
apps/api/src/modules/read-models/testing/index.ts
Normal file
1
apps/api/src/modules/read-models/testing/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { InMemoryProjectionOffsetStore } from './in-memory-projection-offset-store';
|
||||||
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
127
apps/web/app/[locale]/(admin)/admin/__tests__/admin-kyc.spec.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => {
|
||||||
|
const icon = (name: string) => (props: Record<string, unknown>) => <span data-testid={`icon-${name}`} {...props} />;
|
||||||
|
return {
|
||||||
|
CheckCircle: icon('check'),
|
||||||
|
XCircle: icon('x'),
|
||||||
|
RefreshCw: icon('refresh'),
|
||||||
|
ChevronLeft: icon('chevron-left'),
|
||||||
|
ChevronRight: icon('chevron-right'),
|
||||||
|
ShieldCheck: icon('shield'),
|
||||||
|
X: icon('close'),
|
||||||
|
User: icon('user'),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/design-system/status-chip', () => ({
|
||||||
|
StatusChip: ({ status }: { status: string }) => <span data-testid="status-chip">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetKycQueue = vi.fn();
|
||||||
|
const mockApproveKyc = vi.fn();
|
||||||
|
const mockRejectKyc = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/admin-api', () => ({
|
||||||
|
adminApi: {
|
||||||
|
getKycQueue: (...args: unknown[]) => mockGetKycQueue(...args),
|
||||||
|
approveKyc: (...args: unknown[]) => mockApproveKyc(...args),
|
||||||
|
rejectKyc: (...args: unknown[]) => mockRejectKyc(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import AdminKycPage from '../kyc/page';
|
||||||
|
|
||||||
|
const mockQueueData = {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
userId: 'u1',
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
phone: '0912345678',
|
||||||
|
email: 'a@test.com',
|
||||||
|
role: 'AGENT',
|
||||||
|
kycStatus: 'PENDING',
|
||||||
|
kycData: { idType: 'CCCD', idNumber: '012345678901', frontImageUrl: 'https://img.test/front.jpg' },
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
userId: 'u2',
|
||||||
|
fullName: 'Tran Thi B',
|
||||||
|
phone: '0987654321',
|
||||||
|
email: null,
|
||||||
|
role: 'USER',
|
||||||
|
kycStatus: 'PENDING',
|
||||||
|
kycData: null,
|
||||||
|
createdAt: '2024-06-16T10:00:00Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 2,
|
||||||
|
page: 1,
|
||||||
|
limit: 20,
|
||||||
|
totalPages: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('AdminKycPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockGetKycQueue.mockResolvedValue(mockQueueData);
|
||||||
|
mockApproveKyc.mockResolvedValue({});
|
||||||
|
mockRejectKyc.mockResolvedValue({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading and fetches queue', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Duyệt KYC')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(mockGetKycQueue).toHaveBeenCalledWith(1, 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders queue items in table', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tran Thi B')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no requests', async () => {
|
||||||
|
mockGetKycQueue.mockResolvedValue({ data: [], total: 0, page: 1, limit: 20, totalPages: 0 });
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Không có yêu cầu KYC nào đang chờ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state when fetch fails', async () => {
|
||||||
|
mockGetKycQueue.mockRejectedValue(new Error('Network error'));
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refreshes queue on refresh button click', async () => {
|
||||||
|
render(<AdminKycPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Nguyen Van A')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /làm mới/i }));
|
||||||
|
|
||||||
|
expect(mockGetKycQueue).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next/dynamic', () => ({
|
||||||
|
default: () => {
|
||||||
|
const Mock = () => <div data-testid="chart-placeholder">Chart</div>;
|
||||||
|
Mock.displayName = 'MockChart';
|
||||||
|
return Mock;
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: (props: Record<string, unknown>) => <img {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/link', () => ({
|
||||||
|
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||||
|
<a href={href} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockUseMarketReport = vi.fn();
|
||||||
|
const mockUseHeatmap = vi.fn();
|
||||||
|
const mockUseListingsSearch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-analytics', () => ({
|
||||||
|
useMarketReport: (...args: unknown[]) => mockUseMarketReport(...args),
|
||||||
|
useHeatmap: (...args: unknown[]) => mockUseHeatmap(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-listings', () => ({
|
||||||
|
useListingsSearch: (...args: unknown[]) => mockUseListingsSearch(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/listings/listing-status-badge', () => ({
|
||||||
|
ListingStatusBadge: ({ status }: { status: string }) => <span data-testid="status-badge">{status}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import DashboardPage from '../page';
|
||||||
|
|
||||||
|
const fullData = {
|
||||||
|
marketReport: {
|
||||||
|
districts: [
|
||||||
|
{ district: 'Quan 1', totalListings: 100, avgPriceM2: 120000000, medianPrice: '15000000000', daysOnMarket: 45, yoyChange: 5.2, inventoryLevel: 50 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
heatmap: { dataPoints: [{ district: 'Quan 1', avgPriceM2: 120000000, totalListings: 100, lat: 10.77, lng: 106.7 }] },
|
||||||
|
listings: {
|
||||||
|
data: [{
|
||||||
|
id: '1', status: 'ACTIVE', transactionType: 'SALE', priceVND: '5000000000', viewCount: 10,
|
||||||
|
saveCount: 2, inquiryCount: 3, publishedAt: '2024-01-01', createdAt: '2024-01-01',
|
||||||
|
pricePerM2: null, rentPriceMonthly: null, commissionPct: null,
|
||||||
|
property: {
|
||||||
|
id: 'p1', propertyType: 'APARTMENT', title: 'Căn hộ Quận 7', description: 'Test',
|
||||||
|
address: '123 Nguyễn Hữu Thọ', ward: 'Tân Hưng', district: 'Quận 7',
|
||||||
|
city: 'Hồ Chí Minh', areaM2: 75, bedrooms: 2, bathrooms: 2, floors: null,
|
||||||
|
direction: null, yearBuilt: null, legalStatus: null, amenities: null, projectName: null, media: [],
|
||||||
|
},
|
||||||
|
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
|
||||||
|
agent: null,
|
||||||
|
}],
|
||||||
|
total: 1, page: 1, limit: 6, totalPages: 1,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('DashboardPage — deep tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state with placeholders', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders stat cards with computed values', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tin đăng của tôi')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lượt xem')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá TB thị trường')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders recent listings with property title', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Tin đăng gần đây')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Đăng tin mới" link', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: fullData.listings, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty listings state', () => {
|
||||||
|
mockUseMarketReport.mockReturnValue({ data: fullData.marketReport, isLoading: false });
|
||||||
|
mockUseHeatmap.mockReturnValue({ data: fullData.heatmap, isLoading: false });
|
||||||
|
mockUseListingsSearch.mockReturnValue({ data: { data: [], total: 0, page: 1, limit: 6, totalPages: 0 }, isLoading: false });
|
||||||
|
render(<DashboardPage />);
|
||||||
|
|
||||||
|
expect(screen.getByText('Bảng điều khiển')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('link', { name: /đăng tin mới/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
Check: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockFetchProfile = vi.fn();
|
||||||
|
const mockUseAuthStore = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/auth-store', () => ({
|
||||||
|
useAuthStore: (...args: unknown[]) => mockUseAuthStore(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/api-client', () => ({
|
||||||
|
apiClient: { patch: vi.fn().mockResolvedValue({}) },
|
||||||
|
}));
|
||||||
|
|
||||||
|
import KycPage from '../kyc/page';
|
||||||
|
|
||||||
|
function setupStore(overrides: Record<string, unknown> = {}) {
|
||||||
|
const store = {
|
||||||
|
user: {
|
||||||
|
id: 'user-1',
|
||||||
|
fullName: 'Nguyen Van A',
|
||||||
|
phone: '0912345678',
|
||||||
|
kycStatus: 'NONE',
|
||||||
|
...overrides,
|
||||||
|
},
|
||||||
|
fetchProfile: mockFetchProfile,
|
||||||
|
};
|
||||||
|
mockUseAuthStore.mockImplementation((selector?: (s: typeof store) => unknown) => {
|
||||||
|
if (typeof selector === 'function') return selector(store);
|
||||||
|
return store;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('KycPage — deep tests', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
setupStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders heading and NONE status', () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Xác minh danh tính (KYC)')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Chưa xác minh')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders step 1 with document type selector and number input', () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/số giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error and stays on step 1 when doc number is empty', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/vui lòng nhập số giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('advances from step 1 → step 2 after filling doc number', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/ảnh mặt trước/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error on step 2 when front image is missing', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
// Step 1 → 2
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-front-input')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to advance without uploading
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/vui lòng tải ảnh mặt trước/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('goes back from step 2 → step 1', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.type(screen.getByLabelText(/số giấy tờ/i), '012345678901');
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-back-button')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-back-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders VERIFIED state without form', () => {
|
||||||
|
setupStore({ kycStatus: 'VERIFIED' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Đã xác minh')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Danh tính đã được xác minh')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders PENDING state without form', () => {
|
||||||
|
setupStore({ kycStatus: 'PENDING' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Đang chờ duyệt')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang xem xét hồ sơ')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('kyc-next-button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders REJECTED state with form available', () => {
|
||||||
|
setupStore({ kycStatus: 'REJECTED' });
|
||||||
|
render(<KycPage />);
|
||||||
|
expect(screen.getByText('Bị từ chối')).toBeInTheDocument();
|
||||||
|
// Form should still show for resubmission
|
||||||
|
expect(screen.getByLabelText(/loại giấy tờ/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses error when close button is clicked', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
await userEvent.click(screen.getByTestId('kyc-next-button'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId('kyc-error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Đóng'));
|
||||||
|
expect(screen.queryByTestId('kyc-error')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes document type via select', async () => {
|
||||||
|
render(<KycPage />);
|
||||||
|
const select = screen.getByLabelText(/loại giấy tờ/i);
|
||||||
|
await userEvent.selectOptions(select, 'PASSPORT');
|
||||||
|
expect(select).toHaveValue('PASSPORT');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockUseTransactions = vi.fn();
|
||||||
|
|
||||||
|
const mockTransactions = {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'tx-1',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
amountVND: '499000',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
providerTxId: 'TXN123456789012',
|
||||||
|
createdAt: '2024-06-15T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tx-2',
|
||||||
|
type: 'LISTING_FEE',
|
||||||
|
status: 'PENDING',
|
||||||
|
amountVND: '100000',
|
||||||
|
provider: 'MOMO',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-20T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tx-3',
|
||||||
|
type: 'FEATURED_LISTING',
|
||||||
|
status: 'FAILED',
|
||||||
|
amountVND: '200000',
|
||||||
|
provider: 'ZALOPAY',
|
||||||
|
providerTxId: 'ZLP999',
|
||||||
|
createdAt: '2024-06-21T10:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-payments', () => ({
|
||||||
|
useTransactions: (...args: unknown[]) => mockUseTransactions(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PaymentsPage from '../payments/page';
|
||||||
|
|
||||||
|
describe('PaymentsPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockUseTransactions.mockReturnValue({ data: mockTransactions, isLoading: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders payment page heading and description', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByRole('heading', { level: 1, name: 'Thanh toán' })).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/lịch sử giao dịch/i).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders summary cards with correct values', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('Tổng giao dịch')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đã thanh toán')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang chờ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders transaction table with type/provider/status labels', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
// Type labels appear in desktop table + mobile cards, so use getAllByText
|
||||||
|
expect(screen.getAllByText('Gói dịch vụ').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Phí đăng tin').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Tin nổi bật').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Thành công').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Chờ xử lý').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getAllByText('Thất bại').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state', () => {
|
||||||
|
mockUseTransactions.mockReturnValue({ data: undefined, isLoading: true });
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getAllByText('...').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Đang tải...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state', () => {
|
||||||
|
mockUseTransactions.mockReturnValue({ data: { items: [], total: 0 }, isLoading: false });
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('Chưa có giao dịch nào')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes status filter via select', async () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
const select = screen.getByDisplayValue('Tất cả');
|
||||||
|
await userEvent.selectOptions(select, 'COMPLETED');
|
||||||
|
|
||||||
|
expect(mockUseTransactions).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ status: 'COMPLETED' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('truncates long providerTxId', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('TXN123456789...')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows dash for missing providerTxId', () => {
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders pagination when more than 1 page', () => {
|
||||||
|
// 25 total with limit 20 = 2 pages
|
||||||
|
const manyItems = Array.from({ length: 20 }, (_, i) => ({
|
||||||
|
id: `tx-${i}`,
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
amountVND: '100000',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00.000Z',
|
||||||
|
}));
|
||||||
|
mockUseTransactions.mockReturnValue({
|
||||||
|
data: { items: manyItems, total: 25 },
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
render(<PaymentsPage />);
|
||||||
|
expect(screen.getByText(/trang 1\/2/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /trước/i })).toBeDisabled();
|
||||||
|
expect(screen.getByRole('button', { name: /sau/i })).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,8 +64,49 @@ function SearchContent() {
|
|||||||
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
const [saveAlertEnabled, setSaveAlertEnabled] = React.useState(true);
|
||||||
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
const [saveSuccess, setSaveSuccess] = React.useState(false);
|
||||||
|
|
||||||
|
const saveDialogRef = React.useRef<HTMLDivElement>(null);
|
||||||
|
const saveButtonRef = React.useRef<HTMLButtonElement>(null);
|
||||||
|
const saveNameInputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const createSavedSearch = useCreateSavedSearch();
|
const createSavedSearch = useCreateSavedSearch();
|
||||||
|
|
||||||
|
// Focus management for save-search dialog
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (showSaveDialog) {
|
||||||
|
saveNameInputRef.current?.focus();
|
||||||
|
}
|
||||||
|
}, [showSaveDialog]);
|
||||||
|
|
||||||
|
// Focus trap + Escape key for save-search dialog
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!showSaveDialog) return;
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setShowSaveDialog(false);
|
||||||
|
saveButtonRef.current?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === 'Tab') {
|
||||||
|
const dialog = saveDialogRef.current;
|
||||||
|
if (!dialog) return;
|
||||||
|
const focusable = dialog.querySelectorAll<HTMLElement>(
|
||||||
|
'button, input, [tabindex]:not([tabindex="-1"])',
|
||||||
|
);
|
||||||
|
const first = focusable[0];
|
||||||
|
const last = focusable[focusable.length - 1];
|
||||||
|
if (e.shiftKey && document.activeElement === first) {
|
||||||
|
e.preventDefault();
|
||||||
|
last?.focus();
|
||||||
|
} else if (!e.shiftKey && document.activeElement === last) {
|
||||||
|
e.preventDefault();
|
||||||
|
first?.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [showSaveDialog]);
|
||||||
|
|
||||||
const handleMarkerClick = (listing: ListingDetail) => {
|
const handleMarkerClick = (listing: ListingDetail) => {
|
||||||
setSelectedListingId(listing.id);
|
setSelectedListingId(listing.id);
|
||||||
};
|
};
|
||||||
@@ -163,11 +204,15 @@ function SearchContent() {
|
|||||||
{activeFilterCount > 0 && (
|
{activeFilterCount > 0 && (
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Button
|
<Button
|
||||||
|
ref={saveButtonRef}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
onClick={() => setShowSaveDialog(!showSaveDialog)}
|
||||||
|
aria-expanded={showSaveDialog}
|
||||||
|
aria-controls="save-search-dialog"
|
||||||
|
aria-haspopup="dialog"
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z" />
|
||||||
</svg>
|
</svg>
|
||||||
Lưu tìm kiếm
|
Lưu tìm kiếm
|
||||||
@@ -175,10 +220,17 @@ function SearchContent() {
|
|||||||
|
|
||||||
{/* Save search dialog */}
|
{/* Save search dialog */}
|
||||||
{showSaveDialog && (
|
{showSaveDialog && (
|
||||||
<div className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg">
|
<div
|
||||||
|
id="save-search-dialog"
|
||||||
|
ref={saveDialogRef}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="save-search-heading"
|
||||||
|
className="absolute right-0 top-full z-50 mt-2 w-80 rounded-lg border bg-card p-4 shadow-lg"
|
||||||
|
>
|
||||||
{saveSuccess ? (
|
{saveSuccess ? (
|
||||||
<div className="flex items-center gap-2 text-green-600">
|
<div className="flex items-center gap-2 text-green-600">
|
||||||
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
<span className="text-sm font-medium">Đã lưu tìm kiếm!</span>
|
||||||
@@ -188,6 +240,7 @@ function SearchContent() {
|
|||||||
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
<h3 className="mb-3 font-medium" id="save-search-heading">Lưu bộ lọc tìm kiếm</h3>
|
||||||
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
<label htmlFor="save-search-name" className="sr-only">Tên tìm kiếm</label>
|
||||||
<input
|
<input
|
||||||
|
ref={saveNameInputRef}
|
||||||
id="save-search-name"
|
id="save-search-name"
|
||||||
type="text"
|
type="text"
|
||||||
value={saveName}
|
value={saveName}
|
||||||
@@ -246,8 +299,9 @@ function SearchContent() {
|
|||||||
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
variant={viewMode === 'list' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('list')}
|
onClick={() => setViewMode('list')}
|
||||||
|
aria-pressed={viewMode === 'list'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
||||||
</svg>
|
</svg>
|
||||||
Danh sách
|
Danh sách
|
||||||
@@ -256,8 +310,9 @@ function SearchContent() {
|
|||||||
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
variant={viewMode === 'map' ? 'default' : 'ghost'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setViewMode('map')}
|
onClick={() => setViewMode('map')}
|
||||||
|
aria-pressed={viewMode === 'map'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
|
||||||
</svg>
|
</svg>
|
||||||
Bản đồ
|
Bản đồ
|
||||||
@@ -267,8 +322,9 @@ function SearchContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="hidden lg:flex"
|
className="hidden lg:flex"
|
||||||
onClick={() => setViewMode('split')}
|
onClick={() => setViewMode('split')}
|
||||||
|
aria-pressed={viewMode === 'split'}
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
|
||||||
</svg>
|
</svg>
|
||||||
Chia đôi
|
Chia đôi
|
||||||
@@ -280,8 +336,10 @@ function SearchContent() {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="lg:hidden"
|
className="lg:hidden"
|
||||||
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
onClick={() => setShowMobileFilters(!showMobileFilters)}
|
||||||
|
aria-expanded={showMobileFilters}
|
||||||
|
aria-controls="mobile-filter-panel"
|
||||||
>
|
>
|
||||||
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg aria-hidden="true" className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
|
||||||
</svg>
|
</svg>
|
||||||
Bộ lọc
|
Bộ lọc
|
||||||
@@ -305,7 +363,7 @@ function SearchContent() {
|
|||||||
|
|
||||||
{/* Mobile filter panel */}
|
{/* Mobile filter panel */}
|
||||||
{showMobileFilters && (
|
{showMobileFilters && (
|
||||||
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
<div id="mobile-filter-panel" className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
|
||||||
<FilterBar
|
<FilterBar
|
||||||
filters={filters}
|
filters={filters}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
@@ -392,7 +450,11 @@ export default function SearchPage() {
|
|||||||
return (
|
return (
|
||||||
<React.Suspense
|
<React.Suspense
|
||||||
fallback={
|
fallback={
|
||||||
<div className="flex min-h-[400px] items-center justify-center">
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-label="Đang tải..."
|
||||||
|
className="flex min-h-[400px] items-center justify-center"
|
||||||
|
>
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { TransferItemTable } from '../transfer-item-table';
|
||||||
|
|
||||||
|
const baseItem = {
|
||||||
|
id: 'i1',
|
||||||
|
name: 'Tủ lạnh Toshiba',
|
||||||
|
brand: 'Toshiba',
|
||||||
|
modelName: 'GR-RT624WE-PMV',
|
||||||
|
category: 'APPLIANCE' as const,
|
||||||
|
condition: 'GOOD' as const,
|
||||||
|
purchaseYear: 2022,
|
||||||
|
originalPriceVND: '15000000',
|
||||||
|
askingPriceVND: '8000000',
|
||||||
|
aiEstimatePriceVND: '7500000',
|
||||||
|
aiConfidence: 0.85,
|
||||||
|
quantity: 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransferItemTable', () => {
|
||||||
|
it('renders empty state when no items', () => {
|
||||||
|
render(<TransferItemTable items={[]} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText('Chưa có danh sách vật phẩm.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all column headers', () => {
|
||||||
|
render(<TransferItemTable items={[baseItem]} />);
|
||||||
|
expect(screen.getByText('Tên')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Loại')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Tình trạng')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thương hiệu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('SL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá yêu cầu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Giá AI')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item row with localized currency formatting', () => {
|
||||||
|
render(<TransferItemTable items={[baseItem]} />);
|
||||||
|
expect(screen.getByText('Tủ lạnh Toshiba')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GR-RT624WE-PMV')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/8\.000\.000/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/7\.500\.000/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to em-dash for missing brand and AI estimate', () => {
|
||||||
|
render(
|
||||||
|
<TransferItemTable
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
...baseItem,
|
||||||
|
id: 'i2',
|
||||||
|
brand: null,
|
||||||
|
aiEstimatePriceVND: null,
|
||||||
|
aiConfidence: null,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const dashes = screen.getAllByText('—');
|
||||||
|
expect(dashes.length).toBeGreaterThanOrEqual(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { TransferListingCard } from '../transfer-listing-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseListing = {
|
||||||
|
id: 'tl1',
|
||||||
|
sellerId: 's1',
|
||||||
|
category: 'FURNITURE' as const,
|
||||||
|
status: 'ACTIVE' as const,
|
||||||
|
title: 'Bộ sofa gỗ còn mới 90%',
|
||||||
|
address: '123 Lê Lợi',
|
||||||
|
district: 'Quận 1',
|
||||||
|
city: 'TP.HCM',
|
||||||
|
latitude: 10.77,
|
||||||
|
longitude: 106.7,
|
||||||
|
askingPriceVND: '4500000',
|
||||||
|
aiEstimatePriceVND: null,
|
||||||
|
pricingSource: 'MANUAL' as const,
|
||||||
|
isNegotiable: true,
|
||||||
|
areaM2: 12,
|
||||||
|
itemCount: 5,
|
||||||
|
viewCount: 42,
|
||||||
|
publishedAt: '2026-04-10T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TransferListingCard', () => {
|
||||||
|
it('renders title, location and formatted price', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Bộ sofa gỗ còn mới 90%')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Quận 1, TP\.HCM/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/4\.500\.000/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders ACTIVE status with green color and "Thương lượng" when negotiable', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('Đang đăng')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Thương lượng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders item and view counts and square-meter area', () => {
|
||||||
|
render(<TransferListingCard listing={baseListing} />);
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('42')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/12 m/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to listing detail by id', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<TransferListingCard listing={baseListing} />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||||
|
'/chuyen-nhuong/tl1',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits publish date footer when publishedAt is null', () => {
|
||||||
|
render(
|
||||||
|
<TransferListingCard
|
||||||
|
listing={{ ...baseListing, publishedAt: null }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText(/Đăng/)).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { act, render, renderHook, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
DENSITY_CELL_PADDING,
|
||||||
|
DENSITY_DATA_FONT,
|
||||||
|
DENSITY_ROW_HEIGHT,
|
||||||
|
DensityProvider,
|
||||||
|
useDensity,
|
||||||
|
} from '../density-provider';
|
||||||
|
|
||||||
|
// jsdom (opaque origin) does not provide a usable localStorage; install a tiny in-memory shim.
|
||||||
|
function installLocalStorage(): Storage {
|
||||||
|
const store: Record<string, string> = {};
|
||||||
|
const fake: Storage = {
|
||||||
|
get length() {
|
||||||
|
return Object.keys(store).length;
|
||||||
|
},
|
||||||
|
clear: () => {
|
||||||
|
for (const k of Object.keys(store)) delete store[k];
|
||||||
|
},
|
||||||
|
getItem: (k) => (k in store ? store[k]! : null),
|
||||||
|
key: (i) => Object.keys(store)[i] ?? null,
|
||||||
|
removeItem: (k) => {
|
||||||
|
delete store[k];
|
||||||
|
},
|
||||||
|
setItem: (k, v) => {
|
||||||
|
store[k] = String(v);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
configurable: true,
|
||||||
|
value: fake,
|
||||||
|
});
|
||||||
|
return fake;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DensityProvider', () => {
|
||||||
|
let storage: Storage;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storage = installLocalStorage();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (typeof storage.clear === 'function') {
|
||||||
|
storage.clear();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes default density "regular" via useDensity', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||||
|
});
|
||||||
|
expect(result.current.density).toBe('regular');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honors the defaultDensity prop', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => (
|
||||||
|
<DensityProvider defaultDensity="compact">{children}</DensityProvider>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
expect(result.current.density).toBe('compact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('persists density changes to localStorage', () => {
|
||||||
|
const { result } = renderHook(() => useDensity(), {
|
||||||
|
wrapper: ({ children }) => <DensityProvider>{children}</DensityProvider>,
|
||||||
|
});
|
||||||
|
act(() => result.current.setDensity('roomy'));
|
||||||
|
expect(result.current.density).toBe('roomy');
|
||||||
|
expect(localStorage.getItem('goodgo.density')).toBe('roomy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads stored density on mount when valid', () => {
|
||||||
|
localStorage.setItem('goodgo.density', 'compact');
|
||||||
|
function Probe() {
|
||||||
|
const { density } = useDensity();
|
||||||
|
return <span data-testid="d">{density}</span>;
|
||||||
|
}
|
||||||
|
render(
|
||||||
|
<DensityProvider>
|
||||||
|
<Probe />
|
||||||
|
</DensityProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('d').textContent).toBe('compact');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes row-height, padding and font tables for all densities', () => {
|
||||||
|
for (const mode of ['compact', 'regular', 'roomy'] as const) {
|
||||||
|
expect(DENSITY_ROW_HEIGHT[mode]).toBeTruthy();
|
||||||
|
expect(DENSITY_CELL_PADDING[mode]).toBeTruthy();
|
||||||
|
expect(DENSITY_DATA_FONT[mode]).toBeTruthy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
78
apps/web/components/du-an/__tests__/project-card.spec.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ProjectCard } from '../project-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('next/image', () => ({
|
||||||
|
default: ({ alt, src }: { alt: string; src: string }) => (
|
||||||
|
<img alt={alt} src={src} />
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseProject = {
|
||||||
|
id: 'p1',
|
||||||
|
slug: 'vinhomes-central-park',
|
||||||
|
name: 'Vinhomes Central Park',
|
||||||
|
status: 'UNDER_CONSTRUCTION' as const,
|
||||||
|
developer: { id: 'd1', name: 'Vingroup' },
|
||||||
|
city: 'TP.HCM',
|
||||||
|
district: 'Bình Thạnh',
|
||||||
|
address: '208 Nguyễn Hữu Cảnh',
|
||||||
|
latitude: 10.79,
|
||||||
|
longitude: 106.72,
|
||||||
|
thumbnailUrl: 'https://example.com/t.jpg',
|
||||||
|
totalArea: 43,
|
||||||
|
totalUnits: 10000,
|
||||||
|
propertyTypes: ['APARTMENT', 'VILLA'] as ('APARTMENT' | 'VILLA')[],
|
||||||
|
minPrice: '3500000000',
|
||||||
|
maxPrice: '20000000000',
|
||||||
|
completionDate: null,
|
||||||
|
createdAt: '2026-01-01T00:00:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ProjectCard', () => {
|
||||||
|
it('renders name, location, developer and status label', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(screen.getByText('Vinhomes Central Park')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Bình Thạnh, TP\.HCM/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Vingroup')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Đang xây dựng')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to project detail by slug', () => {
|
||||||
|
const { container } = render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(container.querySelector('a')?.getAttribute('href')).toBe(
|
||||||
|
'/du-an/vinhomes-central-park',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders thumbnail image when thumbnailUrl present', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
const img = screen.getByAltText('Vinhomes Central Park') as HTMLImageElement;
|
||||||
|
expect(img.src).toContain('t.jpg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders "Liên hệ" when minPrice is null', () => {
|
||||||
|
render(
|
||||||
|
<ProjectCard project={{ ...baseProject, minPrice: null, maxPrice: null }} />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Liên hệ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders unit count with "căn" suffix', () => {
|
||||||
|
render(<ProjectCard project={baseProject} />);
|
||||||
|
expect(screen.getByText('10000 căn')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { NotificationsProvider } from '../notifications-provider';
|
||||||
|
|
||||||
|
const useSocketNotificationsMock = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/hooks/use-socket-notifications', () => ({
|
||||||
|
useSocketNotifications: () => useSocketNotificationsMock(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('NotificationsProvider', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
render(
|
||||||
|
<NotificationsProvider>
|
||||||
|
<div>child</div>
|
||||||
|
</NotificationsProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('child')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes socket notifications hook on mount', () => {
|
||||||
|
useSocketNotificationsMock.mockClear();
|
||||||
|
render(
|
||||||
|
<NotificationsProvider>
|
||||||
|
<span>x</span>
|
||||||
|
</NotificationsProvider>,
|
||||||
|
);
|
||||||
|
expect(useSocketNotificationsMock).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { QueryProvider } from '../query-provider';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/query-client', () => {
|
||||||
|
const { QueryClient } = require('@tanstack/react-query');
|
||||||
|
return { getQueryClient: () => new QueryClient() };
|
||||||
|
});
|
||||||
|
|
||||||
|
function Boom() {
|
||||||
|
throw new Error('query-fail');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('QueryProvider', () => {
|
||||||
|
let spy: ReturnType<typeof vi.spyOn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
spy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children under provider', () => {
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<div>ok</div>
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('ok')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('catches thrown errors and renders fallback with retry button', () => {
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<Boom />
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
// error.description & common.retry keys surface via mocked translator
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'common.retry' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('surfaces the underlying error message in fallback', () => {
|
||||||
|
render(
|
||||||
|
<QueryProvider>
|
||||||
|
<Boom />
|
||||||
|
</QueryProvider>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('query-fail')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
77
apps/web/components/reports/__tests__/report-card.spec.tsx
Normal file
77
apps/web/components/reports/__tests__/report-card.spec.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import * as React from 'react';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
import { ReportCard } from '../report-card';
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({
|
||||||
|
children,
|
||||||
|
href,
|
||||||
|
...rest
|
||||||
|
}: React.PropsWithChildren<{ href: string } & Record<string, unknown>>) => (
|
||||||
|
<a href={href} {...rest}>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const baseReport = {
|
||||||
|
id: 'r1',
|
||||||
|
type: 'RESIDENTIAL_MARKET' as const,
|
||||||
|
title: 'Báo cáo thị trường Q1',
|
||||||
|
params: {},
|
||||||
|
content: null,
|
||||||
|
pdfUrl: null,
|
||||||
|
status: 'READY' as const,
|
||||||
|
errorMsg: null,
|
||||||
|
createdAt: '2026-04-01T08:30:00.000Z',
|
||||||
|
updatedAt: '2026-04-01T08:30:00.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('ReportCard', () => {
|
||||||
|
it('renders title and type/status badges', () => {
|
||||||
|
render(<ReportCard report={baseReport} />);
|
||||||
|
expect(screen.getByText('Báo cáo thị trường Q1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Nhà ở')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hoàn thành')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('links to report detail for READY report (both detail icon link and bottom "Xem báo cáo" link)', () => {
|
||||||
|
const { container } = render(<ReportCard report={baseReport} />);
|
||||||
|
const links = container.querySelectorAll('a[href="/dashboard/reports/r1"]');
|
||||||
|
expect(links.length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Xem báo cáo')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render "Xem báo cáo" link for non-READY reports', () => {
|
||||||
|
render(
|
||||||
|
<ReportCard report={{ ...baseReport, status: 'GENERATING' }} />,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Xem báo cáo')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error message for FAILED report with errorMsg', () => {
|
||||||
|
render(
|
||||||
|
<ReportCard
|
||||||
|
report={{
|
||||||
|
...baseReport,
|
||||||
|
status: 'FAILED',
|
||||||
|
errorMsg: 'Thiếu dữ liệu',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Thiếu dữ liệu')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Lỗi')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invokes onDelete with report id when delete button clicked', () => {
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
render(<ReportCard report={baseReport} onDelete={onDelete} />);
|
||||||
|
const trashButton = screen
|
||||||
|
.getAllByRole('button')
|
||||||
|
.find((b) => b.className.includes('text-destructive'));
|
||||||
|
expect(trashButton).toBeDefined();
|
||||||
|
fireEvent.click(trashButton!);
|
||||||
|
expect(onDelete).toHaveBeenCalledWith('r1');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
// Mock lucide-react icons
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
AlertCircle: (props: Record<string, unknown>) => <span data-testid="alert-icon" {...props} />,
|
||||||
|
CreditCard: (props: Record<string, unknown>) => <span data-testid="credit-card-icon" {...props} />,
|
||||||
|
Loader2: (props: Record<string, unknown>) => <span data-testid="loader-icon" {...props} />,
|
||||||
|
Smartphone: (props: Record<string, unknown>) => <span data-testid="smartphone-icon" {...props} />,
|
||||||
|
Wallet: (props: Record<string, unknown>) => <span data-testid="wallet-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockCreatePayment = vi.fn();
|
||||||
|
const mockCreateSubscription = vi.fn();
|
||||||
|
const mockUpgradeSubscription = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/payment-api', () => ({
|
||||||
|
paymentApi: {
|
||||||
|
createPayment: (...args: unknown[]) => mockCreatePayment(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/subscription-api', () => ({
|
||||||
|
subscriptionApi: {
|
||||||
|
createSubscription: (...args: unknown[]) => mockCreateSubscription(...args),
|
||||||
|
upgradeSubscription: (...args: unknown[]) => mockUpgradeSubscription(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/components/error-boundary', () => ({
|
||||||
|
ComponentErrorBoundary: ({ children }: { children: React.ReactNode }) => <>{children}</>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { CheckoutModal } from '../checkout-modal';
|
||||||
|
|
||||||
|
const basePlan = {
|
||||||
|
id: 'plan-1',
|
||||||
|
tier: 'AGENT_PRO',
|
||||||
|
name: 'Môi giới Pro',
|
||||||
|
priceMonthlyVND: '499000',
|
||||||
|
priceYearlyVND: '4990000',
|
||||||
|
maxListings: 50,
|
||||||
|
maxSavedSearches: 10,
|
||||||
|
features: {},
|
||||||
|
isActive: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('CheckoutModal', () => {
|
||||||
|
const onOpenChange = vi.fn();
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockCreatePayment.mockResolvedValue({ paymentUrl: 'https://vnpay.vn/pay', paymentId: 'p1', providerTxId: 'tx1' });
|
||||||
|
mockCreateSubscription.mockResolvedValue({ subscriptionId: 's1' });
|
||||||
|
mockUpgradeSubscription.mockResolvedValue({ subscriptionId: 's1' });
|
||||||
|
// Mock window.location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
writable: true,
|
||||||
|
value: { ...window.location, href: 'http://localhost:3000/vi/pricing', origin: 'http://localhost:3000', pathname: '/vi/pricing' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders nothing when plan is null', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={null} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
expect(container.querySelector('[role="dialog"]')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders order summary with plan name and monthly price', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Đăng ký gói dịch vụ')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText('Môi giới Pro').length).toBeGreaterThanOrEqual(1);
|
||||||
|
expect(screen.getByText('Hàng tháng')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/499,000/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders yearly badge and yearly price', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="yearly" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Hàng năm')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('-17%')).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByText(/4,990,000/).length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders upgrade title when isUpgrade=true', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" isUpgrade currentTier="FREE" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Nâng cấp gói dịch vụ')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/Miễn phí/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all three payment providers', () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('VNPay')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('MoMo')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('ZaloPay')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('selects a different payment provider on click', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
const momoButton = screen.getByText('MoMo').closest('button')!;
|
||||||
|
await userEvent.click(momoButton);
|
||||||
|
// MoMo button should now have primary styling (ring-1 ring-primary)
|
||||||
|
expect(momoButton.className).toContain('border-primary');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls createSubscription + createPayment on checkout', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockCreateSubscription).toHaveBeenCalledWith('AGENT_PRO', 'monthly');
|
||||||
|
expect(mockCreatePayment).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: 499000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls upgradeSubscription instead of createSubscription for upgrades', async () => {
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" isUpgrade />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpgradeSubscription).toHaveBeenCalledWith('AGENT_PRO');
|
||||||
|
expect(mockCreateSubscription).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays error message when payment fails', async () => {
|
||||||
|
mockCreateSubscription.mockRejectedValue(new Error('Hệ thống bận'));
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Hệ thống bận')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables close button and providers while processing', async () => {
|
||||||
|
// Make the payment hang
|
||||||
|
mockCreateSubscription.mockReturnValue(new Promise(() => {}));
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText(/đang xử lý/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: /hủy/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismiss error on close button click', async () => {
|
||||||
|
mockCreateSubscription.mockRejectedValue(new Error('Lỗi test'));
|
||||||
|
render(
|
||||||
|
<CheckoutModal open={true} onOpenChange={onOpenChange} plan={basePlan} billingCycle="monthly" />,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByRole('button', { name: /thanh toán/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Lỗi test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByText('Đóng'));
|
||||||
|
expect(screen.queryByText('Lỗi test')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/* eslint-disable import-x/order */
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('lucide-react', () => ({
|
||||||
|
CheckCircle: (props: Record<string, unknown>) => <span data-testid="check-icon" {...props} />,
|
||||||
|
Clock: (props: Record<string, unknown>) => <span data-testid="clock-icon" {...props} />,
|
||||||
|
Loader2: (props: Record<string, unknown>) => <span data-testid="loader-icon" {...props} />,
|
||||||
|
XCircle: (props: Record<string, unknown>) => <span data-testid="x-icon" {...props} />,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGetPaymentStatus = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/lib/payment-api', () => ({
|
||||||
|
paymentApi: {
|
||||||
|
getPaymentStatus: (...args: unknown[]) => mockGetPaymentStatus(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/lib/currency', () => ({
|
||||||
|
formatVND: (amount: string | number) => `${Number(amount).toLocaleString()} đ`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let mockSearchParams = new URLSearchParams();
|
||||||
|
vi.mock('next/navigation', () => ({
|
||||||
|
useSearchParams: () => mockSearchParams,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@/i18n/navigation', () => ({
|
||||||
|
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
|
||||||
|
<a href={href} {...props}>{children}</a>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import PaymentReturnPage from '@/app/[locale]/(public)/payment/return/page';
|
||||||
|
|
||||||
|
describe('PaymentReturnPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "not found" when no paymentId in search params', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams();
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Không tìm thấy giao dịch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Xem bảng giá')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Về trang chủ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows success state for COMPLETED payment', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ paymentId: 'p1' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'p1',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
providerTxId: 'TXN123',
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Thanh toán thành công!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText(/499,000/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('VNPAY')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Xem gói dịch vụ')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows failed state for FAILED payment', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ paymentId: 'p2' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'p2',
|
||||||
|
provider: 'MOMO',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'FAILED',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Thanh toán thất bại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Thử lại')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows cancelled state', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ paymentId: 'p3' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'p3',
|
||||||
|
provider: 'ZALOPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'CANCELLED',
|
||||||
|
providerTxId: null,
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Giao dịch đã hủy')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reads vnp_TxnRef as fallback paymentId', async () => {
|
||||||
|
mockSearchParams = new URLSearchParams({ vnp_TxnRef: 'vnp-123' });
|
||||||
|
mockGetPaymentStatus.mockResolvedValue({
|
||||||
|
id: 'vnp-123',
|
||||||
|
provider: 'VNPAY',
|
||||||
|
type: 'SUBSCRIPTION',
|
||||||
|
amountVND: '499000',
|
||||||
|
status: 'COMPLETED',
|
||||||
|
providerTxId: 'TXN999',
|
||||||
|
createdAt: '2024-06-15T10:00:00Z',
|
||||||
|
updatedAt: '2024-06-15T10:01:00Z',
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<PaymentReturnPage />);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockGetPaymentStatus).toHaveBeenCalledWith('vnp-123');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user