Skip to content

Commit b1fdc3e

Browse files
committed
Refactor code to more closely match other libraries
1 parent ae094e1 commit b1fdc3e

43 files changed

Lines changed: 1743 additions & 937 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

libs/free-access-program/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
* License, v. 2.0. If a copy of the MPL was not distributed with this
33
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
44

5-
export * from './lib/factories';
65
export * from './lib/free-access-program.client.config';
6+
export * from './lib/free-access-program.factories';
77
export * from './lib/free-access-program.manager';
8-
export * from './lib/free-access-program.projector';
98
export * from './lib/free-access-program.reconciler.service';
9+
export * from './lib/free-access-program.types';
10+
export * from './lib/free-access-program.webhook.factories';
1011
export * from './lib/free-access-program.webhook.types';
11-
export * from './lib/types';

libs/free-access-program/src/lib/factories.ts

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
import { faker } from '@faker-js/faker';
6+
import type {
7+
CapabilityChange,
8+
ClientIdCapabilityMap,
9+
FreeAccessRecord,
10+
FreeAccessSnapshot,
11+
NormalizedAccess,
12+
ProjectionResult,
13+
ProjectionSkip,
14+
ProjectionSkipReason,
15+
ReconcileResult,
16+
} from './free-access-program.types';
17+
import { buildSnapshotKey } from './util/buildSnapshotKey';
18+
19+
/* ---------------- Domain factories ---------------- */
20+
21+
export const ClientIdCapabilityMapFactory = (
22+
override?: ClientIdCapabilityMap
23+
): ClientIdCapabilityMap =>
24+
override ?? {
25+
[`client-${faker.string.alphanumeric({ length: 8 }).toLowerCase()}`]: [
26+
`cap-${faker.string.alphanumeric({ length: 8 })}`,
27+
],
28+
};
29+
30+
export const FreeAccessRecordFactory = (
31+
override?: Partial<FreeAccessRecord>
32+
): FreeAccessRecord => ({
33+
entitlementId: faker.string.uuid(),
34+
email: faker.internet.email().toLowerCase(),
35+
offeringApiIdentifiers: [
36+
`offering-${faker.string.alphanumeric({ length: 8 })}`,
37+
],
38+
capabilities: ClientIdCapabilityMapFactory(),
39+
expiresAt: faker.date.future().getTime(),
40+
description: faker.lorem.sentence(),
41+
internalName: faker.company.name(),
42+
createdAt: faker.date.recent().getTime(),
43+
...override,
44+
});
45+
46+
/**
47+
* Build a snapshot from a list of records, keyed by the same composite id
48+
* the manager would use at runtime.
49+
*/
50+
export const FreeAccessSnapshotFactory = (
51+
records?: ReadonlyArray<FreeAccessRecord>
52+
): FreeAccessSnapshot => {
53+
const source = records ?? [FreeAccessRecordFactory()];
54+
const out: FreeAccessSnapshot = {};
55+
for (const record of source) {
56+
out[buildSnapshotKey(record.entitlementId, record.email)] = record;
57+
}
58+
return out;
59+
};
60+
61+
/* ---------------- Reconciler factories ---------------- */
62+
63+
export const CapabilityChangeFactory = (
64+
override?: Partial<CapabilityChange>
65+
): CapabilityChange => ({
66+
email: faker.internet.email().toLowerCase(),
67+
entitlementId: faker.string.uuid(),
68+
added: [`cap-${faker.string.alphanumeric({ length: 6 })}`],
69+
...override,
70+
});
71+
72+
export const ReconcileResultFactory = (
73+
override?: Partial<ReconcileResult>
74+
): ReconcileResult => ({
75+
upserted: 0,
76+
deleted: 0,
77+
...override,
78+
});
79+
80+
/* ---------------- Projector factories ---------------- */
81+
//
82+
// GraphQL-shaped factories (`AccessResultFactory`, `AccessOfferingFactory`,
83+
// etc.) live in `@fxa/shared/cms` — re-use them rather than duplicating.
84+
85+
export const NormalizedAccessFactory = (
86+
override?: Partial<NormalizedAccess>
87+
): NormalizedAccess => ({
88+
documentId: faker.string.uuid(),
89+
internalName: faker.company.name(),
90+
offeringApiIdentifiers: [
91+
`offering-${faker.string.alphanumeric({ length: 8 })}`,
92+
],
93+
capabilities: [
94+
{
95+
slug: `cap-${faker.string.alphanumeric({ length: 8 })}`,
96+
services: [
97+
{
98+
oauthClientId: `client-${faker.string
99+
.alphanumeric({ length: 8 })
100+
.toLowerCase()}`,
101+
},
102+
],
103+
},
104+
],
105+
emailLists: [
106+
{ [faker.internet.email().toLowerCase()]: ['2099-12-31', ''] },
107+
],
108+
...override,
109+
});
110+
111+
const PROJECTION_SKIP_REASONS: readonly ProjectionSkipReason[] = [
112+
'missing-document-id',
113+
'no-capabilities',
114+
'malformed-emails',
115+
'array-email-form',
116+
'empty-email',
117+
'malformed-tuple',
118+
'invalid-date',
119+
'past-expiry',
120+
];
121+
122+
export const ProjectionSkipFactory = (
123+
override?: Partial<ProjectionSkip>
124+
): ProjectionSkip => ({
125+
reason: faker.helpers.arrayElement(PROJECTION_SKIP_REASONS),
126+
...override,
127+
});
128+
129+
export const ProjectionResultFactory = (
130+
override?: Partial<ProjectionResult>
131+
): ProjectionResult => ({
132+
records: [FreeAccessRecordFactory()],
133+
skipped: [],
134+
...override,
135+
});

