|
1 | 1 | import { describe, expect, it } from 'vitest'; |
2 | | -import { lintDataModel } from '../src/lint/data-model-rules'; |
| 2 | +import { lintDataModel, lintUniqueDeclarations } from '../src/lint/data-model-rules'; |
3 | 3 | import { lintConfig } from '../src/commands/lint'; |
4 | 4 |
|
5 | 5 | const rulesOf = (issues: { rule: string }[]) => issues.map((i) => i.rule); |
@@ -173,3 +173,119 @@ describe('lintConfig integration', () => { |
173 | 173 | expect(dataModel.filter((i) => i.severity !== 'suggestion')).toEqual([]); |
174 | 174 | }); |
175 | 175 | }); |
| 176 | + |
| 177 | +// #3991 — the same column declared unique twice, in two spellings that mean |
| 178 | +// different things. One of the two intents is always discarded. |
| 179 | +describe('lintUniqueDeclarations — contradictory uniqueness (#3991)', () => { |
| 180 | + const RULE = 'unique/double-declaration'; |
| 181 | + |
| 182 | + const withBoth = [ |
| 183 | + { |
| 184 | + name: 'crm_contact', |
| 185 | + fields: { email: { type: 'email', unique: true } }, |
| 186 | + indexes: [{ fields: ['email'], unique: true }], |
| 187 | + }, |
| 188 | + ]; |
| 189 | + |
| 190 | + it('returns [] for empty input', () => { |
| 191 | + expect(lintUniqueDeclarations([])).toEqual([]); |
| 192 | + expect(lintUniqueDeclarations(undefined as any)).toEqual([]); |
| 193 | + }); |
| 194 | + |
| 195 | + it('flags field-level unique + a single-column unique index on the same column', () => { |
| 196 | + const issues = lintUniqueDeclarations(withBoth); |
| 197 | + expect(issues).toHaveLength(1); |
| 198 | + expect(issues[0].rule).toBe(RULE); |
| 199 | + expect(issues[0].severity).toBe('warning'); // advisory — never fails a build |
| 200 | + expect(issues[0].message).toContain('crm_contact.email'); |
| 201 | + // The message must name BOTH readings, since tenancy is not inferred here. |
| 202 | + expect(issues[0].message).toMatch(/per tenant|tenant/i); |
| 203 | + expect(issues[0].message).toMatch(/platform-wide/i); |
| 204 | + // And the fix must spell out both ways to resolve it. |
| 205 | + expect(issues[0].fix).toContain("unique: 'global'"); |
| 206 | + expect(issues[0].fix).toContain('organization_id'); |
| 207 | + }); |
| 208 | + |
| 209 | + it('surfaces through lintDataModel too, so `os lint` reports it', () => { |
| 210 | + expect(has(lintDataModel(withBoth), RULE)).toBe(true); |
| 211 | + }); |
| 212 | + |
| 213 | + // ── Shapes that must stay quiet ────────────────────────────────────── |
| 214 | + |
| 215 | + it("exempts unique: 'global' — the index restates the intent, it does not lose it", () => { |
| 216 | + const issues = lintUniqueDeclarations([ |
| 217 | + { |
| 218 | + name: 'runtime', |
| 219 | + fields: { hostname: { type: 'text', unique: 'global' } }, |
| 220 | + indexes: [{ fields: ['hostname'], unique: true }], |
| 221 | + }, |
| 222 | + ]); |
| 223 | + expect(issues).toEqual([]); |
| 224 | + }); |
| 225 | + |
| 226 | + it('exempts an explicit tenant COMPOSITE index — that agrees with the field-level default', () => { |
| 227 | + const issues = lintUniqueDeclarations([ |
| 228 | + { |
| 229 | + name: 'crm_contact', |
| 230 | + fields: { email: { type: 'email', unique: true } }, |
| 231 | + indexes: [{ fields: ['organization_id', 'email'], unique: true }], |
| 232 | + }, |
| 233 | + ]); |
| 234 | + expect(issues).toEqual([]); |
| 235 | + }); |
| 236 | + |
| 237 | + it('ignores a NON-unique index on the same column', () => { |
| 238 | + const issues = lintUniqueDeclarations([ |
| 239 | + { |
| 240 | + name: 'crm_contact', |
| 241 | + fields: { email: { type: 'email', unique: true } }, |
| 242 | + indexes: [{ fields: ['email'] }], |
| 243 | + }, |
| 244 | + ]); |
| 245 | + expect(issues).toEqual([]); |
| 246 | + }); |
| 247 | + |
| 248 | + it('ignores a unique index on a DIFFERENT column', () => { |
| 249 | + const issues = lintUniqueDeclarations([ |
| 250 | + { |
| 251 | + name: 'crm_contact', |
| 252 | + fields: { email: { type: 'email', unique: true }, code: { type: 'text' } }, |
| 253 | + indexes: [{ fields: ['code'], unique: true }], |
| 254 | + }, |
| 255 | + ]); |
| 256 | + expect(issues).toEqual([]); |
| 257 | + }); |
| 258 | + |
| 259 | + it('is quiet when only one of the two spellings is used', () => { |
| 260 | + expect(lintUniqueDeclarations([ |
| 261 | + { name: 'a', fields: { email: { type: 'email', unique: true } } }, |
| 262 | + ])).toEqual([]); |
| 263 | + expect(lintUniqueDeclarations([ |
| 264 | + { name: 'b', fields: { email: { type: 'email' } }, indexes: [{ fields: ['email'], unique: true }] }, |
| 265 | + ])).toEqual([]); |
| 266 | + }); |
| 267 | + |
| 268 | + it('names the declared index when it carries an explicit name', () => { |
| 269 | + const issues = lintUniqueDeclarations([ |
| 270 | + { |
| 271 | + name: 'crm_product', |
| 272 | + fields: { sku: { type: 'text', unique: true } }, |
| 273 | + indexes: [{ name: 'uniq_product_sku', fields: ['sku'], unique: true }], |
| 274 | + }, |
| 275 | + ]); |
| 276 | + expect(issues[0].message).toContain("'uniq_product_sku'"); |
| 277 | + }); |
| 278 | + |
| 279 | + it('reports each offending column once, across several objects', () => { |
| 280 | + const issues = lintUniqueDeclarations([ |
| 281 | + ...withBoth, |
| 282 | + { |
| 283 | + name: 'crm_lead', |
| 284 | + fields: { email: { type: 'email', unique: true }, sku: { type: 'text', unique: true } }, |
| 285 | + indexes: [{ fields: ['email'], unique: true }, { fields: ['sku'], unique: true }], |
| 286 | + }, |
| 287 | + ]); |
| 288 | + expect(issues.map((i) => i.message.match(/"([^"]+)"/)?.[1]).sort()) |
| 289 | + .toEqual(['crm_contact.email', 'crm_lead.email', 'crm_lead.sku']); |
| 290 | + }); |
| 291 | +}); |
0 commit comments