Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
191 changes: 191 additions & 0 deletions src/__tests__/discoverPromoCodes.integration.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
/**
* discoverPromoCodes asks the server to trim its response down to a fixed
* `fields` + `relations` whitelist (see actions.js). This test proves the
* hook still computes correct values when the response actually arrives
* trimmed — i.e. that the request whitelist covers everything the reducer
* writes to state and everything the hook later reads out of it.
*
* Why an integration test and not unit tests:
* Unit tests hand-build fixture objects that happen to include every
* field, so they can't detect a drift between "what the request asks
* for" and "what the hook consumes." Here the fixture is the full
* API response and the stubbed transport filters it exactly the way
* the real server does, so any missing field surfaces as a wrong
* derived value in the hook.
*
* Real (not mocked): discoverPromoCodes thunk, Redux store, reducer,
* usePromoCode hook.
* Stubbed: the HTTP transport (openstack-uicore-foundation's getRequest).
* The stub reads the outgoing `fields` + `relations` params off
* the request and filters FULL_PROMO_CODE the same way the API
* would before dispatching the success action.
*/

import { renderHook } from '@testing-library/react-hooks';
import { createStore, combineReducers, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';

// Every field /promo-codes/all/discover returns when no `fields` filter
// is supplied. Copied from a real API response so the stubbed transport
// filters against a realistic shape.
const FULL_PROMO_CODE = {
id: 4605,
created: 1779486332,
last_edited: 1784279933,
code: 'PRESALE',
redeemed: false,
email_sent: false,
source: 'ADMIN',
summit_id: 73,
creator_id: 26389,
quantity_available: 7250,
quantity_used: 486,
quantity_remaining: 6764,
valid_since_date: 1779778800,
valid_until_date: 1784358000,
class_name: 'DOMAIN_AUTHORIZED_PROMO_CODE',
description: 'Pre-Sale Registration: Members & Sponsors',
notes: '',
allows_to_delegate: false,
allows_to_reassign: true,
// Real responses can contain hundreds of domain strings; kept short here.
allowed_email_domains: ['@apple.com', '@amazon.com', '@microsoft.com'],
quantity_per_account: 25,
auto_apply: true,
badge_features: [],
allowed_ticket_types: [202],
tags: [],
remaining_quantity_per_account: 24,
};

// Every request the stubbed transport receives, so tests can assert what
// went over the wire (URL, `fields`, `relations`).
const requestLog = [];

// Filters a full record the way the API does:
// `fields=` — scalar whitelist. Scalar keys not listed are dropped.
// `relations=` — relation whitelist (arrays / nested objects). Same rule.
// Anything not listed in either is stripped from the response.
const RELATION_KEYS = new Set([
'allowed_email_domains',
'allowed_ticket_types',
'badge_features',
'tags',
]);
const filterRecord = (record, fields, relations) => {
const out = {};
const requestedScalars = new Set(fields ? fields.split(',') : []);
const requestedRelations = new Set(relations ? relations.split(',') : []);
for (const [key, value] of Object.entries(record)) {
if (RELATION_KEYS.has(key)) {
if (requestedRelations.has(key)) out[key] = value;
} else if (requestedScalars.has(key)) {
out[key] = value;
}
}
return out;
};

// Stubbed transport. `createAction` is preserved because the real reducer
// still needs to match the action types it produces. `getRequest` is
// replaced with a version that runs synchronously, records what was sent,
// filters FULL_PROMO_CODE by the request's `fields` + `relations` params,
// and dispatches the success action exactly as the real transport would.
jest.mock('openstack-uicore-foundation/lib/utils/actions', () => ({
createAction: (type) => (payload) => ({ type, payload }),
getRequest: (pre, success, url, _errorHandler) => (params) => (dispatch) => {
const { access_token, fields, relations, ...rest } = params || {};
requestLog.push({ url, fields, relations, otherParams: rest });
if (pre) dispatch(pre({}));
const filtered = filterRecord(FULL_PROMO_CODE, fields, relations);
dispatch(success({
response: {
total: 1,
per_page: 1,
current_page: 1,
last_page: 1,
data: [filtered],
},
}));
return Promise.resolve();
},
postRequest: jest.fn(),
deleteRequest: jest.fn(),
authErrorHandler: jest.fn(),
}));

jest.mock('sweetalert2', () => ({ __esModule: true, default: { fire: jest.fn() } }));

// eslint-disable-next-line import/first
import { discoverPromoCodes } from '../actions';
// eslint-disable-next-line import/first
import registrationLiteState from '../reducer';
// eslint-disable-next-line import/first
import usePromoCode from '../hooks/usePromoCode';

const buildStore = () => createStore(
combineReducers({ registrationLiteState }),
applyMiddleware(thunk.withExtraArgument({
apiBaseUrl: 'https://api.example.com',
getAccessToken: async () => 'INTEGRATION_TOKEN',
})),
);

beforeEach(() => {
requestLog.length = 0;
});

describe('discoverPromoCodes — request → reducer → hook, end to end', () => {
it('sends the fields and relations whitelist and stores only the fields that came back', async () => {
const store = buildStore();
await store.dispatch(discoverPromoCodes(73));

expect(requestLog).toHaveLength(1);
const req = requestLog[0];
expect(req.url).toBe('https://api.example.com/api/v1/summits/73/promo-codes/all/discover');
expect(req.fields).toBeDefined();
expect(req.relations).toBeDefined();

const { discoveredPromoCodes } = store.getState().registrationLiteState;
expect(discoveredPromoCodes).toHaveLength(1);
// Fields the widget does not request must not reach the client.
// allowed_email_domains is the primary reason the request is
// whitelisted — the unfiltered response can leak hundreds of email
// domains per code. description / creator_id / notes are internal
// admin metadata the widget has no reason to see.
expect(discoveredPromoCodes[0]).not.toHaveProperty('allowed_email_domains');
expect(discoveredPromoCodes[0]).not.toHaveProperty('description');
expect(discoveredPromoCodes[0]).not.toHaveProperty('creator_id');
expect(discoveredPromoCodes[0]).not.toHaveProperty('notes');
});

it('the hook derives correct values from the trimmed payload — no missing-field drift', async () => {
const store = buildStore();
await store.dispatch(discoverPromoCodes(73));
const discoveredPromoCodes = store.getState().registrationLiteState.discoveredPromoCodes;

// Feed the trimmed payload (as it really arrives from the reducer)
// into the hook and check every value the widget renders from it.
// If the request forgot to ask for a field the hook needs, one of
// these expectations comes back as null / undefined / NaN and fails.
const { result } = renderHook(() => usePromoCode({
discoveredPromoCodes,
promoCode: 'PRESALE',
promoCodeVerified: true,
promoCodeValidating: false,
applyPromoCode: jest.fn(() => Promise.resolve()),
removePromoCode: jest.fn(),
validatePromoCode: jest.fn(() => Promise.resolve()),
setFormPromoCode: jest.fn(),
ticketDataLoaded: true,
hasTickets: true,
}));

expect(result.current.state.suggestedCode).toBe('PRESALE');
expect(result.current.state.perAccountLimit).toBe(24);
expect(result.current.state.maxQuantityFromPromo).toBe(24); // min(remaining_per_account, quantity_available)
expect(result.current.state.isCodeValidForTicket({ id: 202 })).toBe(true);
expect(result.current.state.isCodeValidForTicket({ id: 999 })).toBe(false);
});

});
7 changes: 6 additions & 1 deletion src/actions.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,14 +108,19 @@ const customErrorHandler = (err, res) => (dispatch, state) => {
export const discoverPromoCodes = (summitId) => async (dispatch, getState, { apiBaseUrl, getAccessToken }) => {
try {
const accessToken = await getAccessToken();
const params = {
access_token: accessToken,
fields: 'code,auto_apply,quantity_per_account,remaining_quantity_per_account,quantity_available',
relations: 'allowed_ticket_types'
};
return getRequest(
createAction(DISCOVER_PROMO_CODES),
createAction(DISCOVER_PROMO_CODES_SUCCESS),
`${apiBaseUrl}/api/v1/summits/${summitId}/promo-codes/all/discover`,
// Discovery is non-blocking - errors silently ignored.
// Auth errors will surface on the next user-initiated action.
null
)({ access_token: accessToken })(dispatch);
)(params)(dispatch);
} catch (e) {
console.log(e);
return null;
Expand Down
170 changes: 170 additions & 0 deletions src/hooks/__tests__/usePromoCode.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1004,3 +1004,173 @@ describe('status: INVALID without ticket', () => {
expect(result.current.state.status).toBe(PROMO_STATUS.APPLYING);
});
});

// The exact set of fields the widget requests from the server in
// actions.js#discoverPromoCodes (via the `fields` and `relations` params).
// Any field this hook reads on a discovered promo code MUST be in this
// list — otherwise it will arrive as undefined in production, even though
// tests that hand-build fixture objects will keep passing.
const REQUESTED_SCALAR_FIELDS = [
'code',
'auto_apply',
'quantity_per_account',
'remaining_quantity_per_account',
'quantity_available',
];
const REQUESTED_RELATIONS = ['allowed_ticket_types'];

// Why this block exists:
// discoverPromoCodes asks the server for a narrow subset of fields. If a
// future change to usePromoCode starts reading a field that isn't on the
// requested list, the field is undefined at runtime — but a normal unit
// test would still pass because its fixture object was built by hand and
// happens to include the field. Static grep can miss aliases, computed
// access, spread and destructuring. This block catches all of that by
// wrapping the fixture in a Proxy and observing what JavaScript actually
// reads while the hook runs through its full lifecycle.
//
// How it works:
// - `observe(target)` returns a Proxy that records every read, `in` check,
// and enumeration (`{...code}`, `Object.keys`, `JSON.stringify`).
// - Enumeration ops record a sentinel (`__enumeration__`) rather than a
// field name, because they leak the whole shape and there's no single
// field to blame.
// - `exerciseFlows` drives the hook through auto-apply, ticket
// qualification, quantity caps, manual entry, and removal.
// - The assertion demands every recorded key is in the requested list.
describe('every field the hook reads on a discovered promo code is one the API request asks for', () => {
const observe = (target, accessed) => new Proxy(target, {
get(t, prop) {
if (typeof prop === 'string') accessed.add(prop);
return t[prop];
},
has(t, prop) {
if (typeof prop === 'string') accessed.add(prop);
return Reflect.has(t, prop);
},
ownKeys(t) {
accessed.add('__enumeration__');
return Reflect.ownKeys(t);
},
getOwnPropertyDescriptor(t, prop) {
accessed.add('__enumeration__');
return Reflect.getOwnPropertyDescriptor(t, prop);
},
});

const REQUESTED = new Set([...REQUESTED_SCALAR_FIELDS, ...REQUESTED_RELATIONS]);
// Keys that fire on any object regardless of consumer code, and so
// shouldn't count as a "field access." `then` is checked by `await` /
// `Promise.resolve`; `toJSON` is probed by `JSON.stringify` (and the
// real leak from stringify is already caught via __enumeration__).
const IGNORED_KEYS = new Set(['then', 'toJSON']);
const isFieldAccess = (k) => !IGNORED_KEYS.has(k);

const exerciseFlows = async (proxiedCode, extraProps = {}) => {
const applyPromoCode = jest.fn(() => Promise.resolve());
const validatePromoCode = jest.fn(() => Promise.resolve());
const removePromoCode = jest.fn();
const setFormPromoCode = jest.fn();
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));

const baseProps = createDefaultProps({
discoveredPromoCodes: [proxiedCode],
applyPromoCode,
validatePromoCode,
removePromoCode,
setFormPromoCode,
ticketDataLoaded: true,
...extraProps,
});

const { result, rerender } = renderHook((p) => usePromoCode(p), {
initialProps: baseProps,
});

// Early auto-apply effect fires
await act(async () => { await flushPromises(); });

// Simulate Redux state update after auto-apply
const appliedProps = { ...baseProps, promoCode: proxiedCode.code, promoCodeVerified: true };
rerender(appliedProps);

// Read every state field the hook exposes (some are memoised — force
// them to compute by touching them)
void result.current.state.status;
void result.current.state.suggestedCode;
void result.current.state.isDiscoveredCode;
void result.current.state.isAutoApplied;
void result.current.state.maxQuantityFromPromo;
void result.current.state.perAccountLimit;
void result.current.state.validationError;
void result.current.state.isReady;

// Ticket qualification against a matching + non-matching id
result.current.state.isCodeValidForTicket({ id: 1 });
result.current.state.isCodeValidForTicket({ id: 999 });

// Ticket selection (auto-apply path + revalidation path)
await act(async () => {
await result.current.actions.onTicketSelected({ id: 1, sub_type: 'Regular' });
});

// Input change + suggestion dismiss/restore
act(() => { result.current.actions.onInputChange(proxiedCode.code); });
act(() => { result.current.actions.onInputChange('different'); });

// Removal
act(() => { result.current.actions.onRemove(); });
};

it('reads no field outside the requested list across the full hook lifecycle', async () => {
const accessed = new Set();
const proxied = observe({
code: 'AUDIT_CODE',
auto_apply: true,
allowed_ticket_types: [{ id: 1 }],
quantity_per_account: 5,
remaining_quantity_per_account: 3,
quantity_available: 20,
}, accessed);

await exerciseFlows(proxied);

const fieldAccesses = [...accessed].filter(isFieldAccess);
const outsideWhitelist = fieldAccesses.filter((f) => !REQUESTED.has(f));

// If this fails, either add the new field to the `fields` /
// `relations` params in actions.js#discoverPromoCodes OR remove the
// stray access from the hook.
expect(outsideWhitelist).toEqual([]);
// Belt-and-suspenders: the recorded set equals the subset of the
// requested list that this run actually touched. Skipped when the
// first assertion fires.
expect([...accessed].filter(isFieldAccess).sort()).toEqual(
[...REQUESTED].filter((f) => fieldAccesses.includes(f)).sort()
);
});

it('actually exercises the hook — at least code, auto_apply, and allowed_ticket_types are read', async () => {
const accessed = new Set();
const proxied = observe({
code: 'AUDIT_CODE',
auto_apply: false, // suggestion path, not auto-apply
allowed_ticket_types: [{ id: 1 }],
quantity_per_account: 5,
remaining_quantity_per_account: 3,
quantity_available: 20,
}, accessed);

await exerciseFlows(proxied);

const fieldAccesses = new Set([...accessed].filter(isFieldAccess));
// Guards the audit from silently degrading into a no-op. If
// exerciseFlows ever stopped driving the hook (or the Proxy stopped
// observing), the previous test could pass trivially with an empty
// access set. These three reads happen in every meaningful run of
// usePromoCode; requiring them means the audit is doing real work.
expect(fieldAccesses.has('code')).toBe(true);
expect(fieldAccesses.has('auto_apply')).toBe(true);
expect(fieldAccesses.has('allowed_ticket_types')).toBe(true);
});
});
Loading