| 1 | import * as GCP from "@/GCP"; |
| 2 | import { |
| 3 | decodeFields, |
| 4 | encodeFields, |
| 5 | encodeValue, |
| 6 | fieldPath, |
| 7 | } from "@/GCP/Firestore/Values.ts"; |
| 8 | import * as Test from "@/Test/Alchemy"; |
| 9 | import { describe, expect } from "alchemy-test"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | |
| 12 | const { test } = Test.make({ providers: GCP.providers() }); |
| 13 | |
| 14 | const when = new Date("2026-01-02T03:04:05.678Z"); |
| 15 | const sample = { |
| 16 | str: "hello", |
| 17 | int: 42, |
| 18 | negative: -7, |
| 19 | double: 3.5, |
| 20 | big: 9007199254740993n, |
| 21 | yes: true, |
| 22 | no: false, |
| 23 | nothing: null, |
| 24 | when, |
| 25 | bytes: new Uint8Array([0, 1, 254, 255]), |
| 26 | list: [1, "two", false, null, { x: 3 }], |
| 27 | nested: { a: { b: "deep" }, n: 1.25 }, |
| 28 | }; |
| 29 | |
| 30 | /** Comparable view of decoded fields (bigint, Date and bytes flattened). */ |
| 31 | const describeFields = (fields: Record<string, unknown>) => ({ |
| 32 | str: fields.str, |
| 33 | int: fields.int, |
| 34 | negative: fields.negative, |
| 35 | double: fields.double, |
| 36 | big: typeof fields.big === "bigint" ? fields.big.toString() : fields.big, |
| 37 | yes: fields.yes, |
| 38 | no: fields.no, |
| 39 | nothing: fields.nothing, |
| 40 | when: fields.when instanceof Date ? fields.when.toISOString() : fields.when, |
| 41 | bytes: fields.bytes instanceof Uint8Array ? [...fields.bytes] : fields.bytes, |
| 42 | list: fields.list, |
| 43 | nested: fields.nested, |
| 44 | }); |
| 45 | |
| 46 | const expectedFields = { |
| 47 | str: "hello", |
| 48 | int: 42, |
| 49 | negative: -7, |
| 50 | double: 3.5, |
| 51 | big: "9007199254740993", |
| 52 | yes: true, |
| 53 | no: false, |
| 54 | nothing: null, |
| 55 | when: "2026-01-02T03:04:05.678Z", |
| 56 | bytes: [0, 1, 254, 255], |
| 57 | list: [1, "two", false, null, { x: 3 }], |
| 58 | nested: { a: { b: "deep" }, n: 1.25 }, |
| 59 | }; |
| 60 | |
| 61 | describe("Values codec", () => { |
| 62 | test( |
| 63 | "encodes plain JavaScript to Firestore Values", |
| 64 | Effect.sync(() => { |
| 65 | expect(encodeFields(sample)).toEqual({ |
| 66 | str: { stringValue: "hello" }, |
| 67 | int: { integerValue: "42" }, |
| 68 | negative: { integerValue: "-7" }, |
| 69 | double: { doubleValue: 3.5 }, |
| 70 | big: { integerValue: "9007199254740993" }, |
| 71 | yes: { booleanValue: true }, |
| 72 | no: { booleanValue: false }, |
| 73 | nothing: { nullValue: "NULL_VALUE" }, |
| 74 | when: { timestampValue: "2026-01-02T03:04:05.678Z" }, |
| 75 | bytes: { bytesValue: "AAH+/w==" }, |
| 76 | list: { |
| 77 | arrayValue: { |
| 78 | values: [ |
| 79 | { integerValue: "1" }, |
| 80 | { stringValue: "two" }, |
| 81 | { booleanValue: false }, |
| 82 | { nullValue: "NULL_VALUE" }, |
| 83 | { mapValue: { fields: { x: { integerValue: "3" } } } }, |
| 84 | ], |
| 85 | }, |
| 86 | }, |
| 87 | nested: { |
| 88 | mapValue: { |
| 89 | fields: { |
| 90 | a: { mapValue: { fields: { b: { stringValue: "deep" } } } }, |
| 91 | n: { doubleValue: 1.25 }, |
| 92 | }, |
| 93 | }, |
| 94 | }, |
| 95 | }); |
| 96 | expect(encodeFields({ skipped: undefined })).toEqual({}); |
| 97 | expect(encodeValue(undefined)).toEqual({ nullValue: "NULL_VALUE" }); |
| 98 | }), |
| 99 | { tags: ["unit", "provider:gcp", "provider:gcp:firestore", "local"] }, |
| 100 | ); |
| 101 | |
| 102 | test( |
| 103 | "decodes Firestore Values to plain JavaScript", |
| 104 | Effect.sync(() => { |
| 105 | expect(describeFields(decodeFields(encodeFields(sample)))).toEqual( |
| 106 | expectedFields, |
| 107 | ); |
| 108 | expect( |
| 109 | decodeFields({ |
| 110 | geo: { geoPointValue: { latitude: 1.5, longitude: -2 } }, |
| 111 | ref: { referenceValue: "projects/p/databases/d/documents/a/b" }, |
| 112 | emptyList: { arrayValue: {} }, |
| 113 | emptyMap: { mapValue: {} }, |
| 114 | }), |
| 115 | ).toEqual({ |
| 116 | geo: { latitude: 1.5, longitude: -2 }, |
| 117 | ref: "projects/p/databases/d/documents/a/b", |
| 118 | emptyList: [], |
| 119 | emptyMap: {}, |
| 120 | }); |
| 121 | }), |
| 122 | { tags: ["unit", "provider:gcp", "provider:gcp:firestore", "local"] }, |
| 123 | ); |
| 124 | |
| 125 | test( |
| 126 | "quotes non-identifier update-mask field paths", |
| 127 | Effect.sync(() => { |
| 128 | expect(fieldPath("plain_Name1")).toEqual("plain_Name1"); |
| 129 | expect(fieldPath("has space")).toEqual("`has space`"); |
| 130 | expect(fieldPath("a.b")).toEqual("`a.b`"); |
| 131 | expect(fieldPath("tick`")).toEqual("`tick\\``"); |
| 132 | }), |
| 133 | { tags: ["unit", "provider:gcp", "provider:gcp:firestore", "local"] }, |
| 134 | ); |
| 135 | }); |