Skip to content

Commit 5e366c5

Browse files
committed
Integration tests
1 parent 6788dab commit 5e366c5

15 files changed

Lines changed: 938 additions & 13 deletions

File tree

scripts/integration-test.ts

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -156,20 +156,23 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
156156
objectTypeFormat: 'interface',
157157
});
158158
},
159-
// The `bytes` scenario is the only place where the wire-level
160-
// representation differs across TS targets (Buffer vs firestore.Bytes
161-
// vs firestore.Blob), so we emit it against the web SDK in addition
162-
// to the admin SDK. The admin pass above writes `generated/secrets.ts`;
163-
// this pass writes `generated/web/secrets.ts`, which is imported by
164-
// the dedicated `secrets.web.test.ts` round-trip suite. The
159+
// The `bytes` and `document-reference` scenarios are the only places
160+
// where the wire-level representation differs across TS targets
161+
// (Buffer vs firestore.Bytes vs firestore.Blob for bytes; and
162+
// firebase-admin's vs firebase-web's `DocumentReference` for refs),
163+
// so we emit those fixtures against the web SDK in addition to the
164+
// admin SDK. The admin pass above writes
165+
// `generated/{secrets,references}.ts`; this pass writes
166+
// `generated/web/{secrets,references}.ts`, which is imported by the
167+
// dedicated `*.web.test.ts` round-trip suites. The
165168
// react-native-firebase target is verified by the unit/snapshot tests
166169
// under `src/renderers/ts/__tests__/`; we don't run it here because
167170
// `@react-native-firebase/firestore` is RN-runtime-only and cannot
168171
// execute under Node.
169172
extraGenerations: [
170173
{
171174
subdir: 'web',
172-
onlyFixtures: ['secrets'],
175+
onlyFixtures: ['secrets', 'references'],
173176
async generate(definition, outFile) {
174177
await typesync.generateTs({
175178
definition,
@@ -241,7 +244,7 @@ const PLATFORMS: Record<Platform, PlatformConfig> = {
241244
},
242245
{
243246
subdir: 'v4-web',
244-
onlyFixtures: ['secrets'],
247+
onlyFixtures: ['secrets', 'references'],
245248
async generate(definition, outFile) {
246249
await typesync.generateZod({
247250
definition,

src/renderers/python/_impl.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ class PythonRendererImpl implements PythonRenderer {
2828
b.append(`# Model Definitions\n\n`);
2929

3030
g.declarations.forEach(declaration => {
31-
b.append(`${this.renderDeclaration(declaration)}\n\n`);
31+
b.append(`${this.renderDeclaration(declaration, g)}\n\n`);
3232
});
3333

3434
const rootFile: RenderedFile = {
@@ -135,14 +135,14 @@ class PythonRendererImpl implements PythonRenderer {
135135
return b.toString();
136136
}
137137

138-
private renderDeclaration(declaration: PythonDeclaration) {
138+
private renderDeclaration(declaration: PythonDeclaration, g: PythonGeneration) {
139139
switch (declaration.type) {
140140
case 'alias':
141141
return this.renderAliasDeclaration(declaration);
142142
case 'enum-class':
143143
return this.renderEnumClassDeclaration(declaration);
144144
case 'pydantic-class': {
145-
return this.renderPydanticClassDeclaration(declaration);
145+
return this.renderPydanticClassDeclaration(declaration, g);
146146
}
147147
default:
148148
assertNever(declaration);
@@ -186,7 +186,7 @@ class PythonRendererImpl implements PythonRenderer {
186186
}
187187
}
188188

189-
private renderPydanticClassDeclaration(declaration: PythonPydanticClassDeclaration) {
189+
private renderPydanticClassDeclaration(declaration: PythonPydanticClassDeclaration, g: PythonGeneration) {
190190
const { undefinedSentinelName } = this.config;
191191
const { modelName, modelType, modelDocs } = declaration;
192192
const b = new StringBuilder();
@@ -214,7 +214,19 @@ class PythonRendererImpl implements PythonRenderer {
214214

215215
b.append(`${this.indent(1)}class Config:\n`);
216216
b.append(`${this.indent(2)}use_enum_values = True\n`);
217-
b.append(`${this.indent(2)}extra = '${modelType.additionalAttributes ? 'allow' : 'forbid'}'\n\n`);
217+
b.append(`${this.indent(2)}extra = '${modelType.additionalAttributes ? 'allow' : 'forbid'}'\n`);
218+
// `firestore.DocumentReference` is a third-party class with no
219+
// `__get_pydantic_core_schema__` hook, so Pydantic refuses to validate
220+
// it by default. Opt every generated model into accepting arbitrary
221+
// types whenever the file imports the Firestore Python client, so any
222+
// model that has (or transitively references) a `document-reference`
223+
// field can be validated. The opt-in is scoped per-file via the
224+
// `usesDocumentReference` flag; generations without Firestore refs
225+
// are unchanged.
226+
if (g.usesDocumentReference) {
227+
b.append(`${this.indent(2)}arbitrary_types_allowed = True\n`);
228+
}
229+
b.append('\n');
218230

219231
b.append(`${this.indent(1)}def __setattr__(self, name: str, value: typing.Any) -> None:\n`);
220232
modelType.attributes.forEach(attribute => {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"label": "primary-note-link",
3+
"target_path": "targets/canonical",
4+
"related_paths": ["notes/sibling-a", "notes/sibling-b", "notes/sibling-c"],
5+
"by_label_paths": {
6+
"primary": "targets/canonical",
7+
"secondary": "targets/secondary",
8+
"tertiary": "targets/tertiary"
9+
},
10+
"created_at": "2024-05-09T10:00:00.000Z"
11+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# yaml-language-server: $schema=../../../../schema.local.json
2+
#
3+
# Shared integration-test fixture for the `document-reference` primitive.
4+
# Each Firestore SDK represents document references with a platform-native
5+
# class:
6+
#
7+
# - TypeScript (firebase-admin@13): firestore.DocumentReference<firestore.DocumentData>
8+
# - TypeScript (firebase@10, web): firestore.DocumentReference<firestore.DocumentData>
9+
# - Python (firebase-admin@6): firestore.DocumentReference
10+
# - Swift (firebase@10): FirebaseFirestore.DocumentReference
11+
#
12+
# The schema deliberately mixes a top-level reference, a list-of-references,
13+
# and a map-of-references so we exercise references in collections too. A
14+
# plain string + timestamp sit alongside so we confirm references coexist
15+
# with non-Firestore-typed fields without accidental coercion.
16+
#
17+
# Doc-level convention used by every per-platform test:
18+
# * `target` is a reference to a sibling `/targets/{id}` document that
19+
# stores the canonical entity the note links to.
20+
# * `related` is a list of references to other notes (siblings in the
21+
# same collection).
22+
# * `by_label` is a map (free-form keys) whose values are references.
23+
24+
NoteLink:
25+
model: document
26+
path: notes/{noteId}
27+
docs: A document that points to other documents via Firestore document references.
28+
type:
29+
type: object
30+
fields:
31+
label:
32+
type: string
33+
docs: Human-readable label for the link (not a reference itself).
34+
target:
35+
type: document-reference
36+
docs: A direct reference to a `/targets/{id}` document.
37+
related:
38+
type:
39+
type: list
40+
elementType: document-reference
41+
docs: A list of related-note references to exercise references nested in a list.
42+
by_label:
43+
type:
44+
type: map
45+
valueType: document-reference
46+
docs: A map of label -> reference to exercise references nested in a map.
47+
created_at:
48+
type: timestamp
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"""Round-trip tests for the `references` fixture.
2+
3+
Verifies that the `document-reference` primitive emitted by the Python
4+
generator (`firestore.DocumentReference` /
5+
`typing.List[firestore.DocumentReference]` /
6+
`typing.Dict[str, firestore.DocumentReference]`) round-trips correctly
7+
through the Firestore emulator using the official
8+
`google-cloud-firestore` client (which is what `firebase-admin` uses
9+
underneath).
10+
"""
11+
12+
from __future__ import annotations
13+
14+
import json
15+
import uuid
16+
from pathlib import Path
17+
18+
import pytest
19+
from google.cloud import firestore
20+
21+
22+
def _load_sample(fixtures_root: Path, name: str) -> dict:
23+
return json.loads((fixtures_root / "samples" / "references" / f"{name}.json").read_text())
24+
25+
26+
@pytest.fixture
27+
def references_module(import_generated_module):
28+
return import_generated_module("references")
29+
30+
31+
def test_note_link_round_trips_document_references_via_firestore_emulator(
32+
references_module,
33+
fixtures_root: Path,
34+
firestore_client: firestore.Client,
35+
isolated_collection: firestore.CollectionReference,
36+
) -> None:
37+
"""A document with a top-level reference + list-of-references +
38+
map-of-references survives a Pydantic-validate -> emulator-write ->
39+
emulator-read -> Pydantic-validate cycle with each reference's path
40+
preserved exactly."""
41+
42+
NoteLink = references_module.NoteLink
43+
44+
sample = _load_sample(fixtures_root, "note-link")
45+
46+
target = firestore_client.document(sample["target_path"])
47+
related = [firestore_client.document(p) for p in sample["related_paths"]]
48+
by_label = {k: firestore_client.document(v) for k, v in sample["by_label_paths"].items()}
49+
50+
# Sanity-check the fixture itself: distinct paths so a buggy SDK that
51+
# aliased every reference to the same value would still be caught.
52+
assert target.path == sample["target_path"]
53+
assert len({r.path for r in related}) == len(related)
54+
assert len(related) == 3
55+
assert len(by_label) == 3
56+
57+
note_link_in = NoteLink.model_validate(
58+
{
59+
"label": sample["label"],
60+
"target": target,
61+
"related": related,
62+
"by_label": by_label,
63+
"created_at": sample["created_at"],
64+
}
65+
)
66+
67+
# The generator emits Pydantic types that store `DocumentReference`
68+
# values verbatim; check that nothing has been auto-coerced (e.g. to
69+
# a string path) before we even reach Firestore.
70+
assert isinstance(note_link_in.target, firestore.DocumentReference)
71+
assert all(isinstance(r, firestore.DocumentReference) for r in note_link_in.related)
72+
assert all(isinstance(v, firestore.DocumentReference) for v in note_link_in.by_label.values())
73+
74+
doc_ref = isolated_collection.document(uuid.uuid4().hex)
75+
doc_ref.set(note_link_in.model_dump())
76+
77+
snapshot = doc_ref.get()
78+
assert snapshot.exists, "expected the written document to be readable"
79+
80+
raw = snapshot.to_dict()
81+
# Wire-level expectations: the Firestore Python client returns refs
82+
# as `DocumentReference` for top-level fields, list entries, and map
83+
# values.
84+
assert isinstance(raw["target"], firestore.DocumentReference)
85+
assert isinstance(raw["related"], list)
86+
assert all(isinstance(r, firestore.DocumentReference) for r in raw["related"])
87+
assert isinstance(raw["by_label"], dict)
88+
assert all(isinstance(v, firestore.DocumentReference) for v in raw["by_label"].values())
89+
90+
# Re-validate through the generated Pydantic model to confirm the
91+
# generated schema round-trips without rejecting plain
92+
# `DocumentReference` values.
93+
note_link_out = NoteLink.model_validate(raw)
94+
assert note_link_out.label == sample["label"]
95+
assert note_link_out.target.path == target.path
96+
assert [r.path for r in note_link_out.related] == [r.path for r in related]
97+
assert {k: v.path for k, v in note_link_out.by_label.items()} == {
98+
k: v.path for k, v in by_label.items()
99+
}
100+
101+
102+
def test_note_link_rejects_string_target(references_module, firestore_client: firestore.Client) -> None:
103+
"""The generated Pydantic class should reject obvious type mismatches
104+
on the reference-typed fields (e.g. a bare string path where a
105+
`DocumentReference` instance is expected)."""
106+
107+
NoteLink = references_module.NoteLink
108+
109+
with pytest.raises(Exception):
110+
NoteLink.model_validate(
111+
{
112+
"label": "x",
113+
"target": "targets/canonical",
114+
"related": [],
115+
"by_label": {},
116+
"created_at": "2024-01-01T00:00:00.000Z",
117+
}
118+
)

0 commit comments

Comments
 (0)