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
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,5 +46,8 @@ jobs:
- name: Repeating edit-scope tests
run: npm run test:repeating-scope

- name: Join-class ref tests
run: npm run test:join-class

- name: Build
run: npm run build
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ node_modules
/server

# local env files
.env
.env.local
.env.*.local

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"test:me-board": "node --experimental-strip-types scripts/test-me-board.mjs",
"test:acting-as": "node --experimental-strip-types scripts/test-acting-as.mjs",
"test:repeating-scope": "node --experimental-strip-types scripts/test-repeating-scope.mjs",
"test:join-class": "node --experimental-strip-types scripts/test-join-class.mjs",
"deploy": "node deploy.js",
"postbuild": "cross-env OS_TYPE=$(uname -s) npm-run-all --parallel copy-files echo-message",
"copy-files": "npm run copy-win || npm run copy-nix",
Expand Down
83 changes: 83 additions & 0 deletions scripts/test-join-class.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* Unit tests for P4 join-ref → bare classId resolution (Add Class / Onboarding).
* Run: node --experimental-strip-types scripts/test-join-class.mjs
* (imports parseJoinRef from ../src/common/paths.ts)
*/
import { parseJoinRef } from "../src/common/paths.ts";

let failed = 0;

function assert(cond, msg) {
if (!cond) {
failed++;
console.error("FAIL:", msg);
} else {
console.log("ok:", msg);
}
}

function assertEq(actual, expected, label) {
const ok = JSON.stringify(actual) === JSON.stringify(expected);
if (!ok) {
failed++;
console.error(
`FAIL: ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`
);
} else {
console.log(`ok: ${label}`);
}
}

const org = "mvla.net";
const orgAt = "@mvla.net";

console.log("--- parseJoinRef (P4 flat enroll) ---\n");

assertEq(parseJoinRef(null, org), null, "null → null");
assertEq(parseJoinRef("", org), null, "empty → null");
assertEq(parseJoinRef(" ", org), null, "whitespace → null");

assertEq(
parseJoinRef("abc123", org),
{ classId: "abc123", needsTeacherLookup: true },
"bare classId needs teacher dual-read"
);

assertEq(
parseJoinRef("t@mvla.net/abc123", org),
{ classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
"email/classId → bare classId + teacher"
);

assertEq(
parseJoinRef("t@mvla.net~abc123", org),
{ classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
"email~classId → bare classId + teacher"
);

assertEq(
parseJoinRef("t~abc123", org),
{ classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
"local~classId expands org domain"
);

assertEq(
parseJoinRef("t~abc123", orgAt),
{ classId: "abc123", teacherEmail: "t@mvla.net", needsTeacherLookup: false },
"local~classId with @orgDomain store form"
);

assertEq(
parseJoinRef(" teacher@mvla.net/cls9 ", org),
{ classId: "cls9", teacherEmail: "teacher@mvla.net", needsTeacherLookup: false },
"trims whitespace around join ref"
);

assert(parseJoinRef("only-id", org)?.needsTeacherLookup === true, "short ref flags lookup");
assert(parseJoinRef("a@b.c/id", org)?.needsTeacherLookup === false, "prefixed ref skips lookup");

if (failed) {
console.error(`\n${failed} failure(s)`);
process.exit(1);
}
console.log("\nall join-class tests passed");
44 changes: 44 additions & 0 deletions src/common/paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@ export interface ParsedClassPath {
hasTeacherPrefix: boolean;
}

/**
* P4 flat enroll: join-ref parts for Add Class / Onboarding.
* Prefer bare classId for enrollClass; dual-read when teacher is unknown.
*/
export interface JoinRefParts {
/** Bare classId for enrollClass({ classId }) / store.add_class. */
classId: string;
/** Present when the join ref included a teacher email or local prefix. */
teacherEmail?: string;
/**
* True for short bare classId refs — caller must dual-read (class_from_ref)
* to obtain teacher email before listing classes.
*/
needsTeacherLookup: boolean;
}

export interface ParsedTaskPath {
teacherEmail?: string;
classId: string;
Expand Down Expand Up @@ -86,6 +102,34 @@ export function parseClassId(
return null;
}

/**
* P4: Parse a join ref (route or code→ref) into teacher + bare classId.
* Does not touch Firestore; async teacher lookup stays in AddClassForm.
* Accepts: classId | email/classId | local~classId (slash or ~).
* No nested classes/{email}/classes writers — bare classId enroll only.
*/
export function parseJoinRef(
ref: string | undefined | null,
orgDomain: string
): JoinRefParts | null {
if (!ref || typeof ref !== "string") return null;
const trimmed = ref.trim();
if (!trimmed) return null;

const parsed = parseClassId(trimmed, orgDomain);
if (!parsed?.classId) return null;

if (!parsed.hasTeacherPrefix) {
return { classId: parsed.classId, needsTeacherLookup: true };
}

return {
classId: parsed.classId,
teacherEmail: parsed.teacherEmail,
needsTeacherLookup: false,
};
}

/**
* Parse task identity from slash id or ~ ref.
* Accepts: classId/taskId | email/classId/taskId | local~classId~taskId | classId~taskId
Expand Down
Loading
Loading