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
40 changes: 40 additions & 0 deletions .github/workflows/validate-blueprints.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
name: Validate Translation Blueprints

on:
pull_request:
paths:
- "lib/translator/blueprints/**"
- "lib/translator/blueprint.schema.ts"
- "scripts/validate-blueprints.ts"
- ".github/workflows/validate-blueprints.yml"
push:
branches: [main, master]
paths:
- "lib/translator/blueprints/**"
- "lib/translator/blueprint.schema.ts"
- "scripts/validate-blueprints.ts"
- ".github/workflows/validate-blueprints.yml"

jobs:
validate:
name: Blueprint Schema & Test Validation
runs-on: ubuntu-latest

steps:
- name: 📥 Checkout Repository
uses: actions/checkout@v4

- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
cache: npm

- name: 📦 Install Dependencies
run: npm ci

- name: 🔍 Run Schema Validation
run: npm run validate:blueprints

- name: 🧪 Run Blueprint Unit Tests
run: npx vitest run lib/translator/__tests__/blueprint-validation.test.ts
135 changes: 135 additions & 0 deletions lib/translator/__tests__/blueprint-validation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect } from "vitest";
import { BlueprintSchema } from "../blueprint.schema";
import { createSacTransferBlueprint, createAllSacBlueprints } from "../blueprints/sac-transfer";
import { createSacMintBurnBlueprint } from "../blueprints/sac-mint-burn";

describe("Translation Blueprint Schema Validation", () => {
describe("Existing Blueprints", () => {
it("should validate a single SAC Transfer blueprint", () => {
const blueprint = createSacTransferBlueprint("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM");
const parseResult = BlueprintSchema.safeParse(blueprint);
expect(parseResult.success).toBe(true);
});

it("should validate all SAC Transfer blueprints returned by createAllSacBlueprints", () => {
const blueprints = createAllSacBlueprints();
expect(blueprints.length).toBeGreaterThan(0);
for (const blueprint of blueprints) {
const parseResult = BlueprintSchema.safeParse(blueprint);
expect(parseResult.success).toBe(true);
}
});

it("should validate a single SAC Mint/Burn blueprint", () => {
const blueprint = createSacMintBurnBlueprint("CAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD2KM");
const parseResult = BlueprintSchema.safeParse(blueprint);
expect(parseResult.success).toBe(true);
});
});

describe("Schema Boundaries", () => {
it("should allow a valid blueprint object", () => {
const valid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "Test Contract",
translate: () => null,
};
expect(BlueprintSchema.safeParse(valid).success).toBe(true);
});

it("should allow optional fields", () => {
const valid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "Test Contract",
matches: () => true,
translate: () => null,
validFromLedger: 100,
version: "v2",
};
expect(BlueprintSchema.safeParse(valid).success).toBe(true);
});

it("should reject contractId with incorrect length", () => {
const invalid = {
contractId: "CDLZFC3SYJYDZT7K6", // too short
contractName: "Test Contract",
translate: () => null,
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("contractId must be a valid 56-to-58-character");
}
});

it("should reject contractId that does not start with C", () => {
const invalid = {
contractId: "GDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC", // starts with G
contractName: "Test Contract",
translate: () => null,
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].message).toContain("contractId must be a valid 56-to-58-character");
}
});

it("should reject empty contractName", () => {
const invalid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "",
translate: () => null,
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain("contractName");
}
});

it("should reject missing translate function", () => {
const invalid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "Test Contract",
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error.issues[0].path).toContain("translate");
}
});

it("should reject invalid translate types", () => {
const invalid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "Test Contract",
translate: "not a function",
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
});

it("should reject negative validFromLedger", () => {
const invalid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "Test Contract",
translate: () => null,
validFromLedger: -1,
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
});

it("should reject non-integer validFromLedger", () => {
const invalid = {
contractId: "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC",
contractName: "Test Contract",
translate: () => null,
validFromLedger: 12.34,
};
const result = BlueprintSchema.safeParse(invalid);
expect(result.success).toBe(false);
});
});
});
50 changes: 50 additions & 0 deletions lib/translator/blueprint.schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { z } from "zod";

/**
* Zod Schema for RawEvent (input to matches and translate functions).
*/
export const RawEventSchema = z.object({
id: z.string(),
contractId: z.string().regex(/^C[A-Z0-9]{55,57}$/i, {
message: "contractId must be a valid 56-to-58-character Stellar contract ID starting with C",
}),
topics: z.array(z.string()),
data: z.string(),
ledger: z.number().int().nonnegative(),
timestamp: z.number().int().nonnegative(),
txHash: z.string(),
});

/**
* Zod Schema for supported languages.
*/
export const LanguageSchema = z.union([
z.literal("en"),
z.literal("es"),
z.literal("fr"),
z.literal("zh"),
]);

/**
* Zod Schema for TranslationResult (return type of translate function).
*/
export const TranslationResultSchema = z.object({
description: z.string().min(1, { message: "description must be a non-empty string" }),
eventType: z.string().min(1, { message: "eventType must be a non-empty string" }),
});

/**
* Zod Schema for a TranslationBlueprint and VersionedTranslationBlueprint.
*/
export const BlueprintSchema = z.object({
contractId: z.string().regex(/^C[A-Z0-9]{55,57}$/i, {
message: "contractId must be a valid 56-to-58-character Stellar contract ID starting with C",
}),
contractName: z.string().min(1, {
message: "contractName must be a non-empty string",
}),
matches: z.function().optional(),
translate: z.function(),
validFromLedger: z.number().int().nonnegative().optional(),
version: z.string().min(1).optional(),
});
Loading