libs/free-access-program/src/lib/free-access-program.manager.spec.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,10 @@ import { Test } from '@nestjs/testing';
88
import { StrapiClient } from '@fxa/shared/cms';
99
import { MockFirestoreProvider } from '@fxa/shared/db/firestore';
1010

11-
import {
12-
buildSnapshotKey,
13-
FreeAccessProgramManager,
14-
type FreeAccessSnapshot,
15-
} from './free-access-program.manager';
11+
import { FreeAccessProgramManager } from './free-access-program.manager';
1612
import { MockFreeAccessProgramClientConfigProvider } from './free-access-program.client.config';
13+
import type { FreeAccessSnapshot } from './free-access-program.types';
14+
import { buildSnapshotKey } from './util/buildSnapshotKey';
1715

1816
// Decorators are pass-through in unit tests: the cache layer is exercised
1917
// in integration, not here. Mirrors strapi.client.spec.ts.

libs/free-access-program/src/lib/free-access-program.manager.ts

Lines changed: 7 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,15 @@ import {
1818
} from '@fxa/shared/db/type-cacheable';
1919

2020
import { FreeAccessProgramClientConfig } from './free-access-program.client.config';
21-
import {
22-
normalizeGraphQLAccess,
23-
projectAccess,
24-
} from './free-access-program.projector';
2521
import type {
2622
ClientIdCapabilityMap,
2723
FreeAccessRecord,
28-
} from './types';
24+
FreeAccessSnapshot,
25+
} from './free-access-program.types';
26+
import { buildSnapshotKey } from './util/buildSnapshotKey';
27+
import { mergeCapabilities } from './util/mergeCapabilities';
28+
import { normalizeGraphQLAccess } from './util/normalizeGraphQLAccess';
29+
import { projectAccess } from './util/projectAccess';
2930

3031
const SNAPSHOT_CACHE_KEY = 'freeAccessProgramSnapshot';
3132

@@ -35,9 +36,6 @@ const DEFAULT_FIRESTORE_OFFLINE_CACHE_TTL_SECONDS = 604800; // 7 days
3536
const DEFAULT_FIRESTORE_CACHE_TTL_SECONDS = 1800; // 30 minutes
3637
const DEFAULT_MEM_CACHE_TTL_SECONDS = 300; // 5 minutes
3738

38-
/** Plain-object snapshot serializable by the type-cacheable Firestore adapter. */
39-
export type FreeAccessSnapshot = Record<string, FreeAccessRecord>;
40-
4139
/**
4240
* Source of truth for the projected free-access-program snapshot used by
4341
* auth-server's per-request capability lookup and by the reconciler's
@@ -51,8 +49,7 @@ export type FreeAccessSnapshot = Record<string, FreeAccessRecord>;
5149
* fallback (the 7-day offline TTL keeps lookups working).
5250
*
5351
* The snapshot is a singleton — every read returns the full projection and
54-
* filters in-memory. `findCapabilitiesForEmail` is the only per-email
55-
* entry point the auth-server consumes today.
52+
* filters in-memory.
5653
*/
5754
@Injectable()
5855
export class FreeAccessProgramManager {
@@ -195,40 +192,3 @@ export class FreeAccessProgramManager {
195192
return snapshot;
196193
}
197194
}
198-
199-
/**
200-
* Composite key used to address a single (entitlement, email) grant in the
201-
* snapshot. Matches the doc-id shape the old Firestore repository used so
202-
* any operator tooling that still reads keys can target the same identity.
203-
*/
204-
export function buildSnapshotKey(
205-
entitlementId: string,
206-
email: string
207-
): string {
208-
return `${entitlementId}_${email.toLowerCase()}`;
209-
}
210-
211-
function mergeCapabilities(
212-
records: ReadonlyArray<FreeAccessRecord>
213-
): ClientIdCapabilityMap {
214-
if (records.length === 0) return {};
215-
// Set per clientId so slugs are deduped across multiple entitlements that
216-
// grant access to the same email.
217-
const builder = new Map<string, Set<string>>();
218-
for (const record of records) {
219-
for (const [clientId, slugs] of Object.entries(record.capabilities)) {
220-
const key = clientId.toLowerCase();
221-
let set = builder.get(key);
222-
if (!set) {
223-
set = new Set();
224-
builder.set(key, set);
225-
}
226-
for (const slug of slugs) set.add(slug);
227-
}
228-
}
229-
const result: Record<string, readonly string[]> = {};
230-
for (const [clientId, slugs] of builder) {
231-
result[clientId] = Object.freeze(Array.from(slugs));
232-
}
233-
return result;
234-
}

0 commit comments

Comments
 (0)