feat(api): complete unified v2 mobile surface
This commit is contained in:
64
backend/unified-api/scripts/ensure-v2-demo-users.mjs
Normal file
64
backend/unified-api/scripts/ensure-v2-demo-users.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
import { signInWithPassword, signUpWithPassword } from '../src/services/identity-toolkit.js';
|
||||
|
||||
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
|
||||
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
|
||||
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
|
||||
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
|
||||
|
||||
async function ensureUser({ email, password, displayName }) {
|
||||
try {
|
||||
const signedIn = await signInWithPassword({ email, password });
|
||||
return {
|
||||
uid: signedIn.localId,
|
||||
email,
|
||||
password,
|
||||
created: false,
|
||||
displayName,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error?.message || '';
|
||||
if (!message.includes('INVALID_LOGIN_CREDENTIALS') && !message.includes('EMAIL_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const signedUp = await signUpWithPassword({ email, password });
|
||||
return {
|
||||
uid: signedUp.localId,
|
||||
email,
|
||||
password,
|
||||
created: true,
|
||||
displayName,
|
||||
};
|
||||
} catch (error) {
|
||||
const message = error?.message || '';
|
||||
if (message.includes('EMAIL_EXISTS')) {
|
||||
throw new Error(`Firebase user ${email} exists but password does not match expected demo password.`);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const owner = await ensureUser({
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
displayName: 'Legendary Demo Owner V2',
|
||||
});
|
||||
|
||||
const staff = await ensureUser({
|
||||
email: staffEmail,
|
||||
password: staffPassword,
|
||||
displayName: 'Ana Barista V2',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(JSON.stringify({ owner, staff }, null, 2));
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
971
backend/unified-api/scripts/live-smoke-v2-unified.mjs
Normal file
971
backend/unified-api/scripts/live-smoke-v2-unified.mjs
Normal file
@@ -0,0 +1,971 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { signInWithPassword } from '../src/services/identity-toolkit.js';
|
||||
import { V2DemoFixture as fixture } from '../../command-api/scripts/v2-demo-fixture.mjs';
|
||||
|
||||
const unifiedBaseUrl = process.env.UNIFIED_API_BASE_URL || 'https://krow-api-v2-e3g6witsvq-uc.a.run.app';
|
||||
const ownerEmail = process.env.V2_DEMO_OWNER_EMAIL || 'legendary.owner+v2@krowd.com';
|
||||
const staffEmail = process.env.V2_DEMO_STAFF_EMAIL || 'ana.barista+v2@krowd.com';
|
||||
const ownerPassword = process.env.V2_DEMO_OWNER_PASSWORD || 'Demo2026!';
|
||||
const staffPassword = process.env.V2_DEMO_STAFF_PASSWORD || 'Demo2026!';
|
||||
|
||||
function uniqueKey(prefix) {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function isoDate(offsetDays = 0) {
|
||||
const value = new Date(Date.now() + (offsetDays * 24 * 60 * 60 * 1000));
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function isoTimestamp(offsetHours = 0) {
|
||||
return new Date(Date.now() + (offsetHours * 60 * 60 * 1000)).toISOString();
|
||||
}
|
||||
|
||||
function logStep(step, payload) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[unified-smoke-v2] ${step}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
|
||||
async function readJson(response) {
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : {};
|
||||
}
|
||||
|
||||
async function apiCall(path, {
|
||||
method = 'GET',
|
||||
token,
|
||||
idempotencyKey,
|
||||
body,
|
||||
expectedStatus = 200,
|
||||
} = {}) {
|
||||
const headers = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
|
||||
const response = await fetch(`${unifiedBaseUrl}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const payload = await readJson(response);
|
||||
if (response.status !== expectedStatus) {
|
||||
throw new Error(`${method} ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function uploadFile(path, token, {
|
||||
filename,
|
||||
contentType,
|
||||
content,
|
||||
fields = {},
|
||||
expectedStatus = 200,
|
||||
}) {
|
||||
const form = new FormData();
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
form.set(key, value);
|
||||
}
|
||||
form.set(
|
||||
'file',
|
||||
new File([content], filename, {
|
||||
type: contentType,
|
||||
})
|
||||
);
|
||||
|
||||
const response = await fetch(`${unifiedBaseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
const payload = await readJson(response);
|
||||
if (response.status !== expectedStatus) {
|
||||
throw new Error(`POST ${path} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function signInClient() {
|
||||
return apiCall('/auth/client/sign-in', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
email: ownerEmail,
|
||||
password: ownerPassword,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function signInStaff() {
|
||||
return signInWithPassword({
|
||||
email: staffEmail,
|
||||
password: staffPassword,
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const reportWindow = `startDate=${encodeURIComponent(isoTimestamp(-24 * 14))}&endDate=${encodeURIComponent(isoTimestamp(24 * 14))}`;
|
||||
const ownerSession = await signInClient();
|
||||
const staffAuth = await signInStaff();
|
||||
|
||||
assert.ok(ownerSession.sessionToken);
|
||||
assert.ok(staffAuth.idToken);
|
||||
assert.equal(ownerSession.business.businessId, fixture.business.id);
|
||||
logStep('auth.client.sign-in.ok', {
|
||||
tenantId: ownerSession.tenant.tenantId,
|
||||
businessId: ownerSession.business.businessId,
|
||||
});
|
||||
logStep('auth.staff.password-sign-in.ok', {
|
||||
uid: staffAuth.localId,
|
||||
email: staffEmail,
|
||||
});
|
||||
|
||||
const authSession = await apiCall('/auth/session', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.equal(authSession.business.businessId, fixture.business.id);
|
||||
logStep('auth.session.ok', authSession);
|
||||
|
||||
const staffPhoneStart = await apiCall('/auth/staff/phone/start', {
|
||||
method: 'POST',
|
||||
body: { phoneNumber: fixture.staff.ana.phone },
|
||||
});
|
||||
assert.equal(staffPhoneStart.mode, 'CLIENT_FIREBASE_SDK');
|
||||
logStep('auth.staff.phone-start.ok', staffPhoneStart);
|
||||
|
||||
const staffPhoneVerify = await apiCall('/auth/staff/phone/verify', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
mode: 'sign-in',
|
||||
idToken: staffAuth.idToken,
|
||||
},
|
||||
});
|
||||
assert.equal(staffPhoneVerify.staff.staffId, fixture.staff.ana.id);
|
||||
logStep('auth.staff.phone-verify.ok', {
|
||||
staffId: staffPhoneVerify.staff.staffId,
|
||||
requiresProfileSetup: staffPhoneVerify.requiresProfileSetup,
|
||||
});
|
||||
|
||||
const clientSession = await apiCall('/client/session', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.equal(clientSession.business.businessId, fixture.business.id);
|
||||
logStep('client.session.ok', clientSession);
|
||||
|
||||
const clientDashboard = await apiCall('/client/dashboard', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.equal(clientDashboard.businessId, fixture.business.id);
|
||||
logStep('client.dashboard.ok', {
|
||||
weeklySpendCents: clientDashboard.spending.weeklySpendCents,
|
||||
openPositionsToday: clientDashboard.coverage.openPositionsToday,
|
||||
});
|
||||
|
||||
const clientReorders = await apiCall('/client/reorders', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(clientReorders.items));
|
||||
logStep('client.reorders.ok', { count: clientReorders.items.length });
|
||||
|
||||
const billingAccounts = await apiCall('/client/billing/accounts', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(billingAccounts.items));
|
||||
logStep('client.billing.accounts.ok', { count: billingAccounts.items.length });
|
||||
|
||||
const pendingInvoices = await apiCall('/client/billing/invoices/pending', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(pendingInvoices.items.length >= 1);
|
||||
const invoiceId = pendingInvoices.items[0].invoiceId;
|
||||
logStep('client.billing.pending-invoices.ok', { count: pendingInvoices.items.length, invoiceId });
|
||||
|
||||
const invoiceHistory = await apiCall('/client/billing/invoices/history', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(invoiceHistory.items));
|
||||
logStep('client.billing.invoice-history.ok', { count: invoiceHistory.items.length });
|
||||
|
||||
const currentBill = await apiCall('/client/billing/current-bill', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(typeof currentBill.currentBillCents === 'number');
|
||||
logStep('client.billing.current-bill.ok', currentBill);
|
||||
|
||||
const savings = await apiCall('/client/billing/savings', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(typeof savings.savingsCents === 'number');
|
||||
logStep('client.billing.savings.ok', savings);
|
||||
|
||||
const spendBreakdown = await apiCall('/client/billing/spend-breakdown?period=month', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(spendBreakdown.items));
|
||||
logStep('client.billing.spend-breakdown.ok', { count: spendBreakdown.items.length });
|
||||
|
||||
const coverage = await apiCall(`/client/coverage?date=${isoDate(0)}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(coverage.items));
|
||||
logStep('client.coverage.ok', { count: coverage.items.length });
|
||||
|
||||
const coverageStats = await apiCall(`/client/coverage/stats?date=${isoDate(0)}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(typeof coverageStats.totalCoveragePercentage === 'number');
|
||||
logStep('client.coverage.stats.ok', coverageStats);
|
||||
|
||||
const coreTeam = await apiCall('/client/coverage/core-team', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(coreTeam.items));
|
||||
logStep('client.coverage.core-team.ok', { count: coreTeam.items.length });
|
||||
|
||||
const hubs = await apiCall('/client/hubs', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(hubs.items.some((hub) => hub.hubId === fixture.clockPoint.id));
|
||||
logStep('client.hubs.ok', { count: hubs.items.length });
|
||||
|
||||
const costCenters = await apiCall('/client/cost-centers', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(costCenters.items.length >= 1);
|
||||
logStep('client.cost-centers.ok', { count: costCenters.items.length });
|
||||
|
||||
const vendors = await apiCall('/client/vendors', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(vendors.items.length >= 1);
|
||||
logStep('client.vendors.ok', { count: vendors.items.length });
|
||||
|
||||
const vendorRoles = await apiCall(`/client/vendors/${fixture.vendor.id}/roles`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(vendorRoles.items.length >= 1);
|
||||
logStep('client.vendor-roles.ok', { count: vendorRoles.items.length });
|
||||
|
||||
const hubManagers = await apiCall(`/client/hubs/${fixture.clockPoint.id}/managers`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(hubManagers.items));
|
||||
logStep('client.hub-managers.ok', { count: hubManagers.items.length });
|
||||
|
||||
const teamMembers = await apiCall('/client/team-members', {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(teamMembers.items));
|
||||
logStep('client.team-members.ok', { count: teamMembers.items.length });
|
||||
|
||||
const viewedOrders = await apiCall(`/client/orders/view?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(Array.isArray(viewedOrders.items));
|
||||
logStep('client.orders.view.ok', { count: viewedOrders.items.length });
|
||||
|
||||
const reorderPreview = await apiCall(`/client/orders/${fixture.orders.completed.id}/reorder-preview`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.equal(reorderPreview.orderId, fixture.orders.completed.id);
|
||||
logStep('client.orders.reorder-preview.ok', reorderPreview);
|
||||
|
||||
const reportSummary = await apiCall(`/client/reports/summary?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
assert.ok(typeof reportSummary.totalShifts === 'number');
|
||||
logStep('client.reports.summary.ok', reportSummary);
|
||||
|
||||
const dailyOps = await apiCall(`/client/reports/daily-ops?date=${isoDate(0)}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('client.reports.daily-ops.ok', dailyOps);
|
||||
|
||||
const spendReport = await apiCall(`/client/reports/spend?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('client.reports.spend.ok', spendReport);
|
||||
|
||||
const coverageReport = await apiCall(`/client/reports/coverage?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('client.reports.coverage.ok', coverageReport);
|
||||
|
||||
const forecastReport = await apiCall(`/client/reports/forecast?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('client.reports.forecast.ok', forecastReport);
|
||||
|
||||
const performanceReport = await apiCall(`/client/reports/performance?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('client.reports.performance.ok', performanceReport);
|
||||
|
||||
const noShowReport = await apiCall(`/client/reports/no-show?${reportWindow}`, {
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('client.reports.no-show.ok', noShowReport);
|
||||
|
||||
const createdHub = await apiCall('/client/hubs', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('create-hub'),
|
||||
body: {
|
||||
name: `Smoke Hub ${Date.now()}`,
|
||||
fullAddress: '500 Castro Street, Mountain View, CA',
|
||||
latitude: 37.3925,
|
||||
longitude: -122.0782,
|
||||
city: 'Mountain View',
|
||||
state: 'CA',
|
||||
country: 'US',
|
||||
zipCode: '94041',
|
||||
costCenterId: fixture.costCenters.cafeOps.id,
|
||||
geofenceRadiusMeters: 100,
|
||||
},
|
||||
});
|
||||
assert.ok(createdHub.hubId);
|
||||
logStep('client.hubs.create.ok', createdHub);
|
||||
|
||||
const updatedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, {
|
||||
method: 'PUT',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('update-hub'),
|
||||
body: {
|
||||
name: `${createdHub.name || 'Smoke Hub'} Updated`,
|
||||
geofenceRadiusMeters: 140,
|
||||
},
|
||||
});
|
||||
logStep('client.hubs.update.ok', updatedHub);
|
||||
|
||||
const assignedHubManager = await apiCall(`/client/hubs/${createdHub.hubId}/managers`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('assign-hub-manager'),
|
||||
body: {
|
||||
managerUserId: fixture.users.operationsManager.id,
|
||||
},
|
||||
});
|
||||
logStep('client.hubs.assign-manager.ok', assignedHubManager);
|
||||
|
||||
const assignedNfc = await apiCall(`/client/hubs/${createdHub.hubId}/assign-nfc`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('assign-hub-nfc'),
|
||||
body: {
|
||||
nfcTagId: `NFC-SMOKE-${Date.now()}`,
|
||||
},
|
||||
});
|
||||
logStep('client.hubs.assign-nfc.ok', assignedNfc);
|
||||
|
||||
const deletedHub = await apiCall(`/client/hubs/${createdHub.hubId}`, {
|
||||
method: 'DELETE',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('delete-hub'),
|
||||
body: {
|
||||
reason: 'smoke cleanup',
|
||||
},
|
||||
});
|
||||
logStep('client.hubs.delete.ok', deletedHub);
|
||||
|
||||
const disputedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/dispute`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('invoice-dispute'),
|
||||
body: {
|
||||
reason: 'Smoke dispute before approval',
|
||||
},
|
||||
});
|
||||
logStep('client.billing.invoice-dispute.ok', disputedInvoice);
|
||||
|
||||
const approvedInvoice = await apiCall(`/client/billing/invoices/${invoiceId}/approve`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('invoice-approve'),
|
||||
body: {},
|
||||
});
|
||||
logStep('client.billing.invoice-approve.ok', approvedInvoice);
|
||||
|
||||
const createdOneTimeOrder = await apiCall('/client/orders/one-time', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('order-one-time'),
|
||||
body: {
|
||||
hubId: fixture.clockPoint.id,
|
||||
vendorId: fixture.vendor.id,
|
||||
eventName: `Smoke One-Time ${Date.now()}`,
|
||||
orderDate: isoDate(3),
|
||||
serviceType: 'RESTAURANT',
|
||||
positions: [
|
||||
{
|
||||
roleId: fixture.roles.barista.id,
|
||||
startTime: '08:00',
|
||||
endTime: '16:00',
|
||||
workerCount: 1,
|
||||
payRateCents: 2200,
|
||||
billRateCents: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.ok(createdOneTimeOrder.orderId);
|
||||
logStep('client.orders.create-one-time.ok', createdOneTimeOrder);
|
||||
|
||||
const createdRecurringOrder = await apiCall('/client/orders/recurring', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('order-recurring'),
|
||||
body: {
|
||||
hubId: fixture.clockPoint.id,
|
||||
vendorId: fixture.vendor.id,
|
||||
eventName: `Smoke Recurring ${Date.now()}`,
|
||||
startDate: isoDate(5),
|
||||
endDate: isoDate(10),
|
||||
recurrenceDays: [1, 3, 5],
|
||||
serviceType: 'RESTAURANT',
|
||||
positions: [
|
||||
{
|
||||
roleId: fixture.roles.barista.id,
|
||||
startTime: '09:00',
|
||||
endTime: '15:00',
|
||||
workersNeeded: 1,
|
||||
payRateCents: 2300,
|
||||
billRateCents: 3600,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.ok(createdRecurringOrder.orderId);
|
||||
logStep('client.orders.create-recurring.ok', createdRecurringOrder);
|
||||
|
||||
const createdPermanentOrder = await apiCall('/client/orders/permanent', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('order-permanent'),
|
||||
body: {
|
||||
hubId: fixture.clockPoint.id,
|
||||
vendorId: fixture.vendor.id,
|
||||
eventName: `Smoke Permanent ${Date.now()}`,
|
||||
startDate: isoDate(7),
|
||||
daysOfWeek: [1, 2, 3, 4, 5],
|
||||
horizonDays: 14,
|
||||
serviceType: 'RESTAURANT',
|
||||
positions: [
|
||||
{
|
||||
roleId: fixture.roles.barista.id,
|
||||
startTime: '07:00',
|
||||
endTime: '13:00',
|
||||
workersNeeded: 1,
|
||||
payRateCents: 2200,
|
||||
billRateCents: 3500,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.ok(createdPermanentOrder.orderId);
|
||||
logStep('client.orders.create-permanent.ok', createdPermanentOrder);
|
||||
|
||||
const editedOrderCopy = await apiCall(`/client/orders/${fixture.orders.completed.id}/edit`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('order-edit'),
|
||||
body: {
|
||||
eventName: `Edited Copy ${Date.now()}`,
|
||||
},
|
||||
});
|
||||
assert.ok(editedOrderCopy.orderId);
|
||||
logStep('client.orders.edit-copy.ok', editedOrderCopy);
|
||||
|
||||
const cancelledOrder = await apiCall(`/client/orders/${createdOneTimeOrder.orderId}/cancel`, {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('order-cancel'),
|
||||
body: {
|
||||
reason: 'Smoke cancel validation',
|
||||
},
|
||||
});
|
||||
logStep('client.orders.cancel.ok', cancelledOrder);
|
||||
|
||||
const coverageReview = await apiCall('/client/coverage/reviews', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
idempotencyKey: uniqueKey('coverage-review'),
|
||||
body: {
|
||||
staffId: fixture.staff.ana.id,
|
||||
assignmentId: fixture.assignments.completedAna.id,
|
||||
rating: 5,
|
||||
markAsFavorite: true,
|
||||
issueFlags: [],
|
||||
feedback: 'Smoke review',
|
||||
},
|
||||
});
|
||||
logStep('client.coverage.review.ok', coverageReview);
|
||||
|
||||
const staffSession = await apiCall('/staff/session', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.equal(staffSession.staff.staffId, fixture.staff.ana.id);
|
||||
logStep('staff.session.ok', staffSession);
|
||||
|
||||
const staffDashboard = await apiCall('/staff/dashboard', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(staffDashboard.recommendedShifts));
|
||||
logStep('staff.dashboard.ok', {
|
||||
todaysShifts: staffDashboard.todaysShifts.length,
|
||||
recommendedShifts: staffDashboard.recommendedShifts.length,
|
||||
});
|
||||
|
||||
const staffProfileCompletion = await apiCall('/staff/profile-completion', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.profile-completion.ok', staffProfileCompletion);
|
||||
|
||||
const staffAvailability = await apiCall('/staff/availability', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(staffAvailability.items));
|
||||
logStep('staff.availability.ok', { count: staffAvailability.items.length });
|
||||
|
||||
const todaysShifts = await apiCall('/staff/clock-in/shifts/today', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(todaysShifts.items));
|
||||
logStep('staff.clock-in.shifts-today.ok', { count: todaysShifts.items.length });
|
||||
|
||||
const attendanceStatusBefore = await apiCall('/staff/clock-in/status', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.clock-in.status-before.ok', attendanceStatusBefore);
|
||||
|
||||
const paymentsSummary = await apiCall('/staff/payments/summary?period=month', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.payments.summary.ok', paymentsSummary);
|
||||
|
||||
const paymentsHistory = await apiCall('/staff/payments/history?period=month', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(paymentsHistory.items));
|
||||
logStep('staff.payments.history.ok', { count: paymentsHistory.items.length });
|
||||
|
||||
const paymentsChart = await apiCall('/staff/payments/chart?period=month', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(paymentsChart.items));
|
||||
logStep('staff.payments.chart.ok', { count: paymentsChart.items.length });
|
||||
|
||||
const assignedShifts = await apiCall(`/staff/shifts/assigned?${reportWindow}`, {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(assignedShifts.items));
|
||||
logStep('staff.shifts.assigned.ok', { count: assignedShifts.items.length });
|
||||
|
||||
const openShifts = await apiCall('/staff/shifts/open', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(openShifts.items.some((shift) => shift.shiftId === fixture.shifts.available.id));
|
||||
logStep('staff.shifts.open.ok', { count: openShifts.items.length });
|
||||
|
||||
const pendingShifts = await apiCall('/staff/shifts/pending', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(pendingShifts.items.some((item) => item.shiftId === fixture.shifts.assigned.id));
|
||||
logStep('staff.shifts.pending.ok', { count: pendingShifts.items.length });
|
||||
|
||||
const cancelledShifts = await apiCall('/staff/shifts/cancelled', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(cancelledShifts.items));
|
||||
logStep('staff.shifts.cancelled.ok', { count: cancelledShifts.items.length });
|
||||
|
||||
const completedShifts = await apiCall('/staff/shifts/completed', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(completedShifts.items));
|
||||
logStep('staff.shifts.completed.ok', { count: completedShifts.items.length });
|
||||
|
||||
const shiftDetail = await apiCall(`/staff/shifts/${fixture.shifts.available.id}`, {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.equal(shiftDetail.shiftId, fixture.shifts.available.id);
|
||||
logStep('staff.shifts.detail.ok', shiftDetail);
|
||||
|
||||
const profileSections = await apiCall('/staff/profile/sections', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.profile.sections.ok', profileSections);
|
||||
|
||||
const personalInfo = await apiCall('/staff/profile/personal-info', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.profile.personal-info.ok', personalInfo);
|
||||
|
||||
const industries = await apiCall('/staff/profile/industries', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(industries.items));
|
||||
logStep('staff.profile.industries.ok', industries);
|
||||
|
||||
const skills = await apiCall('/staff/profile/skills', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(skills.items));
|
||||
logStep('staff.profile.skills.ok', skills);
|
||||
|
||||
const profileDocumentsBefore = await apiCall('/staff/profile/documents', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(profileDocumentsBefore.items));
|
||||
logStep('staff.profile.documents-before.ok', { count: profileDocumentsBefore.items.length });
|
||||
|
||||
const attireChecklistBefore = await apiCall('/staff/profile/attire', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(attireChecklistBefore.items));
|
||||
logStep('staff.profile.attire-before.ok', { count: attireChecklistBefore.items.length });
|
||||
|
||||
const taxForms = await apiCall('/staff/profile/tax-forms', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(taxForms.items));
|
||||
logStep('staff.profile.tax-forms.ok', { count: taxForms.items.length });
|
||||
|
||||
const emergencyContactsBefore = await apiCall('/staff/profile/emergency-contacts', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(emergencyContactsBefore.items));
|
||||
logStep('staff.profile.emergency-contacts-before.ok', { count: emergencyContactsBefore.items.length });
|
||||
|
||||
const certificatesBefore = await apiCall('/staff/profile/certificates', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(certificatesBefore.items));
|
||||
logStep('staff.profile.certificates-before.ok', { count: certificatesBefore.items.length });
|
||||
|
||||
const bankAccountsBefore = await apiCall('/staff/profile/bank-accounts', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(bankAccountsBefore.items));
|
||||
logStep('staff.profile.bank-accounts-before.ok', { count: bankAccountsBefore.items.length });
|
||||
|
||||
const benefits = await apiCall('/staff/profile/benefits', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(benefits.items));
|
||||
logStep('staff.profile.benefits.ok', { count: benefits.items.length });
|
||||
|
||||
const timeCard = await apiCall(`/staff/profile/time-card?month=${new Date().getUTCMonth() + 1}&year=${new Date().getUTCFullYear()}`, {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(Array.isArray(timeCard.items));
|
||||
logStep('staff.profile.time-card.ok', { count: timeCard.items.length });
|
||||
|
||||
const privacyBefore = await apiCall('/staff/profile/privacy', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.profile.privacy-before.ok', privacyBefore);
|
||||
|
||||
const faqs = await apiCall('/staff/faqs');
|
||||
assert.ok(Array.isArray(faqs.items));
|
||||
logStep('staff.faqs.ok', { count: faqs.items.length });
|
||||
|
||||
const faqSearch = await apiCall('/staff/faqs/search?q=payments');
|
||||
assert.ok(Array.isArray(faqSearch.items));
|
||||
logStep('staff.faqs.search.ok', { count: faqSearch.items.length });
|
||||
|
||||
const updatedAvailability = await apiCall('/staff/availability', {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-availability'),
|
||||
body: {
|
||||
dayOfWeek: 2,
|
||||
availabilityStatus: 'PARTIAL',
|
||||
slots: [{ start: '10:00', end: '18:00' }],
|
||||
},
|
||||
});
|
||||
logStep('staff.availability.update.ok', updatedAvailability);
|
||||
|
||||
const quickSetAvailability = await apiCall('/staff/availability/quick-set', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-availability-quick-set'),
|
||||
body: {
|
||||
quickSetType: 'weekdays',
|
||||
startDate: isoTimestamp(0),
|
||||
endDate: isoTimestamp(24 * 7),
|
||||
},
|
||||
});
|
||||
logStep('staff.availability.quick-set.ok', quickSetAvailability);
|
||||
|
||||
const personalInfoUpdate = await apiCall('/staff/profile/personal-info', {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-personal-info'),
|
||||
body: {
|
||||
firstName: 'Ana',
|
||||
lastName: 'Barista',
|
||||
bio: 'Smoke-tested staff bio',
|
||||
preferredLocations: [
|
||||
{
|
||||
label: 'Mountain View',
|
||||
city: 'Mountain View',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
radiusMiles: 20,
|
||||
},
|
||||
],
|
||||
phone: fixture.staff.ana.phone,
|
||||
email: staffEmail,
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.personal-info.update.ok', personalInfoUpdate);
|
||||
|
||||
const experienceUpdate = await apiCall('/staff/profile/experience', {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-experience'),
|
||||
body: {
|
||||
industries: ['CATERING', 'CAFE'],
|
||||
skills: ['BARISTA', 'CUSTOMER_SERVICE'],
|
||||
primaryRole: 'BARISTA',
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.experience.update.ok', experienceUpdate);
|
||||
|
||||
const locationUpdate = await apiCall('/staff/profile/locations', {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-locations'),
|
||||
body: {
|
||||
preferredLocations: [
|
||||
{
|
||||
label: 'Mountain View',
|
||||
city: 'Mountain View',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
radiusMiles: 25,
|
||||
},
|
||||
],
|
||||
maxDistanceMiles: 25,
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.locations.update.ok', locationUpdate);
|
||||
|
||||
const createdEmergencyContact = await apiCall('/staff/profile/emergency-contacts', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-emergency-create'),
|
||||
body: {
|
||||
fullName: 'Smoke Contact',
|
||||
phone: '+15550009999',
|
||||
relationshipType: 'Sibling',
|
||||
isPrimary: false,
|
||||
},
|
||||
});
|
||||
assert.ok(createdEmergencyContact.contactId);
|
||||
logStep('staff.profile.emergency-contact.create.ok', createdEmergencyContact);
|
||||
|
||||
const updatedEmergencyContact = await apiCall(`/staff/profile/emergency-contacts/${createdEmergencyContact.contactId}`, {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-emergency-update'),
|
||||
body: {
|
||||
fullName: 'Smoke Contact Updated',
|
||||
relationshipType: 'Brother',
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.emergency-contact.update.ok', updatedEmergencyContact);
|
||||
|
||||
const savedW4Draft = await apiCall('/staff/profile/tax-forms/w4', {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-tax-w4-draft'),
|
||||
body: {
|
||||
fields: {
|
||||
filingStatus: 'single',
|
||||
},
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.tax-form.w4-draft.ok', savedW4Draft);
|
||||
|
||||
const submittedI9 = await apiCall('/staff/profile/tax-forms/i9/submit', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-tax-i9-submit'),
|
||||
body: {
|
||||
fields: {
|
||||
section1Complete: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.tax-form.i9-submit.ok', submittedI9);
|
||||
|
||||
const addedBankAccount = await apiCall('/staff/profile/bank-accounts', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-bank-account'),
|
||||
body: {
|
||||
bankName: 'Demo Credit Union',
|
||||
accountNumber: '1234567890',
|
||||
routingNumber: '021000021',
|
||||
accountType: 'checking',
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.bank-account.add.ok', addedBankAccount);
|
||||
|
||||
const updatedPrivacy = await apiCall('/staff/profile/privacy', {
|
||||
method: 'PUT',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-privacy'),
|
||||
body: {
|
||||
profileVisible: true,
|
||||
},
|
||||
});
|
||||
logStep('staff.profile.privacy.update.ok', updatedPrivacy);
|
||||
|
||||
const appliedShift = await apiCall(`/staff/shifts/${fixture.shifts.available.id}/apply`, {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-shift-apply'),
|
||||
body: {
|
||||
roleId: fixture.shiftRoles.availableBarista.id,
|
||||
},
|
||||
});
|
||||
logStep('staff.shifts.apply.ok', appliedShift);
|
||||
|
||||
const acceptedShift = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/accept`, {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-shift-accept'),
|
||||
body: {},
|
||||
});
|
||||
logStep('staff.shifts.accept.ok', acceptedShift);
|
||||
|
||||
const clockIn = await apiCall('/staff/clock-in', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-clock-in'),
|
||||
body: {
|
||||
shiftId: fixture.shifts.assigned.id,
|
||||
sourceType: 'NFC',
|
||||
nfcTagId: fixture.clockPoint.nfcTagUid,
|
||||
deviceId: 'smoke-iphone-15-pro',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
accuracyMeters: 8,
|
||||
capturedAt: isoTimestamp(0),
|
||||
},
|
||||
});
|
||||
logStep('staff.clock-in.ok', clockIn);
|
||||
|
||||
const attendanceStatusAfterClockIn = await apiCall('/staff/clock-in/status', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('staff.clock-in.status-after.ok', attendanceStatusAfterClockIn);
|
||||
|
||||
const clockOut = await apiCall('/staff/clock-out', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-clock-out'),
|
||||
body: {
|
||||
shiftId: fixture.shifts.assigned.id,
|
||||
sourceType: 'GEO',
|
||||
deviceId: 'smoke-iphone-15-pro',
|
||||
latitude: fixture.clockPoint.latitude,
|
||||
longitude: fixture.clockPoint.longitude,
|
||||
accuracyMeters: 10,
|
||||
breakMinutes: 30,
|
||||
capturedAt: isoTimestamp(1),
|
||||
},
|
||||
});
|
||||
logStep('staff.clock-out.ok', clockOut);
|
||||
|
||||
const requestedSwap = await apiCall(`/staff/shifts/${fixture.shifts.assigned.id}/request-swap`, {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
idempotencyKey: uniqueKey('staff-shift-swap'),
|
||||
body: {
|
||||
reason: 'Smoke swap request',
|
||||
},
|
||||
});
|
||||
logStep('staff.shifts.request-swap.ok', requestedSwap);
|
||||
|
||||
const uploadedProfilePhoto = await uploadFile('/staff/profile/photo', staffAuth.idToken, {
|
||||
filename: 'profile-photo.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
content: Buffer.from('fake-profile-photo'),
|
||||
});
|
||||
assert.ok(uploadedProfilePhoto.fileUri);
|
||||
logStep('staff.profile.photo.upload.ok', uploadedProfilePhoto);
|
||||
|
||||
const uploadedGovId = await uploadFile(`/staff/profile/documents/${fixture.documents.governmentId.id}/upload`, staffAuth.idToken, {
|
||||
filename: 'government-id.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
content: Buffer.from('fake-government-id'),
|
||||
});
|
||||
assert.equal(uploadedGovId.documentId, fixture.documents.governmentId.id);
|
||||
logStep('staff.profile.document.upload.ok', uploadedGovId);
|
||||
|
||||
const uploadedAttire = await uploadFile(`/staff/profile/attire/${fixture.documents.attireBlackShirt.id}/upload`, staffAuth.idToken, {
|
||||
filename: 'black-shirt.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
content: Buffer.from('fake-black-shirt'),
|
||||
});
|
||||
assert.equal(uploadedAttire.documentId, fixture.documents.attireBlackShirt.id);
|
||||
logStep('staff.profile.attire.upload.ok', uploadedAttire);
|
||||
|
||||
const certificateType = `ALCOHOL_SERVICE_${Date.now()}`;
|
||||
const uploadedCertificate = await uploadFile('/staff/profile/certificates', staffAuth.idToken, {
|
||||
filename: 'certificate.pdf',
|
||||
contentType: 'application/pdf',
|
||||
content: Buffer.from('fake-certificate'),
|
||||
fields: {
|
||||
certificateType,
|
||||
name: 'Alcohol Service Permit',
|
||||
issuer: 'Demo Issuer',
|
||||
},
|
||||
});
|
||||
assert.equal(uploadedCertificate.certificateType, certificateType);
|
||||
logStep('staff.profile.certificate.upload.ok', uploadedCertificate);
|
||||
|
||||
const profileDocumentsAfter = await apiCall('/staff/profile/documents', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(profileDocumentsAfter.items.some((item) => item.documentId === fixture.documents.governmentId.id));
|
||||
logStep('staff.profile.documents-after.ok', { count: profileDocumentsAfter.items.length });
|
||||
|
||||
const certificatesAfter = await apiCall('/staff/profile/certificates', {
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
assert.ok(certificatesAfter.items.some((item) => item.certificateType === certificateType));
|
||||
logStep('staff.profile.certificates-after.ok', { count: certificatesAfter.items.length });
|
||||
|
||||
const deletedCertificate = await apiCall(`/staff/profile/certificates/${certificateType}`, {
|
||||
method: 'DELETE',
|
||||
token: staffAuth.idToken,
|
||||
expectedStatus: 200,
|
||||
});
|
||||
logStep('staff.profile.certificate.delete.ok', deletedCertificate);
|
||||
|
||||
const clientSignOut = await apiCall('/auth/client/sign-out', {
|
||||
method: 'POST',
|
||||
token: ownerSession.sessionToken,
|
||||
});
|
||||
logStep('auth.client.sign-out.ok', clientSignOut);
|
||||
|
||||
const staffSignOut = await apiCall('/auth/staff/sign-out', {
|
||||
method: 'POST',
|
||||
token: staffAuth.idToken,
|
||||
});
|
||||
logStep('auth.staff.sign-out.ok', staffSignOut);
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('LIVE_SMOKE_V2_UNIFIED_OK');
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,14 +1,29 @@
|
||||
import express from 'express';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { parseClientSignIn, parseClientSignUp, signInClient, signOutActor, signUpClient, getSessionForActor } from '../services/auth-service.js';
|
||||
import {
|
||||
getSessionForActor,
|
||||
parseClientSignIn,
|
||||
parseClientSignUp,
|
||||
parseStaffPhoneStart,
|
||||
parseStaffPhoneVerify,
|
||||
signInClient,
|
||||
signOutActor,
|
||||
signUpClient,
|
||||
startStaffPhoneAuth,
|
||||
verifyStaffPhoneAuth,
|
||||
} from '../services/auth-service.js';
|
||||
import { verifyFirebaseToken } from '../services/firebase-auth.js';
|
||||
|
||||
const defaultAuthService = {
|
||||
parseClientSignIn,
|
||||
parseClientSignUp,
|
||||
parseStaffPhoneStart,
|
||||
parseStaffPhoneVerify,
|
||||
signInClient,
|
||||
signOutActor,
|
||||
signUpClient,
|
||||
startStaffPhoneAuth,
|
||||
verifyStaffPhoneAuth,
|
||||
getSessionForActor,
|
||||
};
|
||||
|
||||
@@ -31,7 +46,7 @@ async function requireAuth(req, _res, next) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const decoded = await verifyFirebaseToken(token, { checkRevoked: true });
|
||||
const decoded = await verifyFirebaseToken(token);
|
||||
req.actor = {
|
||||
uid: decoded.uid,
|
||||
email: decoded.email || null,
|
||||
@@ -77,6 +92,32 @@ export function createAuthRouter(options = {}) {
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/staff/phone/start', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseStaffPhoneStart(req.body);
|
||||
const result = await authService.startStaffPhoneAuth(payload, { fetchImpl });
|
||||
return res.status(200).json({
|
||||
...result,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/staff/phone/verify', async (req, res, next) => {
|
||||
try {
|
||||
const payload = authService.parseStaffPhoneVerify(req.body);
|
||||
const session = await authService.verifyStaffPhoneAuth(payload, { fetchImpl });
|
||||
return res.status(200).json({
|
||||
...session,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/session', requireAuth, async (req, res, next) => {
|
||||
try {
|
||||
const session = await authService.getSessionForActor(req.actor);
|
||||
|
||||
@@ -14,10 +14,91 @@ const HOP_BY_HOP_HEADERS = new Set([
|
||||
'upgrade',
|
||||
]);
|
||||
|
||||
function resolveTargetBase(pathname) {
|
||||
if (pathname.startsWith('/core')) return process.env.CORE_API_BASE_URL;
|
||||
if (pathname.startsWith('/commands')) return process.env.COMMAND_API_BASE_URL;
|
||||
if (pathname.startsWith('/query')) return process.env.QUERY_API_BASE_URL;
|
||||
const DIRECT_CORE_ALIASES = [
|
||||
{ methods: new Set(['POST']), pattern: /^\/upload-file$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/create-signed-url$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/invoke-llm$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/transcribe$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/rapid-orders\/parse$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/profile\/photo$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
pattern: /^\/staff\/profile\/documents\/([^/]+)\/upload$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/documents/${match[1]}/upload`,
|
||||
},
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
pattern: /^\/staff\/profile\/attire\/([^/]+)\/upload$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/attire/${match[1]}/upload`,
|
||||
},
|
||||
{
|
||||
methods: new Set(['POST']),
|
||||
pattern: /^\/staff\/profile\/certificates$/,
|
||||
targetPath: () => '/core/staff/certificates/upload',
|
||||
},
|
||||
{
|
||||
methods: new Set(['DELETE']),
|
||||
pattern: /^\/staff\/profile\/certificates\/([^/]+)$/,
|
||||
targetPath: (_pathname, match) => `/core/staff/certificates/${match[1]}`,
|
||||
},
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/documents\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/attire\/([^/]+)\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/staff\/certificates\/upload$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['DELETE']), pattern: /^\/staff\/certificates\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/verifications$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['GET']), pattern: /^\/verifications\/([^/]+)$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/review$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
{ methods: new Set(['POST']), pattern: /^\/verifications\/([^/]+)\/retry$/, targetPath: (pathname) => `/core${pathname}` },
|
||||
];
|
||||
|
||||
function resolveTarget(pathname, method) {
|
||||
const upperMethod = method.toUpperCase();
|
||||
|
||||
if (pathname.startsWith('/core')) {
|
||||
return {
|
||||
baseUrl: process.env.CORE_API_BASE_URL,
|
||||
upstreamPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/commands')) {
|
||||
return {
|
||||
baseUrl: process.env.COMMAND_API_BASE_URL,
|
||||
upstreamPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/query')) {
|
||||
return {
|
||||
baseUrl: process.env.QUERY_API_BASE_URL,
|
||||
upstreamPath: pathname,
|
||||
};
|
||||
}
|
||||
|
||||
for (const alias of DIRECT_CORE_ALIASES) {
|
||||
if (!alias.methods.has(upperMethod)) continue;
|
||||
const match = pathname.match(alias.pattern);
|
||||
if (!match) continue;
|
||||
return {
|
||||
baseUrl: process.env.CORE_API_BASE_URL,
|
||||
upstreamPath: alias.targetPath(pathname, match),
|
||||
};
|
||||
}
|
||||
|
||||
if ((upperMethod === 'GET' || upperMethod === 'HEAD') && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
|
||||
return {
|
||||
baseUrl: process.env.QUERY_API_BASE_URL,
|
||||
upstreamPath: `/query${pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (['POST', 'PUT', 'PATCH', 'DELETE'].includes(upperMethod) && (pathname.startsWith('/client') || pathname.startsWith('/staff'))) {
|
||||
return {
|
||||
baseUrl: process.env.COMMAND_API_BASE_URL,
|
||||
upstreamPath: `/commands${pathname}`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -30,13 +111,13 @@ function copyHeaders(source, target) {
|
||||
|
||||
async function forwardRequest(req, res, next, fetchImpl) {
|
||||
try {
|
||||
const requestPath = new URL(req.originalUrl, 'http://localhost').pathname;
|
||||
const baseUrl = resolveTargetBase(requestPath);
|
||||
if (!baseUrl) {
|
||||
throw new AppError('NOT_FOUND', `No upstream configured for ${requestPath}`, 404);
|
||||
const requestUrl = new URL(req.originalUrl, 'http://localhost');
|
||||
const target = resolveTarget(requestUrl.pathname, req.method);
|
||||
if (!target?.baseUrl) {
|
||||
throw new AppError('NOT_FOUND', `No upstream configured for ${requestUrl.pathname}`, 404);
|
||||
}
|
||||
|
||||
const url = new URL(req.originalUrl, baseUrl);
|
||||
const url = new URL(`${target.upstreamPath}${requestUrl.search}`, target.baseUrl);
|
||||
const headers = new Headers();
|
||||
for (const [key, value] of Object.entries(req.headers)) {
|
||||
if (value == null || HOP_BY_HOP_HEADERS.has(key.toLowerCase())) continue;
|
||||
@@ -69,7 +150,7 @@ export function createProxyRouter(options = {}) {
|
||||
const router = Router();
|
||||
const fetchImpl = options.fetchImpl || fetch;
|
||||
|
||||
router.use(['/core', '/commands', '/query'], (req, res, next) => forwardRequest(req, res, next, fetchImpl));
|
||||
router.use((req, res, next) => forwardRequest(req, res, next, fetchImpl));
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import { z } from 'zod';
|
||||
import { AppError } from '../lib/errors.js';
|
||||
import { withTransaction } from './db.js';
|
||||
import { verifyFirebaseToken, revokeUserSessions } from './firebase-auth.js';
|
||||
import { deleteAccount, signInWithPassword, signUpWithPassword } from './identity-toolkit.js';
|
||||
import {
|
||||
deleteAccount,
|
||||
sendVerificationCode,
|
||||
signInWithPassword,
|
||||
signInWithPhoneNumber,
|
||||
signUpWithPassword,
|
||||
} from './identity-toolkit.js';
|
||||
import { loadActorContext } from './user-context.js';
|
||||
|
||||
const clientSignInSchema = z.object({
|
||||
@@ -17,6 +23,30 @@ const clientSignUpSchema = z.object({
|
||||
displayName: z.string().min(2).max(120).optional(),
|
||||
});
|
||||
|
||||
const staffPhoneStartSchema = z.object({
|
||||
phoneNumber: z.string().min(6).max(40),
|
||||
recaptchaToken: z.string().min(1).optional(),
|
||||
iosReceipt: z.string().min(1).optional(),
|
||||
iosSecret: z.string().min(1).optional(),
|
||||
captchaResponse: z.string().min(1).optional(),
|
||||
playIntegrityToken: z.string().min(1).optional(),
|
||||
safetyNetToken: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
const staffPhoneVerifySchema = z.object({
|
||||
mode: z.enum(['sign-in', 'sign-up']).optional(),
|
||||
idToken: z.string().min(1).optional(),
|
||||
sessionInfo: z.string().min(1).optional(),
|
||||
code: z.string().min(1).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.idToken) return;
|
||||
if (value.sessionInfo && value.code) return;
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Provide idToken or sessionInfo and code',
|
||||
});
|
||||
});
|
||||
|
||||
function slugify(input) {
|
||||
return input
|
||||
.toLowerCase()
|
||||
@@ -40,9 +70,43 @@ function buildAuthEnvelope(authPayload, context) {
|
||||
business: context.business,
|
||||
vendor: context.vendor,
|
||||
staff: context.staff,
|
||||
requiresProfileSetup: !context.staff,
|
||||
};
|
||||
}
|
||||
|
||||
async function upsertUserFromDecodedToken(decoded, fallbackProfile = {}) {
|
||||
await withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`
|
||||
INSERT INTO users (id, email, display_name, phone, status, metadata)
|
||||
VALUES ($1, $2, $3, $4, 'ACTIVE', COALESCE($5::jsonb, '{}'::jsonb))
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET email = COALESCE(EXCLUDED.email, users.email),
|
||||
display_name = COALESCE(EXCLUDED.display_name, users.display_name),
|
||||
phone = COALESCE(EXCLUDED.phone, users.phone),
|
||||
metadata = COALESCE(users.metadata, '{}'::jsonb) || COALESCE(EXCLUDED.metadata, '{}'::jsonb),
|
||||
updated_at = NOW()
|
||||
`,
|
||||
[
|
||||
decoded.uid,
|
||||
decoded.email || fallbackProfile.email || null,
|
||||
decoded.name || fallbackProfile.displayName || fallbackProfile.email || decoded.phone_number || null,
|
||||
decoded.phone_number || fallbackProfile.phoneNumber || null,
|
||||
JSON.stringify({
|
||||
provider: decoded.firebase?.sign_in_provider || fallbackProfile.provider || null,
|
||||
}),
|
||||
]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function hydrateAuthContext(authPayload, fallbackProfile = {}) {
|
||||
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||
await upsertUserFromDecodedToken(decoded, fallbackProfile);
|
||||
const context = await loadActorContext(decoded.uid);
|
||||
return buildAuthEnvelope(authPayload, context);
|
||||
}
|
||||
|
||||
export function parseClientSignIn(body) {
|
||||
const parsed = clientSignInSchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
@@ -63,6 +127,26 @@ export function parseClientSignUp(body) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseStaffPhoneStart(body) {
|
||||
const parsed = staffPhoneStartSchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone start payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function parseStaffPhoneVerify(body) {
|
||||
const parsed = staffPhoneVerifySchema.safeParse(body || {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError('VALIDATION_ERROR', 'Invalid staff phone verify payload', 400, {
|
||||
issues: parsed.error.issues,
|
||||
});
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export async function getSessionForActor(actor) {
|
||||
return loadActorContext(actor.uid);
|
||||
}
|
||||
@@ -70,6 +154,7 @@ export async function getSessionForActor(actor) {
|
||||
export async function signInClient(payload, { fetchImpl = fetch } = {}) {
|
||||
const authPayload = await signInWithPassword(payload, fetchImpl);
|
||||
const decoded = await verifyFirebaseToken(authPayload.idToken);
|
||||
await upsertUserFromDecodedToken(decoded, payload);
|
||||
const context = await loadActorContext(decoded.uid);
|
||||
|
||||
if (!context.user || !context.business) {
|
||||
@@ -151,6 +236,68 @@ export async function signUpClient(payload, { fetchImpl = fetch } = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseClientSdkStaffFlow(payload) {
|
||||
return !payload.recaptchaToken && !payload.iosReceipt && !payload.captchaResponse && !payload.playIntegrityToken && !payload.safetyNetToken;
|
||||
}
|
||||
|
||||
export async function startStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
|
||||
if (shouldUseClientSdkStaffFlow(payload)) {
|
||||
return {
|
||||
mode: 'CLIENT_FIREBASE_SDK',
|
||||
provider: 'firebase-phone-auth',
|
||||
phoneNumber: payload.phoneNumber,
|
||||
nextStep: 'Complete phone verification in the mobile client, then call /auth/staff/phone/verify with the Firebase idToken.',
|
||||
};
|
||||
}
|
||||
|
||||
const authPayload = await sendVerificationCode(
|
||||
{
|
||||
phoneNumber: payload.phoneNumber,
|
||||
recaptchaToken: payload.recaptchaToken,
|
||||
iosReceipt: payload.iosReceipt,
|
||||
iosSecret: payload.iosSecret,
|
||||
captchaResponse: payload.captchaResponse,
|
||||
playIntegrityToken: payload.playIntegrityToken,
|
||||
safetyNetToken: payload.safetyNetToken,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
|
||||
return {
|
||||
mode: 'IDENTITY_TOOLKIT_SMS',
|
||||
phoneNumber: payload.phoneNumber,
|
||||
sessionInfo: authPayload.sessionInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export async function verifyStaffPhoneAuth(payload, { fetchImpl = fetch } = {}) {
|
||||
if (payload.idToken) {
|
||||
return hydrateAuthContext(
|
||||
{
|
||||
idToken: payload.idToken,
|
||||
refreshToken: null,
|
||||
expiresIn: 3600,
|
||||
},
|
||||
{
|
||||
provider: 'firebase-phone-auth',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const authPayload = await signInWithPhoneNumber(
|
||||
{
|
||||
sessionInfo: payload.sessionInfo,
|
||||
code: payload.code,
|
||||
operation: payload.mode === 'sign-up' ? 'SIGN_UP_OR_IN' : undefined,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
|
||||
return hydrateAuthContext(authPayload, {
|
||||
provider: 'firebase-phone-auth',
|
||||
});
|
||||
}
|
||||
|
||||
export async function signOutActor(actor) {
|
||||
await revokeUserSessions(actor.uid);
|
||||
return { signedOut: true };
|
||||
|
||||
@@ -16,3 +16,8 @@ export async function revokeUserSessions(uid) {
|
||||
ensureAdminApp();
|
||||
await getAuth().revokeRefreshTokens(uid);
|
||||
}
|
||||
|
||||
export async function createCustomToken(uid) {
|
||||
ensureAdminApp();
|
||||
return getAuth().createCustomToken(uid);
|
||||
}
|
||||
|
||||
@@ -56,6 +56,33 @@ export async function signUpWithPassword({ email, password }, fetchImpl = fetch)
|
||||
);
|
||||
}
|
||||
|
||||
export async function sendVerificationCode(payload, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:sendVerificationCode',
|
||||
payload,
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function signInWithPhoneNumber(payload, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signInWithPhoneNumber',
|
||||
payload,
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function signInWithCustomToken(payload, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:signInWithCustomToken',
|
||||
{
|
||||
token: payload.token,
|
||||
returnSecureToken: true,
|
||||
},
|
||||
fetchImpl
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAccount({ idToken }, fetchImpl = fetch) {
|
||||
return callIdentityToolkit(
|
||||
'accounts:delete',
|
||||
|
||||
@@ -110,3 +110,75 @@ test('proxy forwards query routes to query base url', async () => {
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(seenUrl, 'https://query.example/query/test-route?foo=bar');
|
||||
});
|
||||
|
||||
test('proxy forwards direct client read routes to query api', async () => {
|
||||
process.env.QUERY_API_BASE_URL = 'https://query.example';
|
||||
process.env.CORE_API_BASE_URL = 'https://core.example';
|
||||
process.env.COMMAND_API_BASE_URL = 'https://command.example';
|
||||
|
||||
let seenUrl = null;
|
||||
const app = createApp({
|
||||
fetchImpl: async (url) => {
|
||||
seenUrl = `${url}`;
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app).get('/client/dashboard');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(seenUrl, 'https://query.example/query/client/dashboard');
|
||||
});
|
||||
|
||||
test('proxy forwards direct client write routes to command api', async () => {
|
||||
process.env.QUERY_API_BASE_URL = 'https://query.example';
|
||||
process.env.CORE_API_BASE_URL = 'https://core.example';
|
||||
process.env.COMMAND_API_BASE_URL = 'https://command.example';
|
||||
|
||||
let seenUrl = null;
|
||||
const app = createApp({
|
||||
fetchImpl: async (url) => {
|
||||
seenUrl = `${url}`;
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/client/orders/one-time')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send({ ok: true });
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(seenUrl, 'https://command.example/commands/client/orders/one-time');
|
||||
});
|
||||
|
||||
test('proxy forwards direct core upload aliases to core api', async () => {
|
||||
process.env.QUERY_API_BASE_URL = 'https://query.example';
|
||||
process.env.CORE_API_BASE_URL = 'https://core.example';
|
||||
process.env.COMMAND_API_BASE_URL = 'https://command.example';
|
||||
|
||||
let seenUrl = null;
|
||||
const app = createApp({
|
||||
fetchImpl: async (url) => {
|
||||
seenUrl = `${url}`;
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.post('/staff/profile/certificates')
|
||||
.set('Authorization', 'Bearer test-token')
|
||||
.send({ ok: true });
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(seenUrl, 'https://core.example/core/staff/certificates/upload');
|
||||
});
|
||||
|
||||
61
backend/unified-api/test/staff-auth.test.js
Normal file
61
backend/unified-api/test/staff-auth.test.js
Normal file
@@ -0,0 +1,61 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import request from 'supertest';
|
||||
import { createApp } from '../src/app.js';
|
||||
|
||||
process.env.AUTH_BYPASS = 'true';
|
||||
|
||||
function createAuthService() {
|
||||
return {
|
||||
parseClientSignIn: (body) => body,
|
||||
parseClientSignUp: (body) => body,
|
||||
parseStaffPhoneStart: (body) => body,
|
||||
parseStaffPhoneVerify: (body) => body,
|
||||
signInClient: async () => assert.fail('signInClient should not be called'),
|
||||
signUpClient: async () => assert.fail('signUpClient should not be called'),
|
||||
signOutActor: async () => ({ signedOut: true }),
|
||||
getSessionForActor: async () => ({ user: { userId: 'u1' } }),
|
||||
startStaffPhoneAuth: async (payload) => ({
|
||||
mode: 'CLIENT_FIREBASE_SDK',
|
||||
phoneNumber: payload.phoneNumber,
|
||||
nextStep: 'continue in app',
|
||||
}),
|
||||
verifyStaffPhoneAuth: async (payload) => ({
|
||||
sessionToken: payload.idToken || 'token',
|
||||
refreshToken: 'refresh',
|
||||
expiresInSeconds: 3600,
|
||||
user: { id: 'staff-user' },
|
||||
tenant: { tenantId: 'tenant-1' },
|
||||
vendor: { vendorId: 'vendor-1' },
|
||||
staff: { staffId: 'staff-1' },
|
||||
requiresProfileSetup: false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('POST /auth/staff/phone/start returns injected start payload', async () => {
|
||||
const app = createApp({ authService: createAuthService() });
|
||||
const res = await request(app)
|
||||
.post('/auth/staff/phone/start')
|
||||
.send({
|
||||
phoneNumber: '+15555550123',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.mode, 'CLIENT_FIREBASE_SDK');
|
||||
assert.equal(res.body.phoneNumber, '+15555550123');
|
||||
});
|
||||
|
||||
test('POST /auth/staff/phone/verify returns injected auth envelope', async () => {
|
||||
const app = createApp({ authService: createAuthService() });
|
||||
const res = await request(app)
|
||||
.post('/auth/staff/phone/verify')
|
||||
.send({
|
||||
idToken: 'firebase-id-token',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(res.body.sessionToken, 'firebase-id-token');
|
||||
assert.equal(res.body.staff.staffId, 'staff-1');
|
||||
assert.equal(res.body.requiresProfileSetup, false);
|
||||
});
|
||||
Reference in New Issue
Block a user