This document describes the end-to-end integration tests that validate the membership flow from contract events to API access decisions.
The integration test suite validates the complete flow:
MembershipNFT Contract Events
↓
Event Fixtures (decoded)
↓
Database State (via Event Helpers)
↓
Policy Engine (evaluates access)
↓
API Response (access allowed/denied)
-
Event Fixtures (
membership-integration.test.ts)- Decoded contract events representing real-world scenarios
- Derived directly from
MembershipNFT.solevent definitions - Cover: active, expired, suspended, and renewed memberships
-
Event Helpers (
services/contractEventHelpers.ts)- Reusable functions for processing contract events
- Designed for both test fixtures and future event indexer
- Provides idempotent database updates
-
Integration Tests (
membership-integration.test.ts)- Fastify app injection for realistic API testing
- Database transactions for test isolation
- Multiple test scenarios covering acceptance criteria
# Install dependencies
pnpm install
# Ensure PostgreSQL is running and DATABASE_URL is configured
export DATABASE_URL="postgresql://user:password@localhost:5432/guildpass"cd apps/access-api
pnpm testcd apps/access-api
pnpm test -- membership-integration.test.ts
pnpm test -- contractEventHelpers.test.tscd apps/access-api
pnpm test -- --coverageSetup:
- Apply
MembershipMintedevent with valid future expiry
Validation:
- ✅ Membership created in database with
state: 'active' - ✅ API
GET /v1/memberships/:walletreturnsstate: 'active' - ✅ API
POST /v1/access/checkwithMEMBERS_ONLYpolicy returnsallowed: true
Acceptance Criterion:
Event ingestion creates expected wallet and membership records
Setup:
- Apply
MembershipMintedevent with past expiry timestamp
Validation:
- ✅ Database stores
state: 'active'butexpiresAtin past - ✅
getNormalizedMembershipState()returns'expired'(computed at read-time) - ✅ API access check returns
allowed: falseandmembershipState: 'expired' - ✅ Policy engine reason includes expiry logic
Acceptance Criterion:
Expired memberships produce deny decisions
Setup:
- Apply
MembershipMintedevent (valid expiry) - Apply
MembershipSuspendedevent withisSuspended: true
Validation:
- ✅ Membership state changes to
'suspended' - ✅ API returns
state: 'suspended'even with valid expiry - ✅ API access check returns
allowed: false
Acceptance Criterion:
Suspended memberships produce deny decisions
Setup:
- Apply
MembershipMintedevent - Record initial
expiresAt - Apply
MembershipRenewedevent with future timestamp - Record updated
expiresAt
Validation:
- ✅ Renewal updates
expiresAtto new value - ✅ Membership remains
'active' - ✅ API access continues to return
allowed: true - ✅
renewedAttimestamp is updated
Acceptance Criterion:
Event ingestion creates expected wallet and membership records
PUBLIC Policy:
- ✅ Allows access regardless of membership state
MEMBERS_ONLY Policy:
- ✅ Denies expired members
- ✅ Denies suspended members
- ✅ Allows active members
ADMINS_ONLY Policy:
- ✅ Allows users with
adminrole assignment - ✅ Denies members without role
No Policy:
- ✅ Denies access when policy doesn't exist
{
type: 'MembershipMinted',
to: '0xwalletaddress', // wallet receiving membership
tokenId: 1, // unique token identifier
communityId: 'community-dev', // which community
expiresAt: 1700000000, // unix timestamp (seconds)
}Database Effect:
- Creates wallet if not exists
- Creates community if not exists
- Creates member if not exists in community
- Creates membership with
state: 'active'and token/expiry
{
type: 'MembershipRenewed',
tokenId: 1,
newExpiresAt: 1700000000,
}Database Effect:
- Updates membership
expiresAtfor the tokenId - Sets
renewedAtto now - Preserves other fields (state, communityId, etc.)
{
type: 'MembershipSuspended',
tokenId: 1,
isSuspended: true, // or false to unsuspend
}Database Effect:
- Updates membership
stateto'suspended'(if true) or'active'(if false) - Preserves expiry - suspension is independent of expiration
Apply a single decoded contract event to the database.
import { applyContractEvent } from './services/contractEventHelpers';
const event: DecodedMembershipMintedEvent = {
type: 'MembershipMinted',
to: '0xalice123...',
tokenId: 1,
communityId: 'dev',
expiresAt: Math.floor(Date.now() / 1000) + 30 * 24 * 60 * 60,
};
await applyContractEvent(prisma, event);Guarantees:
- Idempotent: calling twice with same event is safe
- Atomic: all related records (wallet, community, member, membership) are created/updated
- Validates: throws error if required fields missing
Apply multiple events in order (for batch processing or replay).
const count = await applyContractEvents(prisma, [event1, event2, event3]);
// Returns number of events successfully appliedQuery current membership state for a wallet in a community.
const state = await getCurrentMembershipState(prisma, '0xalice...', 'dev');
// Returns { tokenId, state, expiresAt } or nullGet or create a community (useful for test setup).
const community = await ensureCommunity(prisma, 'dev', 'Developer Guild');Check if a tokenId has already been minted (detect duplicates).
const exists = await tokenIdExists(prisma, 1);| Criterion | Test | Status |
|---|---|---|
| Test fixtures derived from MembershipNFT events | Event types match MembershipNFT.sol exactly | ✅ |
| Event ingestion creates expected wallet and membership records | Scenario 1: Active Membership |
✅ |
| API access checks reflect ingested membership state | All policy engine tests | ✅ |
| Suspended memberships produce deny decisions | Scenario 3: Suspended Membership |
✅ |
| Expired memberships produce deny decisions | Scenario 2: Expired Membership |
✅ |
| Tests run locally without live chain | No external RPC calls | ✅ |
| Fixtures can be extended for role-based access | Role assignment tests included | ✅ |
The contractEventHelpers module is designed to be reused by a future on-chain event indexer. Expected usage:
// Pseudocode for event indexer
import { applyContractEvent } from './services/contractEventHelpers';
// Listen to contract events
contract.on('MembershipMinted', async (event) => {
const decodedEvent: DecodedMembershipMintedEvent = {
type: 'MembershipMinted',
to: event.args.to,
tokenId: event.args.tokenId.toNumber(),
communityId: event.args.communityId,
expiresAt: event.args.expiresAt.toNumber(),
blockNumber: event.blockNumber,
transactionHash: event.transactionHash,
};
await applyContractEvent(prisma, decodedEvent);
});-
Event Order: Tests assume events are applied in order. Indexer should maintain FIFO ordering.
-
Timestamp Format: Contract emits unix seconds; database stores as milliseconds. Helpers handle conversion.
-
Wallet Case-Insensitivity: All wallet addresses are lowercased for consistency.
-
State Machine: Membership state transitions are:
active→suspended(via suspend event)suspended→active(via unsuspend event)active(with past expiry) → computed asexpired(not stored state)
-
Membership Uniqueness: One membership per (community, wallet) pair. Multiple mint events for same wallet/community overwrite.
-
Renewal Semantics: Renewal always succeeds and extends from max of (current expiry, now), never resets to now.
Cause: Test applied MembershipRenewed before MembershipMinted
Fix: Ensure events are applied in order; mints must precede renewals
Cause: Event fixture missing required fields (to, tokenId, etc.)
Fix: Check event fixtures in test file; all fields required
Cause: Database connection not closing
Fix: Ensure prisma.$disconnect() is called in afterAll hook
Cause: Timezone differences or database state not cleaned
Fix: Use beforeEach cleanup for each test; tests are isolated by transaction scope
- Batch Event Fixtures: Generate fixtures from contract ABI using automated tools
- Event Replay Testing: Add ability to replay real contract events from Etherscan
- Performance Tests: Add scenarios with 10k+ memberships for scaling
- Event Indexing Worker: Implement actual event listener using these helpers
- Webhook Events: Add tests for notifying external systems of membership changes